sofhiaazzhr Claude Opus 5 commited on
Commit
2cb1336
·
1 Parent(s): 15028da

[NOTICKET] fix(knowledge-parsing): render markup to prose in text; only numbered headings split sections

Browse files

Both defects were measured by the extraction side on the same document and gold
set, changing only the parse:

PyMuPDF (plain text) recall 0.8537 35/41
MinerU, raw markup in text recall 0.7561 31/41
MinerU, formula + table rendered recall 0.8293 34/41

1. Chunk.text now carries readable prose, not markup.

The term filter is an NER model reading prose. MinerU writes formulas with every
character spaced out — "{ \mathrm { P u r c h a s i n g ~ c o s t s } }" — and
tables as HTML; neither yields a single mention for terms the same model finds
easily in plain text. render.py rejoins spelled-out words and numbers, folds
subscripts, maps operators to non-letter glyphs (a literal "x" for \times would
merge into the token beside it), and flattens tables to " | "-separated rows.

The markup is kept, not discarded: Chunk.latex and Chunk.table_html hold it
verbatim, because the formula branch needs exactly that form.

Found while verifying: table cells carry inline LaTeX of their own, so stripping
HTML alone still left markup in the prose. Inline math inside cells is rendered
too.

2. Only NUMBERED headings open a section.

text_level alone is not sufficient evidence of a section boundary: on the BUMA
standard MinerU also marks "Keterangan:" and "Keterangan grafik:" as headings.
Treating those as boundaries separates a legend from the figure it explains, and
"Other Activity" and "Uncontrollable" go missing even though they sit as prose in
a chunk the filter already processed. text_level is still used for the hierarchy
level; numbering decides whether a line is a boundary at all.

Verified: no markup leaks into text on the sample document; heading-less
"Keterangan" lines stay with their section; ruff clean; import main OK; importing
the package still leaves mineru and torch out of sys.modules.

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

src/knowledge_parsing/contracts.py CHANGED
@@ -108,6 +108,19 @@ class Chunk(BaseModel):
108
  # Non-text attachments (formula images, table/chart crops)
109
  images: list[str] = Field(default_factory=list)
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
  class ParsedDocument(BaseModel):
113
  """The artifact itself — one parsed document, self-describing.
 
108
  # Non-text attachments (formula images, table/chart crops)
109
  images: list[str] = Field(default_factory=list)
110
 
111
+ # Source markup, kept verbatim beside the rendered prose in `text`.
112
+ #
113
+ # `text` carries a readable rendering because the term filter is an NER
114
+ # model reading prose: MinerU writes formulas character-spaced
115
+ # ("P u r c h a s i n g ~ c o s t s") and tables as HTML, and neither
116
+ # produces a single mention. Measured on the same document and gold set,
117
+ # only the parse differing: raw markup in `text` scored recall 0.7561 against
118
+ # 0.8537 for plain text; rendering it back recovered 0.8293.
119
+ #
120
+ # The markup is not discarded — the formula branch needs exactly this form.
121
+ latex: list[str] = Field(default_factory=list)
122
+ table_html: str | None = None
123
+
124
 
125
  class ParsedDocument(BaseModel):
126
  """The artifact itself — one parsed document, self-describing.
src/knowledge_parsing/normalize.py CHANGED
@@ -37,6 +37,7 @@ from pathlib import Path
37
  from typing import Any
38
 
39
  from .contracts import Chunk
 
40
 
41
  # "2.1.3 Judul" / "2.1.3. Judul" / "4 Judul"
42
  _POLA_NOMOR = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+(\S.*)$")
@@ -59,10 +60,18 @@ MAKS_KARAKTER_CHUNK = 6000
59
  def _judul(item: dict[str, Any]) -> tuple[str | None, str | None, int | None]:
60
  """Kembalikan (nomor_section, judul, level) kalau item ini judul section.
61
 
62
- `text_level` dari MinerU adalah sinyal utama dipakai kalau ada, karena itu
63
- hasil deteksi judul MinerU sendiri dan membawa tingkat hierarkinya
64
- (2 / 2.1 / 2.1.1 -> level 2 / 3 / 4). Pola penomoran cuma cadangan untuk
65
- dokumen yang judulnya tidak terdeteksi MinerU.
 
 
 
 
 
 
 
 
66
  """
67
  if item.get("type") not in {"text", "title"}:
68
  return None, None, None
@@ -70,23 +79,27 @@ def _judul(item: dict[str, Any]) -> tuple[str | None, str | None, int | None]:
70
  if not teks or len(teks) > _MAKS_PANJANG_JUDUL:
71
  return None, None, None
72
 
73
- level = item.get("text_level")
74
- if level: # MinerU yakin ini judul
75
- m = _POLA_NOMOR.match(teks)
76
- return (m.group(1), m.group(2), int(level)) if m else (None, teks, int(level))
77
-
78
  m = _POLA_NOMOR.match(teks)
79
- if m and not teks.endswith((".", ":", ";")):
80
- # Tanpa text_level, tingkat diperkirakan dari kedalaman penomoran:
81
- # "2" -> 1, "2.1" -> 2, "2.1.1" -> 3
82
- return m.group(1), m.group(2), m.group(1).count(".") + 1
83
- return None, None, None
 
 
 
 
84
 
85
 
86
  def _teks_tabel(item: dict[str, Any]) -> str:
 
 
 
 
 
87
  bagian = list(item.get("table_caption") or [])
88
  if item.get("table_body"):
89
- bagian.append(item["table_body"])
90
  bagian += list(item.get("table_footnote") or [])
91
  return "\n\n".join(b for b in bagian if b)
92
 
@@ -149,6 +162,7 @@ def normalisasi(items: list[dict[str, Any]], doc_id: str) -> list[Chunk]:
149
  c = buka("table", page)
150
  c.text = _teks_tabel(item)
151
  c.is_tabular = True
 
152
  c.source_items = [i]
153
  c.bbox = item.get("bbox")
154
  if item.get("img_path"):
@@ -178,7 +192,10 @@ def normalisasi(items: list[dict[str, Any]], doc_id: str) -> list[Chunk]:
178
  if item.get("img_path"):
179
  berjalan.images.append(item["img_path"])
180
  if latex:
181
- potongan.append(latex)
 
 
 
182
  continue
183
 
184
  # sisanya: teks
 
37
  from typing import Any
38
 
39
  from .contracts import Chunk
40
+ from .render import render_latex, render_table
41
 
42
  # "2.1.3 Judul" / "2.1.3. Judul" / "4 Judul"
43
  _POLA_NOMOR = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+(\S.*)$")
 
60
  def _judul(item: dict[str, Any]) -> tuple[str | None, str | None, int | None]:
61
  """Kembalikan (nomor_section, judul, level) kalau item ini judul section.
62
 
63
+ HANYA judul BERNOMOR yang membuka section baru.
64
+
65
+ `text_level` dari MinerU tidak cukup dijadikan syarat tunggal: pada standar
66
+ BUMA, MinerU juga menandai "Keterangan:" dan "Keterangan grafik:" sebagai
67
+ judul. Kalau itu dianggap batas section, legend-nya terpisah dari gambar yang
68
+ dijelaskannya — dan istilah seperti "Other Activity" dan "Uncontrollable"
69
+ hilang, padahal ada sebagai prosa di chunk yang sudah lewat filter. Itu sisa
70
+ selisih recall terhadap jalur teks polos.
71
+
72
+ Jadi `text_level` dipakai untuk TINGKAT hierarkinya, tapi penomoranlah yang
73
+ menentukan apakah sebuah baris benar-benar batas section. Baris ber-
74
+ `text_level` tanpa nomor tetap ikut sebagai isi chunk yang sedang berjalan.
75
  """
76
  if item.get("type") not in {"text", "title"}:
77
  return None, None, None
 
79
  if not teks or len(teks) > _MAKS_PANJANG_JUDUL:
80
  return None, None, None
81
 
 
 
 
 
 
82
  m = _POLA_NOMOR.match(teks)
83
+ if not m or teks.endswith((".", ":", ";")):
84
+ return None, None, None
85
+
86
+ level = item.get("text_level")
87
+ if level:
88
+ return m.group(1), m.group(2), int(level)
89
+ # Tanpa text_level, tingkat diperkirakan dari kedalaman penomoran:
90
+ # "2" -> 1, "2.1" -> 2, "2.1.1" -> 3
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
98
+ `Chunk.table_html`. Lihat alasan terukurnya di render.py.
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
 
 
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]
167
  c.bbox = item.get("bbox")
168
  if item.get("img_path"):
 
192
  if item.get("img_path"):
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
200
 
201
  # sisanya: teks
src/knowledge_parsing/render.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render MinerU's markup back into readable prose for `Chunk.text`.
2
+
3
+ Why this exists — measured, not assumed. Running the same extraction pipeline,
4
+ same document, same gold set, changing only the parse:
5
+
6
+ PyMuPDF (plain text) recall 0.8537 35/41
7
+ MinerU, raw markup in text recall 0.7561 31/41
8
+ MinerU, formula + table rendered recall 0.8293 34/41
9
+
10
+ The term filter is an NER model reading prose. MinerU writes formulas with every
11
+ character spaced out — `{ \\mathrm { P u r c h a s i n g ~ c o s t s } }` — and
12
+ tables as HTML. Neither yields a single mention, even for terms the same model
13
+ finds easily in plain text.
14
+
15
+ The markup is not discarded: `Chunk.latex` and `Chunk.table_html` keep it
16
+ verbatim, because the formula branch needs exactly that form.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import html
22
+ import re
23
+
24
+ # --- LaTeX ---------------------------------------------------------------
25
+
26
+ _DROP_COMMANDS = re.compile(
27
+ r"\\(?:qquad|quad|left|right|displaystyle|limits|nolimits)\b|\\[!,;:]"
28
+ )
29
+ _WRAPPERS = re.compile(r"\\(?:mathrm|mathbf|mathit|mathsf|text|textrm|operatorname)\s*")
30
+ # Operators map to non-letter glyphs on purpose. A letter here (e.g. "x" for
31
+ # \times) would itself look like a spelled-out single character and get merged
32
+ # into the token beside it — "C_p x T" becoming "C_p xT".
33
+ _SYMBOLS = {
34
+ r"\times": "×", r"\cdot": "·", r"\div": "/", r"\pm": "±",
35
+ r"\leq": "≤", r"\geq": "≥", r"\neq": "≠", r"\approx": "≈",
36
+ r"\%": "%", r"\$": "$", r"\&": "&",
37
+ }
38
+
39
+ # "P u r c h a s i n g" -> "Purchasing", "1 0 0" -> "100". Runs of >= 2 single
40
+ # characters; letters and digits are joined separately so "C 5" is left alone.
41
+ # Subscripts are folded first (below), so a real variable like `C_p` is already
42
+ # one token and never gets swallowed into a neighbouring word.
43
+ _SPACED_RUN = re.compile(
44
+ r"(?<!\S)(?:[A-Za-z]\s+){1,}[A-Za-z](?!\S)"
45
+ r"|(?<!\S)(?:\d\s+){1,}\d(?!\S)"
46
+ )
47
+
48
+ # `~` is a non-breaking SPACE in LaTeX, i.e. a real word boundary. It has to
49
+ # survive the whitespace pass, otherwise "c o s t s" after it merges into the
50
+ # preceding word and "Purchasing costs" becomes "Purchasingcosts".
51
+ _WORD_GAP = "\x00"
52
+
53
+
54
+ def render_latex(latex: str) -> str:
55
+ """LaTeX -> a readable line. Best effort: it feeds a term filter, not a parser."""
56
+ s = latex.replace("$$", " ").replace("$", " ")
57
+ s = s.replace("~", _WORD_GAP)
58
+
59
+ # array/matrix scaffolding carries no meaning for a term filter
60
+ s = re.sub(r"\\(?:begin|end)\s*\{[^{}]*\}", " ", s)
61
+ s = re.sub(r"\{\s*(?:[rlc|]\s*){1,8}\}", " ", s) # column spec, e.g. { r l }
62
+ s = s.replace("\\\\", " ; ").replace("&", " ")
63
+
64
+ # fold sub/superscripts first, so `C _ { p }` becomes one token `C_p`
65
+ s = re.sub(r"\s*_\s*\{\s*([^{}]*?)\s*\}", r"_\1", s)
66
+ s = re.sub(r"\s*\^\s*\{\s*([^{}]*?)\s*\}", r"^\1", s)
67
+ s = re.sub(r"\s*_\s*([A-Za-z0-9])", r"_\1", s)
68
+ s = re.sub(r"\s*\^\s*([A-Za-z0-9])", r"^\1", s)
69
+
70
+ # \frac{a}{b} -> (a) / (b); repeat for nesting
71
+ for _ in range(4):
72
+ baru = re.sub(r"\\d?frac\s*\{([^{}]*)\}\s*\{([^{}]*)\}", r"(\1) / (\2)", s)
73
+ if baru == s:
74
+ break
75
+ s = baru
76
+
77
+ s = re.sub(r"\\sqrt\s*\{([^{}]*)\}", r"sqrt(\1)", s)
78
+ for k, v in _SYMBOLS.items():
79
+ s = s.replace(k, f" {v} ")
80
+ s = _WRAPPERS.sub(" ", s)
81
+ s = _DROP_COMMANDS.sub(" ", s)
82
+ s = re.sub(r"\\[A-Za-z]+", " ", s) # any command left over
83
+ s = s.replace("{", " ").replace("}", " ").replace("\\", " ")
84
+ s = re.sub(r"[ \t\r\n]+", " ", s).strip()
85
+
86
+ # now that spacing is uniform, rejoin the spelled-out words and numbers
87
+ s = _SPACED_RUN.sub(lambda m: m.group(0).replace(" ", ""), s)
88
+
89
+ s = s.replace(_WORD_GAP, " ")
90
+ return re.sub(r"\s+", " ", s).strip()
91
+
92
+
93
+ # --- HTML tables ---------------------------------------------------------
94
+
95
+ _CELL_END = re.compile(r"</\s*(?:td|th)\s*>", re.I)
96
+ _ROW_END = re.compile(r"</\s*tr\s*>", re.I)
97
+ _TAG = re.compile(r"<[^>]+>")
98
+
99
+ # Table cells routinely carry inline LaTeX ("$\frac{kVA}{...}$"). Stripping the
100
+ # HTML alone leaves that markup sitting in the prose, which is the same problem
101
+ # the formula rendering exists to solve — found in a real table on the first run.
102
+ _INLINE_MATH = re.compile(r"\$([^$]{1,400})\$")
103
+
104
+
105
+ def render_table(table_html: str) -> str:
106
+ """HTML table -> plain rows, cells separated by ' | '.
107
+
108
+ Keeps the reading order a person would use, so a term appearing only in a
109
+ table header is still a mention.
110
+ """
111
+ s = _ROW_END.sub("\n", table_html)
112
+ s = _CELL_END.sub(" | ", s)
113
+ s = _TAG.sub(" ", s)
114
+ s = html.unescape(s)
115
+ s = _INLINE_MATH.sub(lambda m: " " + render_latex(m.group(1)) + " ", s)
116
+
117
+ baris = []
118
+ for raw in s.split("\n"):
119
+ bersih = re.sub(r"[ \t]+", " ", raw).strip().strip("|").strip()
120
+ if bersih:
121
+ baris.append(re.sub(r"\s*\|\s*", " | ", bersih))
122
+ return "\n".join(baris)