sofhiaazzhr's picture
[NOTICKET] fix(knowledge-parsing): render markup to prose in text; only numbered headings split sections
2cb1336
Raw
History Blame
11.1 kB
"""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)