"""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)