[NOTICKET] fix(knowledge-parsing): render markup to prose in text; only numbered headings split sections
Browse filesBoth 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 +13 -0
- src/knowledge_parsing/normalize.py +33 -16
- src/knowledge_parsing/render.py +122 -0
|
@@ -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.
|
|
@@ -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 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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)
|