|
| 1 | +"""Heading-based chunking for incremental translation. |
| 2 | +
|
| 3 | +Splits markdown files at ``## ``-level headings and compares old vs new |
| 4 | +English chunks to determine which sections actually changed. Unchanged |
| 5 | +sections reuse the existing translation, avoiding expensive LLM calls. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import hashlib |
| 11 | +import re |
| 12 | +from dataclasses import dataclass, field |
| 13 | +from difflib import SequenceMatcher |
| 14 | + |
| 15 | +from .postprocess import CODE_FENCE_RE |
| 16 | + |
| 17 | + |
| 18 | +# --------------------------------------------------------------------------- |
| 19 | +# Data structures |
| 20 | +# --------------------------------------------------------------------------- |
| 21 | + |
| 22 | + |
| 23 | +@dataclass |
| 24 | +class Chunk: |
| 25 | + """A section of a markdown file delimited by ``## `` headings.""" |
| 26 | + |
| 27 | + heading: str |
| 28 | + """The heading line itself (e.g. ``## 2. Publish outputs``), or empty |
| 29 | + string for the preamble (chunk 0).""" |
| 30 | + |
| 31 | + content: str |
| 32 | + """Full text of the chunk **including** the heading line.""" |
| 33 | + |
| 34 | + index: int |
| 35 | + """Position of this chunk in the file (0-based).""" |
| 36 | + |
| 37 | + content_hash: str = field(default="", repr=False) |
| 38 | + """MD5 hex digest of *content* (set automatically by ``split_into_chunks``).""" |
| 39 | + |
| 40 | + |
| 41 | +@dataclass |
| 42 | +class ChunkDiff: |
| 43 | + """Result of comparing old and new chunk lists.""" |
| 44 | + |
| 45 | + unchanged: list[tuple[int, int]] |
| 46 | + """(old_idx, new_idx) pairs for chunks with identical content.""" |
| 47 | + |
| 48 | + modified: list[tuple[int, int]] |
| 49 | + """(old_idx, new_idx) pairs for chunks whose content changed.""" |
| 50 | + |
| 51 | + added: list[int] |
| 52 | + """new_idx values with no match in the old list.""" |
| 53 | + |
| 54 | + removed: list[int] |
| 55 | + """old_idx values with no match in the new list.""" |
| 56 | + |
| 57 | + |
| 58 | +# --------------------------------------------------------------------------- |
| 59 | +# Splitting |
| 60 | +# --------------------------------------------------------------------------- |
| 61 | + |
| 62 | + |
| 63 | +def _content_hash(text: str) -> str: |
| 64 | + """Hash content with trailing whitespace stripped for stable comparison. |
| 65 | +
|
| 66 | + Trailing blank lines shift between chunks when sections are added or |
| 67 | + removed, so we strip them before hashing to avoid false "modified" |
| 68 | + classifications. |
| 69 | + """ |
| 70 | + return hashlib.md5(text.rstrip().encode()).hexdigest()[:12] |
| 71 | + |
| 72 | + |
| 73 | +def split_into_chunks(text: str) -> list[Chunk]: |
| 74 | + """Split markdown *text* into chunks at ``## `` headings. |
| 75 | +
|
| 76 | + * Chunk 0 is the "preamble" — everything before the first ``## ``. |
| 77 | + * Each subsequent chunk starts at a ``## `` line (outside code blocks) |
| 78 | + and includes all content up to (but not including) the next ``## ``. |
| 79 | + * ``## `` lines inside fenced code blocks are ignored. |
| 80 | + """ |
| 81 | + lines = text.split("\n") |
| 82 | + chunks: list[Chunk] = [] |
| 83 | + current_lines: list[str] = [] |
| 84 | + current_heading = "" |
| 85 | + in_code = False |
| 86 | + fence = "" |
| 87 | + |
| 88 | + def _flush(): |
| 89 | + content = "\n".join(current_lines) |
| 90 | + chunks.append( |
| 91 | + Chunk( |
| 92 | + heading=current_heading, |
| 93 | + content=content, |
| 94 | + index=len(chunks), |
| 95 | + content_hash=_content_hash(content), |
| 96 | + ) |
| 97 | + ) |
| 98 | + |
| 99 | + for line in lines: |
| 100 | + stripped = line.lstrip() |
| 101 | + |
| 102 | + # Track code fences |
| 103 | + if not in_code: |
| 104 | + if m := CODE_FENCE_RE.match(stripped): |
| 105 | + in_code, fence = True, m.group(1) |
| 106 | + elif stripped.startswith(fence) and stripped.strip() == fence: |
| 107 | + in_code, fence = False, "" |
| 108 | + |
| 109 | + # Start a new chunk at each ## heading (outside code blocks) |
| 110 | + if not in_code and line.startswith("## "): |
| 111 | + _flush() |
| 112 | + current_lines = [line] |
| 113 | + current_heading = line |
| 114 | + else: |
| 115 | + current_lines.append(line) |
| 116 | + |
| 117 | + _flush() |
| 118 | + return chunks |
| 119 | + |
| 120 | + |
| 121 | +def reassemble_chunks(chunks: list[Chunk]) -> str: |
| 122 | + """Reassemble chunks into a single document string.""" |
| 123 | + return "\n".join(c.content for c in chunks) |
| 124 | + |
| 125 | + |
| 126 | +# --------------------------------------------------------------------------- |
| 127 | +# Diffing |
| 128 | +# --------------------------------------------------------------------------- |
| 129 | + |
| 130 | + |
| 131 | +def _normalize_heading(heading: str) -> str: |
| 132 | + """Normalize heading text for comparison (strip whitespace, anchors).""" |
| 133 | + # Remove trailing anchor IDs like { #some-id } |
| 134 | + h = re.sub(r"\s*\{[^}]*\}\s*$", "", heading) |
| 135 | + return h.strip() |
| 136 | + |
| 137 | + |
| 138 | +def diff_chunks(old_chunks: list[Chunk], new_chunks: list[Chunk]) -> ChunkDiff: |
| 139 | + """Compare old and new chunk lists to identify changes. |
| 140 | +
|
| 141 | + Matching strategy (applied in order): |
| 142 | + 1. **Heading match**: exact heading text (after normalization). |
| 143 | + 2. **Content hash match**: same content, different heading (detects |
| 144 | + renames/reorders without content changes). |
| 145 | + 3. **Fuzzy heading match**: heading similarity >0.8 via SequenceMatcher |
| 146 | + (detects heading renames with content changes). |
| 147 | + """ |
| 148 | + unchanged: list[tuple[int, int]] = [] |
| 149 | + modified: list[tuple[int, int]] = [] |
| 150 | + |
| 151 | + # Track which chunks have been matched |
| 152 | + matched_old: set[int] = set() |
| 153 | + matched_new: set[int] = set() |
| 154 | + |
| 155 | + # Build lookup maps |
| 156 | + old_by_heading: dict[str, list[int]] = {} |
| 157 | + for c in old_chunks: |
| 158 | + key = _normalize_heading(c.heading) |
| 159 | + old_by_heading.setdefault(key, []).append(c.index) |
| 160 | + |
| 161 | + old_by_hash: dict[str, list[int]] = {} |
| 162 | + for c in old_chunks: |
| 163 | + old_by_hash.setdefault(c.content_hash, []).append(c.index) |
| 164 | + |
| 165 | + # Pass 1: exact heading match |
| 166 | + for nc in new_chunks: |
| 167 | + key = _normalize_heading(nc.heading) |
| 168 | + candidates = old_by_heading.get(key, []) |
| 169 | + for oi in candidates: |
| 170 | + if oi not in matched_old: |
| 171 | + matched_old.add(oi) |
| 172 | + matched_new.add(nc.index) |
| 173 | + if old_chunks[oi].content_hash == nc.content_hash: |
| 174 | + unchanged.append((oi, nc.index)) |
| 175 | + else: |
| 176 | + modified.append((oi, nc.index)) |
| 177 | + break |
| 178 | + |
| 179 | + # Pass 2: content hash match (for reordered/renamed chunks) |
| 180 | + for nc in new_chunks: |
| 181 | + if nc.index in matched_new: |
| 182 | + continue |
| 183 | + candidates = old_by_hash.get(nc.content_hash, []) |
| 184 | + for oi in candidates: |
| 185 | + if oi not in matched_old: |
| 186 | + matched_old.add(oi) |
| 187 | + matched_new.add(nc.index) |
| 188 | + unchanged.append((oi, nc.index)) |
| 189 | + break |
| 190 | + |
| 191 | + # Pass 3: fuzzy heading match |
| 192 | + unmatched_old = [c for c in old_chunks if c.index not in matched_old] |
| 193 | + unmatched_new = [c for c in new_chunks if c.index not in matched_new] |
| 194 | + |
| 195 | + for nc in list(unmatched_new): |
| 196 | + best_score = 0.0 |
| 197 | + best_oc = None |
| 198 | + nc_heading = _normalize_heading(nc.heading) |
| 199 | + for oc in unmatched_old: |
| 200 | + oc_heading = _normalize_heading(oc.heading) |
| 201 | + score = SequenceMatcher(None, oc_heading, nc_heading).ratio() |
| 202 | + if score > best_score: |
| 203 | + best_score = score |
| 204 | + best_oc = oc |
| 205 | + if best_oc is not None and best_score > 0.8: |
| 206 | + matched_old.add(best_oc.index) |
| 207 | + matched_new.add(nc.index) |
| 208 | + unmatched_old.remove(best_oc) |
| 209 | + unmatched_new.remove(nc) |
| 210 | + if best_oc.content_hash == nc.content_hash: |
| 211 | + unchanged.append((best_oc.index, nc.index)) |
| 212 | + else: |
| 213 | + modified.append((best_oc.index, nc.index)) |
| 214 | + |
| 215 | + # Remaining unmatched |
| 216 | + added = [nc.index for nc in unmatched_new] |
| 217 | + removed = [oc.index for oc in unmatched_old] |
| 218 | + |
| 219 | + return ChunkDiff( |
| 220 | + unchanged=unchanged, |
| 221 | + modified=modified, |
| 222 | + added=added, |
| 223 | + removed=removed, |
| 224 | + ) |
0 commit comments