[NOTICKET] fix(knowledge-parsing): render markup to prose in text; only numbered headings split sections
2cb1336 | """Tahap 3 — ubah daftar item datar MinerU menjadi chunk yang bermakna. | |
| Kenapa tahap ini ada: MinerU mengeluarkan daftar item DATAR, bukan unit | |
| bermakna. Item-item itu perlu digabung jadi chunk per section. | |
| MinerU memberi penanda judul `text_level` (2 / 2.1 / 2.1.1 -> level 2 / 3 / 4) | |
| di KEDUA backend — dicek langsung ke source-nya, `pipeline` dan `vlm` memakai | |
| logika yang sama. Tapi penanda itu hanya muncul kalau dokumennya memang punya | |
| judul yang terdeteksi: | |
| - dokumen standar BUMA (judul bernomor eksplisit) -> hierarki lengkap | |
| - handbook McGraw-Hill (kumpulan halaman tengah bab) -> nyaris tidak ada, | |
| 1 dari 84 item, dan itu pun caption tabel | |
| Jadi ketersediaan judul itu sifat DOKUMEN, bukan sifat backend. Karena itu | |
| `text_level` dipakai sebagai sinyal utama, dengan pola penomoran sebagai | |
| cadangan, dan hasilnya tetap boleh kosong. | |
| Yang dilakukan: | |
| - `page_number` dibuang (nomor halaman, bukan isi) | |
| - `header` dipakai sebagai konteks bab, bukan judul section | |
| (di dokumen uji semuanya header berjalan di bbox y~57-72, satu per halaman) | |
| - judul section dari `text_level`, cadangan pola penomoran ("2.1.3 Judul"), | |
| lalu disusun jadi `heading_path` (jejak judul induk sampai judul sendiri) | |
| - `table` dan `chart` jadi chunk sendiri; `equation` menempel ke chunk berjalan | |
| dan menyalakan `has_formula` | |
| ⚠️ Teks TIDAK PERNAH dirapikan di sini — tidak digabung barisnya, tidak | |
| diseragamkan spasinya. Lihat alasannya di contracts.py. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from pathlib import Path | |
| from typing import Any | |
| from .contracts import Chunk | |
| from .render import render_latex, render_table | |
| # "2.1.3 Judul" / "2.1.3. Judul" / "4 Judul" | |
| _POLA_NOMOR = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+(\S.*)$") | |
| # Judul biasanya pendek. Ambang ini mencegah paragraf yang kebetulan diawali | |
| # angka ikut dianggap judul. Nilainya diselaraskan dengan konstanta terkalibrasi | |
| # di KNOWLEDGE_PIPELINE_CALIBRATION.md §4: baris lebih panjang dari ini adalah | |
| # kalimat atau baris formula, bukan judul. | |
| _MAKS_PANJANG_JUDUL = 90 | |
| _DIBUANG = {"page_number"} | |
| # Batas ukuran chunk. Judul tetap batas utama; ini cuma pengaman untuk dokumen | |
| # yang judulnya tidak terdeteksi sama sekali — tanpa batas, satu chunk bisa | |
| # menelan seluruh dokumen, yang membuat evidence ranking tumpul dan menggelembungkan | |
| # porsi token cabang summary. ~6000 karakter kira-kira setara 1.500 token. | |
| MAKS_KARAKTER_CHUNK = 6000 | |
| def _judul(item: dict[str, Any]) -> tuple[str | None, str | None, int | None]: | |
| """Kembalikan (nomor_section, judul, level) kalau item ini judul section. | |
| ⭐ HANYA judul BERNOMOR yang membuka section baru. | |
| `text_level` dari MinerU tidak cukup dijadikan syarat tunggal: pada standar | |
| BUMA, MinerU juga menandai "Keterangan:" dan "Keterangan grafik:" sebagai | |
| judul. Kalau itu dianggap batas section, legend-nya terpisah dari gambar yang | |
| dijelaskannya — dan istilah seperti "Other Activity" dan "Uncontrollable" | |
| hilang, padahal ada sebagai prosa di chunk yang sudah lewat filter. Itu sisa | |
| selisih recall terhadap jalur teks polos. | |
| Jadi `text_level` dipakai untuk TINGKAT hierarkinya, tapi penomoranlah yang | |
| menentukan apakah sebuah baris benar-benar batas section. Baris ber- | |
| `text_level` tanpa nomor tetap ikut sebagai isi chunk yang sedang berjalan. | |
| """ | |
| if item.get("type") not in {"text", "title"}: | |
| return None, None, None | |
| teks = (item.get("text") or "").strip() | |
| if not teks or len(teks) > _MAKS_PANJANG_JUDUL: | |
| return None, None, None | |
| m = _POLA_NOMOR.match(teks) | |
| if not m or teks.endswith((".", ":", ";")): | |
| return None, None, None | |
| level = item.get("text_level") | |
| if level: | |
| return m.group(1), m.group(2), int(level) | |
| # Tanpa text_level, tingkat diperkirakan dari kedalaman penomoran: | |
| # "2" -> 1, "2.1" -> 2, "2.1.1" -> 3 | |
| return m.group(1), m.group(2), m.group(1).count(".") + 1 | |
| def _teks_tabel(item: dict[str, Any]) -> str: | |
| """Caption + isi tabel sebagai teks terbaca. | |
| HTML mentahnya TIDAK ditaruh di sini — disimpan terpisah di | |
| `Chunk.table_html`. Lihat alasan terukurnya di render.py. | |
| """ | |
| bagian = list(item.get("table_caption") or []) | |
| if item.get("table_body"): | |
| bagian.append(render_table(item["table_body"])) | |
| bagian += list(item.get("table_footnote") or []) | |
| return "\n\n".join(b for b in bagian if b) | |
| def _teks_chart(item: dict[str, Any]) -> str: | |
| bagian = list(item.get("chart_caption") or []) | |
| if (item.get("content") or "").strip(): | |
| bagian.append(item["content"]) | |
| bagian += list(item.get("chart_footnote") or []) | |
| return "\n\n".join(b for b in bagian if b) | |
| def normalisasi(items: list[dict[str, Any]], doc_id: str) -> list[Chunk]: | |
| # Konteks bab per halaman, dari header berjalan | |
| bab_per_halaman: dict[int, str] = {} | |
| for x in items: | |
| if x.get("type") == "header" and (x.get("text") or "").strip(): | |
| bab_per_halaman.setdefault(x.get("page_idx", 0), x["text"].strip()) | |
| chunks: list[Chunk] = [] | |
| berjalan: Chunk | None = None | |
| potongan: list[str] = [] | |
| # Tumpukan judul yang sedang berlaku: [(level, teks_judul), ...]. | |
| # Judul level N menutup semua judul level >= N sebelumnya. | |
| tumpukan: list[tuple[int, str]] = [] | |
| def dorong_judul(level: int, teks: str) -> None: | |
| while tumpukan and tumpukan[-1][0] >= level: | |
| tumpukan.pop() | |
| tumpukan.append((level, teks)) | |
| def tutup() -> None: | |
| nonlocal berjalan, potongan | |
| if berjalan is not None: | |
| berjalan.text = "\n\n".join(potongan).strip() | |
| if berjalan.text or berjalan.images: | |
| berjalan.page_idxs = sorted(set(berjalan.page_idxs)) | |
| chunks.append(berjalan) | |
| berjalan, potongan = None, [] | |
| def buka(kind: str, page: int, section_no=None, heading=None) -> Chunk: | |
| return Chunk( | |
| chunk_id=f"{doc_id}::{len(chunks):04d}", | |
| doc_id=doc_id, kind=kind, text="", | |
| page_idx=page, page_idxs=[page], | |
| section_no=section_no, heading=heading, | |
| chapters=[bab_per_halaman[page]] if page in bab_per_halaman else [], | |
| heading_path=[t for _, t in tumpukan], | |
| ) | |
| for i, item in enumerate(items): | |
| tipe = item.get("type") | |
| if tipe in _DIBUANG or tipe == "header": | |
| continue | |
| page = item.get("page_idx", 0) | |
| if tipe == "table": | |
| tutup() | |
| c = buka("table", page) | |
| c.text = _teks_tabel(item) | |
| c.is_tabular = True | |
| c.table_html = item.get("table_body") or None | |
| c.source_items = [i] | |
| c.bbox = item.get("bbox") | |
| if item.get("img_path"): | |
| c.images = [item["img_path"]] | |
| if c.text or c.images: | |
| chunks.append(c) | |
| continue | |
| if tipe == "chart": | |
| tutup() | |
| c = buka("chart", page) | |
| c.text = _teks_chart(item) | |
| c.source_items = [i] | |
| c.bbox = item.get("bbox") | |
| if item.get("img_path"): | |
| c.images = [item["img_path"]] | |
| chunks.append(c) | |
| continue | |
| if tipe == "equation": | |
| latex = (item.get("text") or "").strip() | |
| if berjalan is None: | |
| berjalan = buka("text", page) | |
| berjalan.has_formula = True | |
| berjalan.source_items.append(i) | |
| berjalan.page_idxs.append(page) | |
| if item.get("img_path"): | |
| berjalan.images.append(item["img_path"]) | |
| if latex: | |
| berjalan.latex.append(latex) # mentah, untuk cabang formula | |
| terbaca = render_latex(latex) | |
| if terbaca: | |
| potongan.append(terbaca) # prosa, supaya NER menemukannya | |
| continue | |
| # sisanya: teks | |
| teks = (item.get("text") or "").strip() | |
| if not teks: | |
| continue | |
| nomor, judul, level = _judul(item) | |
| if judul is not None: | |
| # BREADCRUMB: banyak dokumen mencetak ulang jalur judulnya di atas | |
| # setiap halaman (BUMA mengulang "2. PENJELASAN PARAMETER / | |
| # 2.1. Production Parameter" di hal. 2-8). Judul yang SUDAH ada di | |
| # tumpukan berarti section yang sedang berjalan atau induknya — | |
| # bukan section baru. Tanpa aturan ini, section yang sama pecah | |
| # berkali-kali dan semua angka di hilir ikut rusak. | |
| if any(teks == t for _, t in tumpukan): | |
| if berjalan is not None: | |
| berjalan.page_idxs.append(page) # section berlanjut, halaman meluas | |
| continue | |
| tutup() | |
| # Judul didorong SEBELUM chunk dibuka, supaya chunk isinya membawa | |
| # judulnya sendiri di ujung heading_path. | |
| dorong_judul(level or 1, teks) | |
| berjalan = buka("text", page, section_no=nomor, heading=judul) | |
| berjalan.source_items = [i] | |
| berjalan.bbox = item.get("bbox") | |
| # Baris judul TIDAK dimasukkan ke `text` — cukup di field `heading`, | |
| # verbatim. | |
| # | |
| # Sempat dicoba sebaliknya, karena di standar BUMA judul menyebut | |
| # istilahnya lalu badan section mulai "Adalah ..." tanpa mengulangnya, | |
| # sehingga chunk yang mendefinisikan istilah tidak memuat istilah itu. | |
| # Tapi sisi ekstraksi sudah menangani ini: haystack span-check-nya | |
| # disusun `heading + text`, dan ranker-nya memperlakukan judul sebagai | |
| # mention di offset 0. | |
| # | |
| # Menyalinnya ke `text` justru merugikan: judul terhitung dua kali di | |
| # haystack, dan kemunculannya jadi mention nyata sehingga | |
| # `mention_count` menggelembung — padahal angka itu yang dibandingkan | |
| # ke baseline beku (169 mention -> 66 cluster). Perbandingan v2 akan | |
| # bergeser karena sebab yang tidak ada hubungannya dengan mutu. | |
| continue | |
| if berjalan is None: | |
| berjalan = buka("text", page) | |
| berjalan.bbox = item.get("bbox") | |
| berjalan.source_items.append(i) | |
| berjalan.page_idxs.append(page) | |
| bab = bab_per_halaman.get(page) | |
| if bab and bab not in berjalan.chapters: | |
| berjalan.chapters.append(bab) | |
| potongan.append(teks) # verbatim, tanpa dirapikan | |
| # Pengaman ukuran: hanya berlaku untuk dokumen tanpa judul terdeteksi. | |
| # Chunk dipotong di batas item, jadi teksnya tetap verbatim. | |
| if sum(len(x) for x in potongan) >= MAKS_KARAKTER_CHUNK: | |
| lanjutan_dari = berjalan | |
| tutup() | |
| berjalan = buka("text", page, | |
| section_no=lanjutan_dari.section_no, | |
| heading=lanjutan_dari.heading) | |
| tutup() | |
| return chunks | |
| def normalisasi_dari_berkas(content_list: Path, doc_id: str) -> list[Chunk]: | |
| items = json.loads(content_list.read_text(encoding="utf-8")) | |
| return normalisasi(items, doc_id) | |