"""Pemeriksaan mutu hasil parse — menangkap kerusakan yang TIDAK bersuara. Dasarnya temuan di `Hasil-Uji-MinerU.md`: formula EOQ hasil pipeline CPU angkanya hilang tanpa error, tanpa peringatan. Kerusakan seperti itu tidak akan pernah ketahuan dari status "selesai" — harus diperiksa sendiri. Semua temuan di sini sifatnya PERINGATAN, bukan kegagalan: dokumen tetap diproses, tapi peringatannya tercatat di manifest supaya tidak ada mutu yang turun diam-diam di antara ratusan dokumen. """ from __future__ import annotations import re from collections import Counter from pathlib import Path from typing import Any # Angka di dalam latex. Formula teknis hampir selalu punya angka atau pembagi. _ADA_ANGKA = re.compile(r"\d") # Panjang minimal deret angka yang dicocokkan ke sumber. # # Diuji ke dokumen nyata: dengan ambang 2 digit, bug asli (55600) tertangkap # tapi ikut muncul 6 peringatan palsu dari angka pendek — lapisan teks PDF-nya # memang rusak sebagian, jadi angka pendek sering tidak ketemu padahal benar. # Dengan 3 digit, bug tetap tertangkap dan derau hilang. MIN_DIGIT = 3 _SEMUA_ANGKA = re.compile(r"\d+") def _deret(teks: str, min_digit: int) -> set[str]: return {a for a in _SEMUA_ANGKA.findall(teks) if len(a) >= min_digit} def _angka_dari_latex(latex: str, min_digit: int = MIN_DIGIT) -> set[str]: """MinerU menulis latex berspasi ('5 5 6 0 0'), jadi spasi dibuang dulu.""" return _deret(re.sub(r"\s+", "", latex), min_digit) def _angka_per_halaman(pdf: Path, min_digit: int = MIN_DIGIT) -> dict[int, set[str]] | None: """Deret angka pada lapisan teks PDF asli, per halaman. Dipakai sebagai pembanding. Kalau PDF tidak terbaca, kembalikan None dan pemeriksaan ini dilewati diam-diam — lebih baik tidak memeriksa daripada memberi peringatan palsu. """ try: from pypdf import PdfReader reader = PdfReader(str(pdf)) except Exception: return None hasil: dict[int, set[str]] = {} for i, hal in enumerate(reader.pages): try: teks = hal.extract_text() or "" except Exception: teks = "" # buang pemisah ribuan & spasi supaya "$5,600" dan "5 600" -> "5600" bersih = re.sub(r"[, \s]", "", teks) hasil[i] = _deret(bersih, min_digit) return hasil def periksa_items( items: list[dict[str, Any]], min_latex_len: int = 8, pdf_sumber: Path | None = None, offset_halaman: int = 0, ) -> dict[str, Any]: """Kembalikan ringkasan + daftar peringatan untuk satu dokumen. `pdf_sumber` mengaktifkan pencocokan angka ke PDF asli — satu-satunya cara menangkap angka yang berubah diam-diam (bug EOQ: 5600 -> 55600). """ per_tipe = Counter(x.get("type") for x in items) halaman = {x.get("page_idx") for x in items if x.get("page_idx") is not None} peringatan: list[dict[str, Any]] = [] # 1. Halaman tanpa teks sama sekali -> kemungkinan gagal dibaca teks_per_halaman = Counter( x.get("page_idx") for x in items if x.get("type") in {"text", "table", "equation"} and (x.get("text") or x.get("table_body")) ) for p in sorted(halaman): if teks_per_halaman.get(p, 0) == 0: peringatan.append({"jenis": "halaman_kosong", "page": p}) # 2. Formula terpotong / kehilangan angka <- bug EOQ for i, x in enumerate(items): if x.get("type") != "equation": continue latex = (x.get("text") or "").strip() if len(latex) < min_latex_len: peringatan.append({"jenis": "latex_terlalu_pendek", "item": i, "page": x.get("page_idx"), "isi": latex[:60]}) elif not _ADA_ANGKA.search(latex) and "\\frac" not in latex: peringatan.append({"jenis": "latex_tanpa_angka", "item": i, "page": x.get("page_idx"), "isi": latex[:60]}) # 3. ⭐ Angka di formula tidak ada di PDF asli -> berubah diam-diam. # Ini SATU-SATUNYA pemeriksaan yang bisa menangkap bug EOQ; pemeriksaan # lain di berkas ini hanya melihat output sendiri, dan angka yang salah # tetap menghasilkan latex yang sah sepenuhnya. angka_asli = _angka_per_halaman(pdf_sumber) if pdf_sumber else None if angka_asli is not None: for i, x in enumerate(items): if x.get("type") != "equation": continue p = x.get("page_idx", 0) + offset_halaman acuan = angka_asli.get(p) if not acuan: # halaman tanpa lapisan teks -> tidak bisa dinilai continue for angka in sorted(_angka_dari_latex(x.get("text") or "")): if angka not in acuan: peringatan.append({ "jenis": "angka_tidak_ada_di_sumber", "item": i, "page": p, "angka": angka, "catatan": "angka pada formula tidak ditemukan di teks PDF asli", }) # 4. Tabel tanpa isi for i, x in enumerate(items): if x.get("type") == "table" and not (x.get("table_body") or "").strip(): peringatan.append({"jenis": "tabel_kosong", "item": i, "page": x.get("page_idx")}) # 5. Chart tanpa konteks apa pun (tidak bisa dipakai tahap berikutnya) for i, x in enumerate(items): if x.get("type") == "chart": punya = (x.get("content") or "").strip() or (x.get("chart_caption") or []) if not punya: peringatan.append({"jenis": "chart_tanpa_konteks", "item": i, "page": x.get("page_idx")}) return { "item": len(items), "halaman_terdeteksi": len(halaman), "per_tipe": dict(per_tipe), "peringatan": peringatan, "jumlah_peringatan": len(peringatan), }