sofhiaazzhr Claude Opus 5 commited on
Commit
e047f7a
·
1 Parent(s): 5fed261

[NOTICKET] fix(knowledge-parsing): read word boundaries back from the source PDF

Browse files

MinerU writes formulas one character at a time ("T o t a l H o u r s"), and
once those are rejoined the word boundaries are gone: "Total Hours" and
"TotalHours" are no longer distinguishable from the LaTeX alone. Everything
fuses into a single token.

The multiplication written as a letter "x" is not a separate bug. It is the
same one — an "x" between terms is just another spaced character, so
"MOHH x Qty x PA x UA x Pty" collapses to "MOHHxQtyxPAxUAxPty".

Two obvious fixes were tried and rejected:

- Rewriting a lone "x" to a multiplication sign guesses, and guesses wrong
on any document with a variable named x, silently. It also leaves
"TotalHours" untouched, which is the larger half of the damage.
- Emitting a warning fires on 7 of 11 equations. A warning that lights up on
64% of items is not actionable, and recovers nothing.

The source PDF still holds the boundaries: its text layer reads
"MOHH x Qty x PA x UA x Pty", properly spaced. So the answer is read from the
document rather than guessed. A token that is not already a word on that page
is split only if EVERY piece is a word on that page, and only if exactly one
shortest such split exists. Ambiguity is left alone; so is anything that
cannot be segmented. It fails closed.

NFKC normalisation is required — formulas use a mathematical font, so the page
literally contains U+1D440 and friends, and without it nothing matches.

Correct documents are untouched by construction: a token already present as a
word never enters the segmenter, so a document writing \times produces tokens
that are all in the vocabulary and skips this path entirely. Verified — the
second test document changed in no way at all.

Threshold measured, not picked: 6, 4 and 3 are identical on equations, and 3
additionally recovers "MOHHx" and "xPA_A" in table cells. 2 starts doing
damage, splitting "PA_ij" into "PA_i j".

Result on the 9-page BUMA standard: glossary terms readable as whole tokens
320 -> 397, across 9 of 31 chunks. Chunk.latex is untouched, so the formula
branch still receives the original form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

src/knowledge_parsing/checks.py CHANGED
@@ -12,6 +12,7 @@ turun diam-diam di antara ratusan dokumen.
12
  from __future__ import annotations
13
 
14
  import re
 
15
  from collections import Counter
16
  from pathlib import Path
17
  from typing import Any
@@ -64,6 +65,39 @@ def _angka_per_halaman(pdf: Path, min_digit: int = MIN_DIGIT) -> dict[int, set[s
64
  return hasil
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def periksa_items(
68
  items: list[dict[str, Any]],
69
  min_latex_len: int = 8,
 
12
  from __future__ import annotations
13
 
14
  import re
15
+ import unicodedata
16
  from collections import Counter
17
  from pathlib import Path
18
  from typing import Any
 
65
  return hasil
66
 
67
 
68
+ _KATA = re.compile(r"[A-Za-z][A-Za-z0-9]*")
69
+
70
+
71
+ def kosakata_per_halaman(pdf: Path) -> dict[int, frozenset[str]] | None:
72
+ """Kata-kata pada lapisan teks PDF asli, per halaman.
73
+
74
+ Dipakai `render.pulihkan_batas_kata` sebagai wasit: MinerU menulis rumus per
75
+ karakter dan batas katanya hilang di situ, sementara PDF-nya masih menyimpan
76
+ batas itu utuh (`MOHH x Qty x PA`, bukan `MOHHxQtyxPAxUAxPty`).
77
+
78
+ NFKC dipakai karena rumus memakai font matematika — halaman ini menulis
79
+ `𝑀𝑂𝐻𝐻` (U+1D440…), bukan ASCII, dan tanpa normalisasi tidak ada satu kata
80
+ pun yang akan cocok.
81
+
82
+ Kembalikan None kalau PDF tidak terbaca; pemanggil lalu melewati pemulihan
83
+ diam-diam, sama seperti pemeriksaan angka di bawah.
84
+ """
85
+ try:
86
+ from pypdf import PdfReader
87
+ reader = PdfReader(str(pdf))
88
+ except Exception:
89
+ return None
90
+
91
+ hasil: dict[int, frozenset[str]] = {}
92
+ for i, hal in enumerate(reader.pages):
93
+ try:
94
+ teks = hal.extract_text() or ""
95
+ except Exception:
96
+ teks = ""
97
+ hasil[i] = frozenset(_KATA.findall(unicodedata.normalize("NFKC", teks)))
98
+ return hasil
99
+
100
+
101
  def periksa_items(
102
  items: list[dict[str, Any]],
103
  min_latex_len: int = 8,
src/knowledge_parsing/normalize.py CHANGED
@@ -91,7 +91,7 @@ def _judul(item: dict[str, Any]) -> tuple[str | None, str | None, int | None]:
91
  return m.group(1), m.group(2), m.group(1).count(".") + 1
92
 
93
 
94
- def _teks_tabel(item: dict[str, Any]) -> str:
95
  """Caption + isi tabel sebagai teks terbaca.
96
 
97
  HTML mentahnya TIDAK ditaruh di sini — disimpan terpisah di
@@ -99,7 +99,7 @@ def _teks_tabel(item: dict[str, Any]) -> str:
99
  """
100
  bagian = list(item.get("table_caption") or [])
101
  if item.get("table_body"):
102
- bagian.append(render_table(item["table_body"]))
103
  bagian += list(item.get("table_footnote") or [])
104
  return "\n\n".join(b for b in bagian if b)
105
 
@@ -112,7 +112,21 @@ def _teks_chart(item: dict[str, Any]) -> str:
112
  return "\n\n".join(b for b in bagian if b)
113
 
114
 
115
- def normalisasi(items: list[dict[str, Any]], doc_id: str) -> list[Chunk]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  # Konteks bab per halaman, dari header berjalan
117
  bab_per_halaman: dict[int, str] = {}
118
  for x in items:
@@ -160,7 +174,7 @@ def normalisasi(items: list[dict[str, Any]], doc_id: str) -> list[Chunk]:
160
  if tipe == "table":
161
  tutup()
162
  c = buka("table", page)
163
- c.text = _teks_tabel(item)
164
  c.is_tabular = True
165
  c.table_html = item.get("table_body") or None
166
  c.source_items = [i]
@@ -193,7 +207,7 @@ def normalisasi(items: list[dict[str, Any]], doc_id: str) -> list[Chunk]:
193
  berjalan.images.append(item["img_path"])
194
  if latex:
195
  berjalan.latex.append(latex) # mentah, untuk cabang formula
196
- terbaca = render_latex(latex)
197
  if terbaca:
198
  potongan.append(terbaca) # prosa, supaya NER menemukannya
199
  continue
@@ -262,6 +276,10 @@ def normalisasi(items: list[dict[str, Any]], doc_id: str) -> list[Chunk]:
262
  return chunks
263
 
264
 
265
- def normalisasi_dari_berkas(content_list: Path, doc_id: str) -> list[Chunk]:
 
 
 
 
266
  items = json.loads(content_list.read_text(encoding="utf-8"))
267
- return normalisasi(items, doc_id)
 
91
  return m.group(1), m.group(2), m.group(1).count(".") + 1
92
 
93
 
94
+ def _teks_tabel(item: dict[str, Any], kosakata: frozenset[str] | None = None) -> str:
95
  """Caption + isi tabel sebagai teks terbaca.
96
 
97
  HTML mentahnya TIDAK ditaruh di sini — disimpan terpisah di
 
99
  """
100
  bagian = list(item.get("table_caption") or [])
101
  if item.get("table_body"):
102
+ bagian.append(render_table(item["table_body"], kosakata))
103
  bagian += list(item.get("table_footnote") or [])
104
  return "\n\n".join(b for b in bagian if b)
105
 
 
112
  return "\n\n".join(b for b in bagian if b)
113
 
114
 
115
+ def normalisasi(
116
+ items: list[dict[str, Any]],
117
+ doc_id: str,
118
+ kosakata_halaman: dict[int, frozenset[str]] | None = None,
119
+ ) -> list[Chunk]:
120
+ """`kosakata_halaman` opsional: kata-kata lapisan teks PDF asli per halaman.
121
+
122
+ Dipakai hanya untuk memulihkan batas kata yang hilang saat MinerU menulis
123
+ rumus per karakter (lihat `render.pulihkan_batas_kata`). Tanpa itu, hasilnya
124
+ persis seperti sebelumnya.
125
+ """
126
+
127
+ def kosakata_dari(hal: int) -> frozenset[str] | None:
128
+ return (kosakata_halaman or {}).get(hal)
129
+
130
  # Konteks bab per halaman, dari header berjalan
131
  bab_per_halaman: dict[int, str] = {}
132
  for x in items:
 
174
  if tipe == "table":
175
  tutup()
176
  c = buka("table", page)
177
+ c.text = _teks_tabel(item, kosakata_dari(page))
178
  c.is_tabular = True
179
  c.table_html = item.get("table_body") or None
180
  c.source_items = [i]
 
207
  berjalan.images.append(item["img_path"])
208
  if latex:
209
  berjalan.latex.append(latex) # mentah, untuk cabang formula
210
+ terbaca = render_latex(latex, kosakata_dari(page))
211
  if terbaca:
212
  potongan.append(terbaca) # prosa, supaya NER menemukannya
213
  continue
 
276
  return chunks
277
 
278
 
279
+ def normalisasi_dari_berkas(
280
+ content_list: Path,
281
+ doc_id: str,
282
+ kosakata_halaman: dict[int, frozenset[str]] | None = None,
283
+ ) -> list[Chunk]:
284
  items = json.loads(content_list.read_text(encoding="utf-8"))
285
+ return normalisasi(items, doc_id, kosakata_halaman)
src/knowledge_parsing/render.py CHANGED
@@ -71,6 +71,86 @@ def _rapatkan(teks: str) -> str:
71
  return _SPACED_RUN.sub(lambda m: m.group(0).replace(" ", ""), teks)
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def _argumen(s: str, i: int) -> tuple[str | None, int]:
75
  """Isi kurung SEIMBANG yang dimulai di `s[i] == '{'`.
76
 
@@ -122,8 +202,14 @@ def _render_frac(s: str) -> str:
122
  return s
123
 
124
 
125
- def render_latex(latex: str) -> str:
126
- """LaTeX -> a readable line. Best effort: it feeds a term filter, not a parser."""
 
 
 
 
 
 
127
  s = latex.replace("$$", " ").replace("$", " ")
128
  s = s.replace("~", _WORD_GAP)
129
 
@@ -169,7 +255,11 @@ def render_latex(latex: str) -> str:
169
  s = _rapatkan(s)
170
 
171
  s = s.replace(_WORD_GAP, " ")
172
- return re.sub(r"\s+", " ", s).strip()
 
 
 
 
173
 
174
 
175
  # --- HTML tables ---------------------------------------------------------
@@ -184,7 +274,7 @@ _TAG = re.compile(r"<[^>]+>")
184
  _INLINE_MATH = re.compile(r"\$([^$]{1,400})\$")
185
 
186
 
187
- def render_table(table_html: str) -> str:
188
  """HTML table -> plain rows, cells separated by ' | '.
189
 
190
  Keeps the reading order a person would use, so a term appearing only in a
@@ -194,7 +284,7 @@ def render_table(table_html: str) -> str:
194
  s = _CELL_END.sub(" | ", s)
195
  s = _TAG.sub(" ", s)
196
  s = html.unescape(s)
197
- s = _INLINE_MATH.sub(lambda m: " " + render_latex(m.group(1)) + " ", s)
198
 
199
  baris = []
200
  for raw in s.split("\n"):
 
71
  return _SPACED_RUN.sub(lambda m: m.group(0).replace(" ", ""), teks)
72
 
73
 
74
+ # Panjang minimum token yang boleh dibelah. Diukur pada dokumen nyata: 6, 4 dan
75
+ # 3 memberi hasil identik pada rumus, dan 3 juga memulihkan `MOHHx` / `xPA_A` di
76
+ # sel tabel. Turun ke 2 mulai MERUSAK — `PA_ij` terbelah jadi `PA_i j`, karena
77
+ # `i` dan `j` dua-duanya kata yang sah di halaman itu.
78
+ MIN_TOKEN_SEGMEN = 3
79
+ _MAKS_KATA = 24 # panjang wajar satu kata; membatasi kerja DP
80
+
81
+ _TOKEN = re.compile(r"[A-Za-z][A-Za-z0-9]*")
82
+
83
+
84
+ def _segmentasi(tok: str, kosakata: frozenset[str]) -> list[str] | None:
85
+ """Belah `tok` menjadi kata-kata yang SEMUANYA ada di `kosakata`.
86
+
87
+ Kembalikan None kalau tidak ada pembelahan yang sah, ATAU kalau ada lebih
88
+ dari satu pembelahan terpendek yang sama sahnya. Ambiguitas dibiarkan apa
89
+ adanya — modul ini tidak menebak, dan sebuah token yang tidak bisa
90
+ dipulihkan dengan pasti lebih baik dibiarkan rusak daripada diperbaiki
91
+ ke bentuk yang salah tanpa jejak.
92
+ """
93
+ n = len(tok)
94
+ # dp[i] = (jumlah kata minimum untuk tok[:i], berapa cara mencapainya, potongan)
95
+ dp: list[tuple[int, int, list[str]] | None] = [None] * (n + 1)
96
+ dp[0] = (0, 1, [])
97
+
98
+ for i in range(1, n + 1):
99
+ terbaik: int | None = None
100
+ cara = 0
101
+ potongan: list[str] = []
102
+ for j in range(max(0, i - _MAKS_KATA), i):
103
+ sebelum = dp[j]
104
+ if sebelum is None or tok[j:i] not in kosakata:
105
+ continue
106
+ panjang_j, cara_j, kata_j = sebelum
107
+ calon = panjang_j + 1
108
+ if terbaik is None or calon < terbaik:
109
+ terbaik, cara, potongan = calon, cara_j, kata_j + [tok[j:i]]
110
+ elif calon == terbaik:
111
+ cara += cara_j
112
+ if terbaik is not None:
113
+ dp[i] = (terbaik, cara, potongan)
114
+
115
+ hasil = dp[n]
116
+ if hasil is None:
117
+ return None
118
+ _, cara, potongan = hasil
119
+ if cara != 1 or len(potongan) < 2:
120
+ return None
121
+ return potongan
122
+
123
+
124
+ def pulihkan_batas_kata(teks: str, kosakata: frozenset[str] | None) -> str:
125
+ """Kembalikan spasi antar kata yang hilang saat deret berspasi dirapatkan.
126
+
127
+ MinerU menulis rumus per karakter (`T o t a l H o u r s`), dan begitu
128
+ dirapatkan batas katanya tidak bisa dipulihkan dari LaTeX-nya sendiri:
129
+ `Total Hours` dan `TotalHours` menjadi tidak terbedakan. Tanda kali yang
130
+ ditulis sebagai huruf `x` ikut melebur karena bentuknya sama saja dengan
131
+ karakter berspasi lain.
132
+
133
+ Lapisan teks PDF aslinya MASIH menyimpan batas itu, jadi jawabannya dibaca
134
+ dari dokumen alih-alih ditebak. Token yang sudah ada sebagai kata di sumber
135
+ tidak pernah disentuh — sehingga dokumen yang menulis rumusnya dengan benar
136
+ melewati jalur ini seluruhnya.
137
+ """
138
+ if not kosakata:
139
+ return teks
140
+
141
+ ganti: dict[str, str] = {}
142
+ for tok in set(_TOKEN.findall(teks)):
143
+ if len(tok) < MIN_TOKEN_SEGMEN or tok in kosakata:
144
+ continue
145
+ potongan = _segmentasi(tok, kosakata)
146
+ if potongan:
147
+ ganti[tok] = " ".join(potongan)
148
+
149
+ for tok, jadi in sorted(ganti.items(), key=lambda kv: -len(kv[0])):
150
+ teks = re.sub(rf"(?<![A-Za-z0-9]){re.escape(tok)}(?![A-Za-z0-9])", jadi, teks)
151
+ return teks
152
+
153
+
154
  def _argumen(s: str, i: int) -> tuple[str | None, int]:
155
  """Isi kurung SEIMBANG yang dimulai di `s[i] == '{'`.
156
 
 
202
  return s
203
 
204
 
205
+ def render_latex(latex: str, kosakata: frozenset[str] | None = None) -> str:
206
+ """LaTeX -> a readable line. Best effort: it feeds a term filter, not a parser.
207
+
208
+ `kosakata` is the source page's own word list, used only to put back word
209
+ boundaries this function cannot recover on its own — see
210
+ `pulihkan_batas_kata`. Omitting it reproduces the previous behaviour
211
+ exactly, so existing callers are unaffected.
212
+ """
213
  s = latex.replace("$$", " ").replace("$", " ")
214
  s = s.replace("~", _WORD_GAP)
215
 
 
255
  s = _rapatkan(s)
256
 
257
  s = s.replace(_WORD_GAP, " ")
258
+ s = re.sub(r"\s+", " ", s).strip()
259
+
260
+ # Terakhir, setelah spasi normal kembali: pulihkan batas kata yang tidak
261
+ # bisa diketahui dari LaTeX-nya sendiri.
262
+ return pulihkan_batas_kata(s, kosakata)
263
 
264
 
265
  # --- HTML tables ---------------------------------------------------------
 
274
  _INLINE_MATH = re.compile(r"\$([^$]{1,400})\$")
275
 
276
 
277
+ def render_table(table_html: str, kosakata: frozenset[str] | None = None) -> str:
278
  """HTML table -> plain rows, cells separated by ' | '.
279
 
280
  Keeps the reading order a person would use, so a term appearing only in a
 
284
  s = _CELL_END.sub(" | ", s)
285
  s = _TAG.sub(" ", s)
286
  s = html.unescape(s)
287
+ s = _INLINE_MATH.sub(lambda m: " " + render_latex(m.group(1), kosakata) + " ", s)
288
 
289
  baris = []
290
  for raw in s.split("\n"):
src/knowledge_parsing/run.py CHANGED
@@ -20,7 +20,7 @@ import traceback
20
  from datetime import datetime
21
  from pathlib import Path
22
 
23
- from .checks import periksa_items
24
  from .config import PipelineConfig
25
  from .contracts import ParsedDocument
26
  from .manifest import CatatanDokumen, Manifest
@@ -76,8 +76,18 @@ def jalankan(cfg: PipelineConfig) -> Manifest:
76
  pdf_sumber=source, offset_halaman=cfg.start_page,
77
  )
78
 
 
 
 
 
 
 
 
 
 
 
79
  t0 = time.perf_counter()
80
- chunks = normalisasi(items, doc_id)
81
  catatan.detik_normalisasi = time.perf_counter() - t0
82
  catatan.jumlah_chunk = len(chunks)
83
 
 
20
  from datetime import datetime
21
  from pathlib import Path
22
 
23
+ from .checks import kosakata_per_halaman, periksa_items
24
  from .config import PipelineConfig
25
  from .contracts import ParsedDocument
26
  from .manifest import CatatanDokumen, Manifest
 
76
  pdf_sumber=source, offset_halaman=cfg.start_page,
77
  )
78
 
79
+ # Kosakata halaman sumber, untuk memulihkan batas kata yang hilang
80
+ # saat MinerU menulis rumus per karakter. Dikunci ulang ke indeks
81
+ # halaman MinerU: page_idx 0 berarti halaman `start_page` di PDF.
82
+ mentah = kosakata_per_halaman(source)
83
+ kosakata = (
84
+ {p - cfg.start_page: kata for p, kata in mentah.items()
85
+ if p >= cfg.start_page}
86
+ if mentah else None
87
+ )
88
+
89
  t0 = time.perf_counter()
90
+ chunks = normalisasi(items, doc_id, kosakata)
91
  catatan.detik_normalisasi = time.perf_counter() - t0
92
  catatan.jumlah_chunk = len(chunks)
93