[NOTICKET] feat(knowledge-parsing): document parsing half of the knowledge pipeline
Browse filesImplements §4 B2/B5/B6 and closes the four open seam items (S2, S3, S4, S6b/c/d)
against the contracts.py draft circulated 2026-08-19. Additive and flag-gated:
src/knowledge/ (Tesseract -> pgvector) is untouched.
Seam — src/knowledge_parsing/contracts.py, pydantic per S6e:
- ParsedDocument envelope: content_hash, n_pages, version, parser_name/version/
backend/config, schema_version, raw_output_dir. parser_backend is read from
MinerU's _middle.json, so it records what actually ran, not what was configured
— a MinerU change and a prompt change stay distinguishable (S2).
- Heading structure is READ, not derived. Verified in MinerU's source: pipeline
and vlm run identical text_level logic, and bbox is emitted by both. Heading
availability is document-dependent, not backend-dependent — the earlier claim
came from sampling the McGraw-Hill handbook, which has no numbered headings (S3).
- source_wording on TermRecord; heading carried verbatim (S4).
- page_idx/page_idxs, 0-based, no conversion anywhere — 1-based is the UI's job,
done once at display time (S6b).
Parsing:
- Content-addressed parse cache keyed on file hash + settings + MinerU version.
Parsing is the expensive stage (~19 s/page CPU); the normalizer can be iterated
without re-parsing. Version is part of the key so numbers stay comparable.
- Batch with --resume and per-document failure isolation (failures.jsonl); one
bad document does not stop the run.
- manifest.json + report.py --scale N produce the throughput numbers directly.
Runs under 20 pages are refused for extrapolation: model load is a fixed cost,
so 1 page reads ~2.8x slower per page than 20 (54.0 vs 19.0 s/page measured).
Normalizer, from KNOWLEDGE_PIPELINE_CALIBRATION.md §4:
- Breadcrumb headings. Documents reprint their heading path on every page; a
naive splitter reopens the section and shatters it. A heading already on the
stack is a breadcrumb — the section continues and its page range extends.
- The heading line is the first segment of Chunk.text, verbatim. In the BUMA
standard the heading names the term and the body opens with "Adalah ..."
without repeating it, so a split-out heading leaves the defining chunk with no
mention of its own term — ranking below sections that merely use it. It also
makes Chunk.text the single place a span check must look.
- Heading length capped at 90 chars and chunks at ~1500 tokens, per §4.
Quality checks (checks.py) — warnings, never failures:
- Numbers in extracted formulas are cross-checked against the source PDF's text
layer. This is the only check that can catch silent corruption: the known real
defect turned 5600 into 55600, leaving valid-looking LaTeX and no error. No
output-only check can see that. Threshold 3 digits — at 2 the real defect was
still caught but six false positives came with it.
Dependencies: MinerU is an optional extra ("knowledge-parsing"), not a main
dependency. It is imported inside parse.py at call time, so importing the package
does not pull torch and the deployed Space builds without it. Verified: importing
src.knowledge_parsing leaves mineru and torch absent from sys.modules.
Verification: ruff clean (T201 scoped to the two CLI files, following the
existing eval/** precedent); import main OK; full run reproduced in this repo
against the existing parse cache. Output paths (data/knowledge_*) are gitignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- .gitignore +5 -1
- pyproject.toml +16 -0
- src/knowledge_parsing/__init__.py +22 -0
- src/knowledge_parsing/checks.py +144 -0
- src/knowledge_parsing/config.py +121 -0
- src/knowledge_parsing/contracts.py +185 -0
- src/knowledge_parsing/manifest.py +86 -0
- src/knowledge_parsing/normalize.py +248 -0
- src/knowledge_parsing/parse.py +167 -0
- src/knowledge_parsing/report.py +117 -0
- src/knowledge_parsing/run.py +168 -0
|
@@ -54,4 +54,8 @@ docs/specs/tabular_parquet_contract.md
|
|
| 54 |
docs/specs/tabular_parquet.md
|
| 55 |
|
| 56 |
# Personal / local working docs (not for the shared repo) — archived out of root
|
| 57 |
-
docs/_archive/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
docs/specs/tabular_parquet.md
|
| 55 |
|
| 56 |
# Personal / local working docs (not for the shared repo) — archived out of root
|
| 57 |
+
docs/_archive/
|
| 58 |
+
# Knowledge pipeline output — data, not code (parse cache + run artifacts)
|
| 59 |
+
data/knowledge_cache/
|
| 60 |
+
data/knowledge_runs/
|
| 61 |
+
data/knowledge_docs/
|
|
@@ -106,6 +106,18 @@ dev = [
|
|
| 106 |
"pre-commit==4.0.1",
|
| 107 |
]
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
[tool.hatch.build.targets.wheel]
|
| 110 |
packages = ["src/agent_service"]
|
| 111 |
|
|
@@ -131,6 +143,10 @@ ignore = [
|
|
| 131 |
# Same rule, same reason: this is an operator-run CLI, and its printed output IS
|
| 132 |
# the deliverable.
|
| 133 |
"src/knowledge_extraction/cli.py" = ["T201"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
[tool.mypy]
|
| 136 |
python_version = "3.12"
|
|
|
|
| 106 |
"pre-commit==4.0.1",
|
| 107 |
]
|
| 108 |
|
| 109 |
+
# Document parsing for the knowledge pipeline (src/knowledge_parsing/).
|
| 110 |
+
#
|
| 111 |
+
# Deliberately an EXTRA, not a main dependency: MinerU pulls torch and its model
|
| 112 |
+
# tail (GBs), and the agent service never parses documents at request time —
|
| 113 |
+
# parsing is an offline, admin-triggered batch job. Keeping it here means the
|
| 114 |
+
# deployed Space does not build or ship any of it.
|
| 115 |
+
#
|
| 116 |
+
# pip install -e ".[parsing]"
|
| 117 |
+
knowledge-parsing = [
|
| 118 |
+
"mineru==3.4.4",
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
[tool.hatch.build.targets.wheel]
|
| 122 |
packages = ["src/agent_service"]
|
| 123 |
|
|
|
|
| 143 |
# Same rule, same reason: this is an operator-run CLI, and its printed output IS
|
| 144 |
# the deliverable.
|
| 145 |
"src/knowledge_extraction/cli.py" = ["T201"]
|
| 146 |
+
# Same again for the knowledge-parsing CLI entry points. Scoped to the two files
|
| 147 |
+
# rather than the package, so the library modules stay print-free.
|
| 148 |
+
"src/knowledge_parsing/run.py" = ["T201"]
|
| 149 |
+
"src/knowledge_parsing/report.py" = ["T201"]
|
| 150 |
|
| 151 |
[tool.mypy]
|
| 152 |
python_version = "3.12"
|
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document parsing — the first half of the knowledge pipeline.
|
| 2 |
+
|
| 3 |
+
PDF/DOCX in, a versioned `ParsedDocument` out. The extraction half consumes that
|
| 4 |
+
artifact and never the parser itself, which is what keeps MinerU swappable.
|
| 5 |
+
|
| 6 |
+
Additive and flag-gated: the existing unstructured path (`src/knowledge/`,
|
| 7 |
+
Tesseract OCR -> chunk -> pgvector) is untouched and keeps running as-is.
|
| 8 |
+
|
| 9 |
+
Heavy dependencies (MinerU, torch) are an optional extra — see `pyproject.toml`.
|
| 10 |
+
Importing this package does not import them; only `parse.py` does, at call time,
|
| 11 |
+
so the agent service starts without them installed.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from .contracts import SCHEMA_VERSION, Chunk, Mention, ParsedDocument, TermRecord
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
"SCHEMA_VERSION",
|
| 18 |
+
"Chunk",
|
| 19 |
+
"Mention",
|
| 20 |
+
"ParsedDocument",
|
| 21 |
+
"TermRecord",
|
| 22 |
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pemeriksaan mutu hasil parse — menangkap kerusakan yang TIDAK bersuara.
|
| 2 |
+
|
| 3 |
+
Dasarnya temuan di `Hasil-Uji-MinerU.md`: formula EOQ hasil pipeline CPU
|
| 4 |
+
angkanya hilang tanpa error, tanpa peringatan. Kerusakan seperti itu tidak akan
|
| 5 |
+
pernah ketahuan dari status "selesai" — harus diperiksa sendiri.
|
| 6 |
+
|
| 7 |
+
Semua temuan di sini sifatnya PERINGATAN, bukan kegagalan: dokumen tetap
|
| 8 |
+
diproses, tapi peringatannya tercatat di manifest supaya tidak ada mutu yang
|
| 9 |
+
turun diam-diam di antara ratusan dokumen.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
from collections import Counter
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
# Angka di dalam latex. Formula teknis hampir selalu punya angka atau pembagi.
|
| 20 |
+
_ADA_ANGKA = re.compile(r"\d")
|
| 21 |
+
|
| 22 |
+
# Panjang minimal deret angka yang dicocokkan ke sumber.
|
| 23 |
+
#
|
| 24 |
+
# Diuji ke dokumen nyata: dengan ambang 2 digit, bug asli (55600) tertangkap
|
| 25 |
+
# tapi ikut muncul 6 peringatan palsu dari angka pendek — lapisan teks PDF-nya
|
| 26 |
+
# memang rusak sebagian, jadi angka pendek sering tidak ketemu padahal benar.
|
| 27 |
+
# Dengan 3 digit, bug tetap tertangkap dan derau hilang.
|
| 28 |
+
MIN_DIGIT = 3
|
| 29 |
+
|
| 30 |
+
_SEMUA_ANGKA = re.compile(r"\d+")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _deret(teks: str, min_digit: int) -> set[str]:
|
| 34 |
+
return {a for a in _SEMUA_ANGKA.findall(teks) if len(a) >= min_digit}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _angka_dari_latex(latex: str, min_digit: int = MIN_DIGIT) -> set[str]:
|
| 38 |
+
"""MinerU menulis latex berspasi ('5 5 6 0 0'), jadi spasi dibuang dulu."""
|
| 39 |
+
return _deret(re.sub(r"\s+", "", latex), min_digit)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _angka_per_halaman(pdf: Path, min_digit: int = MIN_DIGIT) -> dict[int, set[str]] | None:
|
| 43 |
+
"""Deret angka pada lapisan teks PDF asli, per halaman.
|
| 44 |
+
|
| 45 |
+
Dipakai sebagai pembanding. Kalau PDF tidak terbaca, kembalikan None dan
|
| 46 |
+
pemeriksaan ini dilewati diam-diam — lebih baik tidak memeriksa daripada
|
| 47 |
+
memberi peringatan palsu.
|
| 48 |
+
"""
|
| 49 |
+
try:
|
| 50 |
+
from pypdf import PdfReader
|
| 51 |
+
reader = PdfReader(str(pdf))
|
| 52 |
+
except Exception:
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
hasil: dict[int, set[str]] = {}
|
| 56 |
+
for i, hal in enumerate(reader.pages):
|
| 57 |
+
try:
|
| 58 |
+
teks = hal.extract_text() or ""
|
| 59 |
+
except Exception:
|
| 60 |
+
teks = ""
|
| 61 |
+
# buang pemisah ribuan & spasi supaya "$5,600" dan "5 600" -> "5600"
|
| 62 |
+
bersih = re.sub(r"[, \s]", "", teks)
|
| 63 |
+
hasil[i] = _deret(bersih, min_digit)
|
| 64 |
+
return hasil
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def periksa_items(
|
| 68 |
+
items: list[dict[str, Any]],
|
| 69 |
+
min_latex_len: int = 8,
|
| 70 |
+
pdf_sumber: Path | None = None,
|
| 71 |
+
offset_halaman: int = 0,
|
| 72 |
+
) -> dict[str, Any]:
|
| 73 |
+
"""Kembalikan ringkasan + daftar peringatan untuk satu dokumen.
|
| 74 |
+
|
| 75 |
+
`pdf_sumber` mengaktifkan pencocokan angka ke PDF asli — satu-satunya cara
|
| 76 |
+
menangkap angka yang berubah diam-diam (bug EOQ: 5600 -> 55600).
|
| 77 |
+
"""
|
| 78 |
+
per_tipe = Counter(x.get("type") for x in items)
|
| 79 |
+
halaman = {x.get("page_idx") for x in items if x.get("page_idx") is not None}
|
| 80 |
+
|
| 81 |
+
peringatan: list[dict[str, Any]] = []
|
| 82 |
+
|
| 83 |
+
# 1. Halaman tanpa teks sama sekali -> kemungkinan gagal dibaca
|
| 84 |
+
teks_per_halaman = Counter(
|
| 85 |
+
x.get("page_idx") for x in items
|
| 86 |
+
if x.get("type") in {"text", "table", "equation"} and (x.get("text") or x.get("table_body"))
|
| 87 |
+
)
|
| 88 |
+
for p in sorted(halaman):
|
| 89 |
+
if teks_per_halaman.get(p, 0) == 0:
|
| 90 |
+
peringatan.append({"jenis": "halaman_kosong", "page": p})
|
| 91 |
+
|
| 92 |
+
# 2. Formula terpotong / kehilangan angka <- bug EOQ
|
| 93 |
+
for i, x in enumerate(items):
|
| 94 |
+
if x.get("type") != "equation":
|
| 95 |
+
continue
|
| 96 |
+
latex = (x.get("text") or "").strip()
|
| 97 |
+
if len(latex) < min_latex_len:
|
| 98 |
+
peringatan.append({"jenis": "latex_terlalu_pendek", "item": i,
|
| 99 |
+
"page": x.get("page_idx"), "isi": latex[:60]})
|
| 100 |
+
elif not _ADA_ANGKA.search(latex) and "\\frac" not in latex:
|
| 101 |
+
peringatan.append({"jenis": "latex_tanpa_angka", "item": i,
|
| 102 |
+
"page": x.get("page_idx"), "isi": latex[:60]})
|
| 103 |
+
|
| 104 |
+
# 3. ⭐ Angka di formula tidak ada di PDF asli -> berubah diam-diam.
|
| 105 |
+
# Ini SATU-SATUNYA pemeriksaan yang bisa menangkap bug EOQ; pemeriksaan
|
| 106 |
+
# lain di berkas ini hanya melihat output sendiri, dan angka yang salah
|
| 107 |
+
# tetap menghasilkan latex yang sah sepenuhnya.
|
| 108 |
+
angka_asli = _angka_per_halaman(pdf_sumber) if pdf_sumber else None
|
| 109 |
+
if angka_asli is not None:
|
| 110 |
+
for i, x in enumerate(items):
|
| 111 |
+
if x.get("type") != "equation":
|
| 112 |
+
continue
|
| 113 |
+
p = x.get("page_idx", 0) + offset_halaman
|
| 114 |
+
acuan = angka_asli.get(p)
|
| 115 |
+
if not acuan: # halaman tanpa lapisan teks -> tidak bisa dinilai
|
| 116 |
+
continue
|
| 117 |
+
for angka in sorted(_angka_dari_latex(x.get("text") or "")):
|
| 118 |
+
if angka not in acuan:
|
| 119 |
+
peringatan.append({
|
| 120 |
+
"jenis": "angka_tidak_ada_di_sumber", "item": i, "page": p,
|
| 121 |
+
"angka": angka,
|
| 122 |
+
"catatan": "angka pada formula tidak ditemukan di teks PDF asli",
|
| 123 |
+
})
|
| 124 |
+
|
| 125 |
+
# 4. Tabel tanpa isi
|
| 126 |
+
for i, x in enumerate(items):
|
| 127 |
+
if x.get("type") == "table" and not (x.get("table_body") or "").strip():
|
| 128 |
+
peringatan.append({"jenis": "tabel_kosong", "item": i, "page": x.get("page_idx")})
|
| 129 |
+
|
| 130 |
+
# 5. Chart tanpa konteks apa pun (tidak bisa dipakai tahap berikutnya)
|
| 131 |
+
for i, x in enumerate(items):
|
| 132 |
+
if x.get("type") == "chart":
|
| 133 |
+
punya = (x.get("content") or "").strip() or (x.get("chart_caption") or [])
|
| 134 |
+
if not punya:
|
| 135 |
+
peringatan.append({"jenis": "chart_tanpa_konteks", "item": i,
|
| 136 |
+
"page": x.get("page_idx")})
|
| 137 |
+
|
| 138 |
+
return {
|
| 139 |
+
"item": len(items),
|
| 140 |
+
"halaman_terdeteksi": len(halaman),
|
| 141 |
+
"per_tipe": dict(per_tipe),
|
| 142 |
+
"peringatan": peringatan,
|
| 143 |
+
"jumlah_peringatan": len(peringatan),
|
| 144 |
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Satu-satunya tempat pengaturan pipeline.
|
| 2 |
+
|
| 3 |
+
⭐ Titik flip GPU ada di sini: ubah `backend` dari "pipeline" ke "vlm" dan tidak
|
| 4 |
+
ada berkas lain yang perlu disentuh.
|
| 5 |
+
|
| 6 |
+
pipeline - murni CPU. Terbukti jalan di laptop tanpa GPU (19,0 dtk/hal @ 20 hal).
|
| 7 |
+
Dipakai sementara supaya pipeline bisa dibangun & diuji sekarang.
|
| 8 |
+
vlm - target resmi (akurasi 95,30). BUTUH GPU, VRAM >= 8 GB.
|
| 9 |
+
hybrid - butuh GPU juga. Di T4 kecepatannya setara pipeline-CPU, jadi
|
| 10 |
+
GPU di jalur ini membeli mutu, bukan kecepatan.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import json
|
| 17 |
+
from dataclasses import asdict, dataclass, field
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, Literal
|
| 20 |
+
|
| 21 |
+
Backend = Literal["pipeline", "vlm", "hybrid"]
|
| 22 |
+
|
| 23 |
+
# Akar penyimpanan: root repo (src/knowledge_parsing/ -> ../../).
|
| 24 |
+
# Semua keluaran (cache/, runs/) adalah DATA, bukan kode — di-gitignore.
|
| 25 |
+
ROOT = Path(__file__).resolve().parent.parent.parent
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class PipelineConfig:
|
| 30 |
+
# --- yang paling sering diubah ---
|
| 31 |
+
backend: Backend = "pipeline"
|
| 32 |
+
|
| 33 |
+
# ⚠️ MinerU TIDAK punya kode bahasa Indonesia. Dokumen berbahasa Indonesia
|
| 34 |
+
# memakai "ch", yang cakupannya "Chinese, English, Japanese, Chinese
|
| 35 |
+
# Traditional, LATIN" — aksara Latin itu yang mencakup bahasa Indonesia.
|
| 36 |
+
# Mengisi "id" akan gagal: ValueError: Language id not supported.
|
| 37 |
+
lang: str = "ch"
|
| 38 |
+
|
| 39 |
+
# --- lokasi ---
|
| 40 |
+
input_dir: Path = ROOT / "data" / "knowledge_docs"
|
| 41 |
+
cache_dir: Path = ROOT / "data" / "knowledge_cache" # hasil parse, dikunci isi dokumen
|
| 42 |
+
runs_dir: Path = ROOT / "data" / "knowledge_runs" # hasil normalize + manifest per run
|
| 43 |
+
|
| 44 |
+
# --- opsi MinerU ---
|
| 45 |
+
formula_enable: bool = True
|
| 46 |
+
table_enable: bool = True
|
| 47 |
+
effort: str = "medium" # hanya dipakai backend hybrid
|
| 48 |
+
start_page: int = 0
|
| 49 |
+
end_page: int | None = None
|
| 50 |
+
|
| 51 |
+
# --- perilaku ---
|
| 52 |
+
resume: bool = True # lewati dokumen yang sudah selesai
|
| 53 |
+
write_debug_pdf: bool = True # _layout.pdf & _span.pdf: cek mutu paling cepat
|
| 54 |
+
|
| 55 |
+
# --- ambang pemeriksaan mutu (lihat checks.py) ---
|
| 56 |
+
min_latex_len: int = 8 # latex lebih pendek dari ini dicurigai terpotong
|
| 57 |
+
|
| 58 |
+
extra: dict[str, Any] = field(default_factory=dict)
|
| 59 |
+
|
| 60 |
+
def lang_kanonik(self) -> str:
|
| 61 |
+
"""Kode bahasa yang benar-benar dipakai MinerU.
|
| 62 |
+
|
| 63 |
+
`validate_public_ocr_lang` sekaligus memvalidasi dan mengkanonikkan:
|
| 64 |
+
alias 'latin' / 'en' / 'japan' -> 'ch', dan kode tak dikenal ditolak.
|
| 65 |
+
Ini fungsi yang sama yang memunculkan pesan "Language id not supported".
|
| 66 |
+
|
| 67 |
+
Dipakai juga oleh sidik jari cache: tanpa dikanonikkan, `--lang latin`
|
| 68 |
+
dan `--lang ch` menghasilkan kunci cache berbeda padahal hasil
|
| 69 |
+
parsingnya identik — dokumen yang sama akan diparse dua kali.
|
| 70 |
+
|
| 71 |
+
Catatan: hanya ImportError yang ditangkap (MinerU belum terpasang).
|
| 72 |
+
ValueError sengaja DIBIARKAN naik — itu justru kode bahasa yang salah,
|
| 73 |
+
dan menelannya berarti kesalahan baru ketahuan setelah model dimuat.
|
| 74 |
+
"""
|
| 75 |
+
try:
|
| 76 |
+
from mineru.utils.ocr_language import validate_public_ocr_lang
|
| 77 |
+
except ImportError:
|
| 78 |
+
return self.lang
|
| 79 |
+
return validate_public_ocr_lang(self.lang)
|
| 80 |
+
|
| 81 |
+
def periksa(self) -> None:
|
| 82 |
+
"""Gagal cepat kalau pengaturannya salah.
|
| 83 |
+
|
| 84 |
+
Tanpa ini, kode bahasa yang keliru baru ketahuan setelah ~30 detik
|
| 85 |
+
memuat model — dan pada batch besar, setelah dokumen ke sekian.
|
| 86 |
+
"""
|
| 87 |
+
try:
|
| 88 |
+
self.lang_kanonik()
|
| 89 |
+
except ValueError as e:
|
| 90 |
+
raise SystemExit(
|
| 91 |
+
f"{e}\n"
|
| 92 |
+
" -> Dokumen berbahasa Indonesia memakai 'ch' "
|
| 93 |
+
"(mencakup aksara Latin). MinerU tidak punya kode 'id'."
|
| 94 |
+
) from e
|
| 95 |
+
|
| 96 |
+
def fingerprint(self) -> str:
|
| 97 |
+
"""Sidik jari pengaturan yang MEMPENGARUHI hasil parse.
|
| 98 |
+
|
| 99 |
+
Dipakai sebagai bagian kunci cache. Opsi yang tidak mengubah hasil
|
| 100 |
+
(resume, runs_dir, ambang pemeriksaan) sengaja tidak ikut, supaya
|
| 101 |
+
mengubahnya tidak membatalkan cache yang masih sah.
|
| 102 |
+
"""
|
| 103 |
+
bahan = {
|
| 104 |
+
"backend": self.backend,
|
| 105 |
+
"lang": self.lang_kanonik(),
|
| 106 |
+
"formula_enable": self.formula_enable,
|
| 107 |
+
"table_enable": self.table_enable,
|
| 108 |
+
"effort": self.effort if self.backend == "hybrid" else None,
|
| 109 |
+
"start_page": self.start_page,
|
| 110 |
+
"end_page": self.end_page,
|
| 111 |
+
}
|
| 112 |
+
payload = json.dumps(bahan, sort_keys=True).encode()
|
| 113 |
+
return hashlib.sha256(payload).hexdigest()[:12]
|
| 114 |
+
|
| 115 |
+
def to_dict(self) -> dict[str, Any]:
|
| 116 |
+
d = asdict(self)
|
| 117 |
+
for k, v in d.items():
|
| 118 |
+
if isinstance(v, Path):
|
| 119 |
+
d[k] = str(v)
|
| 120 |
+
d["fingerprint"] = self.fingerprint()
|
| 121 |
+
return d
|
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data shapes handed between pipeline stages — the parsing/extraction seam.
|
| 2 |
+
|
| 3 |
+
Extraction consumes `ParsedDocument`, never a file path and never the parser's
|
| 4 |
+
API. That is what keeps the parser swappable: a Tesseract or Azure Document
|
| 5 |
+
Intelligence path emits the same artifact, and the extraction half never learns
|
| 6 |
+
which one ran.
|
| 7 |
+
|
| 8 |
+
Design note — these fields were derived from real MinerU output, not designed on
|
| 9 |
+
paper. Verified against actual `content_list.json` output and cross-checked
|
| 10 |
+
against MinerU's source for both the `pipeline` and `vlm` backends.
|
| 11 |
+
|
| 12 |
+
Two things worth knowing before reviewing:
|
| 13 |
+
|
| 14 |
+
1. `bbox` and `text_level` are emitted by **both** backends — verified in
|
| 15 |
+
MinerU's source, where `pipeline` and `vlm` run identical logic:
|
| 16 |
+
|
| 17 |
+
elif para_type == BlockType.TITLE:
|
| 18 |
+
title_level = get_title_level(para_block)
|
| 19 |
+
if title_level != 0:
|
| 20 |
+
para_content['text_level'] = title_level
|
| 21 |
+
|
| 22 |
+
So heading structure is read from the document when MinerU detects titles,
|
| 23 |
+
and only derived from numbering patterns as a fallback.
|
| 24 |
+
|
| 25 |
+
2. Heading availability is **document-dependent, not backend-dependent**. A
|
| 26 |
+
document with explicit numbered headings (e.g. the BUMA standard: `2.` /
|
| 27 |
+
`2.1.` / `2.1.1.`) yields a clean hierarchy. A document of mid-chapter pages
|
| 28 |
+
with no headings yields none — which is why `section_no`, `heading` and
|
| 29 |
+
`heading_path` stay Optional rather than required.
|
| 30 |
+
|
| 31 |
+
⚠️ `Chunk.text` must stay VERBATIM from the source document. The extraction-side
|
| 32 |
+
guardrail locates LLM-quoted spans literally inside this text; if the text is
|
| 33 |
+
ever cleaned up (lines rejoined, whitespace normalised), the lookup fails and
|
| 34 |
+
the field is silently set to null instead of raising. The failure looks like a
|
| 35 |
+
bad LLM, but the cause would be here.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
from typing import Literal
|
| 41 |
+
|
| 42 |
+
from pydantic import BaseModel, Field
|
| 43 |
+
|
| 44 |
+
ChunkKind = Literal["text", "table", "chart", "equation"]
|
| 45 |
+
|
| 46 |
+
SCHEMA_VERSION = "0.2.0"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Chunk(BaseModel):
|
| 50 |
+
"""One unit of a parsed document, ready for the extraction stage."""
|
| 51 |
+
|
| 52 |
+
chunk_id: str
|
| 53 |
+
doc_id: str
|
| 54 |
+
kind: ChunkKind
|
| 55 |
+
|
| 56 |
+
# Content. VERBATIM — never reflowed or normalised. See note above.
|
| 57 |
+
#
|
| 58 |
+
# When a section has a heading, its heading line is the FIRST segment of
|
| 59 |
+
# this text, verbatim. Not for tidiness: in the BUMA standard the heading
|
| 60 |
+
# names the term ("2.1.3. Physical of Availability (PA)") and the body then
|
| 61 |
+
# opens with "Adalah ..." without repeating it. With the heading split out,
|
| 62 |
+
# the chunk that actually *defines* a term contains no mention of it, and
|
| 63 |
+
# ranks below calculation sections that merely use it repeatedly.
|
| 64 |
+
#
|
| 65 |
+
# It also makes this field the single place a span check has to look:
|
| 66 |
+
# `TermRecord.source_wording` is findable here, not in a second field.
|
| 67 |
+
text: str
|
| 68 |
+
|
| 69 |
+
# Location in the source document.
|
| 70 |
+
#
|
| 71 |
+
# Named `page_idx` deliberately: these are 0-BASED, exactly as MinerU reports
|
| 72 |
+
# them, with no conversion anywhere in the pipeline. Page numbers eventually
|
| 73 |
+
# reach a human review queue, and an off-by-one there is invisible until an
|
| 74 |
+
# expert opens the wrong page. Converting to 1-based is the UI's job, done
|
| 75 |
+
# once at display time — never here, so the artifact always matches the raw
|
| 76 |
+
# MinerU output kept alongside it.
|
| 77 |
+
page_idx: int
|
| 78 |
+
page_idxs: list[int] = Field(default_factory=list)
|
| 79 |
+
|
| 80 |
+
# Structural context. All Optional — many documents carry no headings.
|
| 81 |
+
section_no: str | None = None # e.g. "2.1.3", when the document is numbered
|
| 82 |
+
|
| 83 |
+
# This chunk's own section title, VERBATIM as the document writes it —
|
| 84 |
+
# including wording a reader may be tempted to "fix". The BUMA standard says
|
| 85 |
+
# "Physical of Availability (PA)", not "Physical Availability"; that exact
|
| 86 |
+
# string must reach the extraction model, or it gets silently normalised and
|
| 87 |
+
# the discrepancy never reaches the expert.
|
| 88 |
+
heading: str | None = None
|
| 89 |
+
|
| 90 |
+
# Running headers of every page this chunk spans, in page order. A list
|
| 91 |
+
# rather than a single value: a chunk crossing a chapter boundary would
|
| 92 |
+
# otherwise silently keep only the first page's chapter.
|
| 93 |
+
chapters: list[str] = Field(default_factory=list)
|
| 94 |
+
|
| 95 |
+
# Breadcrumb of enclosing headings, outermost first, built from MinerU's
|
| 96 |
+
# `text_level` hierarchy. Example from the BUMA standard:
|
| 97 |
+
# ["2. PENJELASAN PARAMETER", "2.1. Production Parameter", "2.1.1. Production"]
|
| 98 |
+
heading_path: list[str] = Field(default_factory=list)
|
| 99 |
+
|
| 100 |
+
# Flags for cheap filtering downstream
|
| 101 |
+
has_formula: bool = False
|
| 102 |
+
is_tabular: bool = False
|
| 103 |
+
|
| 104 |
+
# Trace back to the source: item indices in MinerU's content_list.json
|
| 105 |
+
source_items: list[int] = Field(default_factory=list)
|
| 106 |
+
|
| 107 |
+
# Position on the page of the first source item, as MinerU reports it.
|
| 108 |
+
# Carried through for a curation UI that highlights where on the page a
|
| 109 |
+
# definition came from. Nothing in the pipeline reasons about it — ordering
|
| 110 |
+
# uses item sequence, never coordinates.
|
| 111 |
+
bbox: list[int] | None = None
|
| 112 |
+
|
| 113 |
+
# Non-text attachments (formula images, table/chart crops)
|
| 114 |
+
images: list[str] = Field(default_factory=list)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class ParsedDocument(BaseModel):
|
| 118 |
+
"""The artifact itself — one parsed document, self-describing.
|
| 119 |
+
|
| 120 |
+
The chunk list alone is not enough to hand across the seam: an artifact that
|
| 121 |
+
travels to the extraction half must carry its own provenance. Without
|
| 122 |
+
`parser_name` / `parser_version` / `parser_backend`, a MinerU upgrade and a
|
| 123 |
+
prompt change are indistinguishable when extraction results shift.
|
| 124 |
+
|
| 125 |
+
`content_hash` is the hash of the SOURCE FILE, so re-parsing the same
|
| 126 |
+
document is detectable and a changed document forces a new artifact version.
|
| 127 |
+
"""
|
| 128 |
+
|
| 129 |
+
doc_id: str
|
| 130 |
+
chunks: list[Chunk]
|
| 131 |
+
|
| 132 |
+
# Identity of the source
|
| 133 |
+
source_path: str
|
| 134 |
+
content_hash: str # sha256 of the source file
|
| 135 |
+
n_pages: int
|
| 136 |
+
|
| 137 |
+
# Which parser produced this, and how
|
| 138 |
+
parser_name: str = "mineru"
|
| 139 |
+
parser_version: str | None = None # e.g. "3.4.4"
|
| 140 |
+
parser_backend: str | None = None # "pipeline" | "vlm" | "hybrid", as MinerU recorded it
|
| 141 |
+
parser_config: str | None = None # fingerprint of the settings that affect output
|
| 142 |
+
|
| 143 |
+
# Version of THIS ARTIFACT for this document — bumped when the document is
|
| 144 |
+
# re-parsed (new source content, new parser version, or changed settings).
|
| 145 |
+
# Distinct from `schema_version`, which versions the contract itself.
|
| 146 |
+
version: int = 1
|
| 147 |
+
|
| 148 |
+
schema_version: str = SCHEMA_VERSION
|
| 149 |
+
created_at: str | None = None
|
| 150 |
+
|
| 151 |
+
# Where the untouched MinerU output for this document lives
|
| 152 |
+
raw_output_dir: str | None = None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# --- Seam for the downstream stages (declared, not yet used) ---
|
| 156 |
+
# Written here so the handoff shape is visible from the start. The extraction
|
| 157 |
+
# half owns the final form of these two — see `KNOWLEDGE_PIPELINE_TODO.md` §5.
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class Mention(BaseModel):
|
| 161 |
+
"""One occurrence of a term inside a chunk."""
|
| 162 |
+
|
| 163 |
+
term: str
|
| 164 |
+
chunk_id: str
|
| 165 |
+
page_idx: int
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class TermRecord(BaseModel):
|
| 169 |
+
"""Extracted knowledge for one term (one cluster of mentions)."""
|
| 170 |
+
|
| 171 |
+
term: str
|
| 172 |
+
full_name: str | None = None
|
| 173 |
+
|
| 174 |
+
# What the document literally says, before any normalisation — e.g.
|
| 175 |
+
# "Physical of Availability (PA)" where `full_name` may read "Physical
|
| 176 |
+
# Availability". Span-checked against `Chunk.text` like every other field,
|
| 177 |
+
# so the discrepancy surfaces to the expert instead of being quietly fixed.
|
| 178 |
+
source_wording: str | None = None
|
| 179 |
+
|
| 180 |
+
definition: str | None = None
|
| 181 |
+
formula_latex: str | None = None
|
| 182 |
+
subdomain_tags: list[str] = Field(default_factory=list)
|
| 183 |
+
mention_count: int = 0
|
| 184 |
+
provenance: dict = Field(default_factory=dict)
|
| 185 |
+
extraction_status: str = "ok"
|
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Catatan satu kali jalan — bahan mentah untuk angka justifikasi biaya.
|
| 2 |
+
|
| 3 |
+
Yang WAJIB tercatat dan gampang terlupa: backend yang benar-benar dipakai dan
|
| 4 |
+
versi MinerU. Tanpa keduanya, angka CPU hari ini tidak sah dibandingkan dengan
|
| 5 |
+
angka GPU bulan depan — padahal perbandingan itulah inti argumennya.
|
| 6 |
+
|
| 7 |
+
Backend dibaca dari `_middle.json` keluaran MinerU (`_backend`), bukan dari
|
| 8 |
+
config, supaya yang tercatat adalah kenyataan, bukan niat.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import platform
|
| 15 |
+
from dataclasses import asdict, dataclass, field
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class CatatanDokumen:
|
| 23 |
+
doc_id: str
|
| 24 |
+
source: str
|
| 25 |
+
status: str # "ok" | "gagal"
|
| 26 |
+
pages: int = 0
|
| 27 |
+
detik_parse: float = 0.0
|
| 28 |
+
detik_normalisasi: float = 0.0
|
| 29 |
+
dari_cache: bool = False
|
| 30 |
+
jumlah_chunk: int = 0
|
| 31 |
+
backend_tercatat: str | None = None
|
| 32 |
+
pemeriksaan: dict[str, Any] = field(default_factory=dict)
|
| 33 |
+
galat: str | None = None
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def detik_per_halaman(self) -> float | None:
|
| 37 |
+
return self.detik_parse / self.pages if self.pages else None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class Manifest:
|
| 42 |
+
run_id: str
|
| 43 |
+
dimulai: str
|
| 44 |
+
config: dict[str, Any]
|
| 45 |
+
mineru_version: str | None = None
|
| 46 |
+
mesin: dict[str, str] = field(default_factory=dict)
|
| 47 |
+
dokumen: list[CatatanDokumen] = field(default_factory=list)
|
| 48 |
+
|
| 49 |
+
@classmethod
|
| 50 |
+
def baru(cls, config: dict[str, Any]) -> Manifest:
|
| 51 |
+
return cls(
|
| 52 |
+
run_id=datetime.now().strftime("%Y%m%d-%H%M%S"),
|
| 53 |
+
dimulai=datetime.now().isoformat(timespec="seconds"),
|
| 54 |
+
config=config,
|
| 55 |
+
mesin={
|
| 56 |
+
"os": platform.platform(),
|
| 57 |
+
"python": platform.python_version(),
|
| 58 |
+
"prosesor": platform.processor() or "?",
|
| 59 |
+
},
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
def ringkasan(self) -> dict[str, Any]:
|
| 63 |
+
ok = [d for d in self.dokumen if d.status == "ok"]
|
| 64 |
+
halaman = sum(d.pages for d in ok)
|
| 65 |
+
# Hanya run NYATA yang dihitung untuk kecepatan — hasil dari cache
|
| 66 |
+
# akan membuat angkanya terlihat jauh lebih cepat dari kenyataan.
|
| 67 |
+
nyata = [d for d in ok if not d.dari_cache]
|
| 68 |
+
detik = sum(d.detik_parse for d in nyata)
|
| 69 |
+
halaman_nyata = sum(d.pages for d in nyata)
|
| 70 |
+
return {
|
| 71 |
+
"dokumen_ok": len(ok),
|
| 72 |
+
"dokumen_gagal": len(self.dokumen) - len(ok),
|
| 73 |
+
"total_halaman": halaman,
|
| 74 |
+
"halaman_terukur": halaman_nyata,
|
| 75 |
+
"detik_parse_terukur": round(detik, 1),
|
| 76 |
+
"detik_per_halaman": round(detik / halaman_nyata, 2) if halaman_nyata else None,
|
| 77 |
+
"total_peringatan": sum(
|
| 78 |
+
d.pemeriksaan.get("jumlah_peringatan", 0) for d in self.dokumen
|
| 79 |
+
),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
def simpan(self, path: Path) -> None:
|
| 83 |
+
isi = asdict(self)
|
| 84 |
+
isi["ringkasan"] = self.ringkasan()
|
| 85 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 86 |
+
path.write_text(json.dumps(isi, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tahap 3 — ubah daftar item datar MinerU menjadi chunk yang bermakna.
|
| 2 |
+
|
| 3 |
+
Kenapa tahap ini ada: MinerU mengeluarkan daftar item DATAR, bukan unit
|
| 4 |
+
bermakna. Item-item itu perlu digabung jadi chunk per section.
|
| 5 |
+
|
| 6 |
+
MinerU memberi penanda judul `text_level` (2 / 2.1 / 2.1.1 -> level 2 / 3 / 4)
|
| 7 |
+
di KEDUA backend — dicek langsung ke source-nya, `pipeline` dan `vlm` memakai
|
| 8 |
+
logika yang sama. Tapi penanda itu hanya muncul kalau dokumennya memang punya
|
| 9 |
+
judul yang terdeteksi:
|
| 10 |
+
|
| 11 |
+
- dokumen standar BUMA (judul bernomor eksplisit) -> hierarki lengkap
|
| 12 |
+
- handbook McGraw-Hill (kumpulan halaman tengah bab) -> nyaris tidak ada,
|
| 13 |
+
1 dari 84 item, dan itu pun caption tabel
|
| 14 |
+
|
| 15 |
+
Jadi ketersediaan judul itu sifat DOKUMEN, bukan sifat backend. Karena itu
|
| 16 |
+
`text_level` dipakai sebagai sinyal utama, dengan pola penomoran sebagai
|
| 17 |
+
cadangan, dan hasilnya tetap boleh kosong.
|
| 18 |
+
|
| 19 |
+
Yang dilakukan:
|
| 20 |
+
- `page_number` dibuang (nomor halaman, bukan isi)
|
| 21 |
+
- `header` dipakai sebagai konteks bab, bukan judul section
|
| 22 |
+
(di dokumen uji semuanya header berjalan di bbox y~57-72, satu per halaman)
|
| 23 |
+
- judul section dari `text_level`, cadangan pola penomoran ("2.1.3 Judul"),
|
| 24 |
+
lalu disusun jadi `heading_path` (jejak judul induk sampai judul sendiri)
|
| 25 |
+
- `table` dan `chart` jadi chunk sendiri; `equation` menempel ke chunk berjalan
|
| 26 |
+
dan menyalakan `has_formula`
|
| 27 |
+
|
| 28 |
+
⚠️ Teks TIDAK PERNAH dirapikan di sini — tidak digabung barisnya, tidak
|
| 29 |
+
diseragamkan spasinya. Lihat alasannya di contracts.py.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import json
|
| 35 |
+
import re
|
| 36 |
+
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.*)$")
|
| 43 |
+
|
| 44 |
+
# Judul biasanya pendek. Ambang ini mencegah paragraf yang kebetulan diawali
|
| 45 |
+
# angka ikut dianggap judul. Nilainya diselaraskan dengan konstanta terkalibrasi
|
| 46 |
+
# di KNOWLEDGE_PIPELINE_CALIBRATION.md §4: baris lebih panjang dari ini adalah
|
| 47 |
+
# kalimat atau baris formula, bukan judul.
|
| 48 |
+
_MAKS_PANJANG_JUDUL = 90
|
| 49 |
+
|
| 50 |
+
_DIBUANG = {"page_number"}
|
| 51 |
+
|
| 52 |
+
# Batas ukuran chunk. Judul tetap batas utama; ini cuma pengaman untuk dokumen
|
| 53 |
+
# yang judulnya tidak terdeteksi sama sekali — tanpa batas, satu chunk bisa
|
| 54 |
+
# menelan seluruh dokumen, yang membuat evidence ranking tumpul dan menggelembungkan
|
| 55 |
+
# porsi token cabang summary. ~6000 karakter kira-kira setara 1.500 token.
|
| 56 |
+
MAKS_KARAKTER_CHUNK = 6000
|
| 57 |
+
|
| 58 |
+
|
| 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 |
+
`text_level` dari MinerU adalah sinyal utama — dipakai kalau ada, karena itu
|
| 63 |
+
hasil deteksi judul MinerU sendiri dan membawa tingkat hierarkinya
|
| 64 |
+
(2 / 2.1 / 2.1.1 -> level 2 / 3 / 4). Pola penomoran cuma cadangan untuk
|
| 65 |
+
dokumen yang judulnya tidak terdeteksi MinerU.
|
| 66 |
+
"""
|
| 67 |
+
if item.get("type") not in {"text", "title"}:
|
| 68 |
+
return None, None, None
|
| 69 |
+
teks = (item.get("text") or "").strip()
|
| 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 and not teks.endswith((".", ":", ";")):
|
| 80 |
+
# Tanpa text_level, tingkat diperkirakan dari kedalaman penomoran:
|
| 81 |
+
# "2" -> 1, "2.1" -> 2, "2.1.1" -> 3
|
| 82 |
+
return m.group(1), m.group(2), m.group(1).count(".") + 1
|
| 83 |
+
return None, None, None
|
| 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 |
+
|
| 93 |
+
|
| 94 |
+
def _teks_chart(item: dict[str, Any]) -> str:
|
| 95 |
+
bagian = list(item.get("chart_caption") or [])
|
| 96 |
+
if (item.get("content") or "").strip():
|
| 97 |
+
bagian.append(item["content"])
|
| 98 |
+
bagian += list(item.get("chart_footnote") or [])
|
| 99 |
+
return "\n\n".join(b for b in bagian if b)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def normalisasi(items: list[dict[str, Any]], doc_id: str) -> list[Chunk]:
|
| 103 |
+
# Konteks bab per halaman, dari header berjalan
|
| 104 |
+
bab_per_halaman: dict[int, str] = {}
|
| 105 |
+
for x in items:
|
| 106 |
+
if x.get("type") == "header" and (x.get("text") or "").strip():
|
| 107 |
+
bab_per_halaman.setdefault(x.get("page_idx", 0), x["text"].strip())
|
| 108 |
+
|
| 109 |
+
chunks: list[Chunk] = []
|
| 110 |
+
berjalan: Chunk | None = None
|
| 111 |
+
potongan: list[str] = []
|
| 112 |
+
|
| 113 |
+
# Tumpukan judul yang sedang berlaku: [(level, teks_judul), ...].
|
| 114 |
+
# Judul level N menutup semua judul level >= N sebelumnya.
|
| 115 |
+
tumpukan: list[tuple[int, str]] = []
|
| 116 |
+
|
| 117 |
+
def dorong_judul(level: int, teks: str) -> None:
|
| 118 |
+
while tumpukan and tumpukan[-1][0] >= level:
|
| 119 |
+
tumpukan.pop()
|
| 120 |
+
tumpukan.append((level, teks))
|
| 121 |
+
|
| 122 |
+
def tutup() -> None:
|
| 123 |
+
nonlocal berjalan, potongan
|
| 124 |
+
if berjalan is not None:
|
| 125 |
+
berjalan.text = "\n\n".join(potongan).strip()
|
| 126 |
+
if berjalan.text or berjalan.images:
|
| 127 |
+
berjalan.page_idxs = sorted(set(berjalan.page_idxs))
|
| 128 |
+
chunks.append(berjalan)
|
| 129 |
+
berjalan, potongan = None, []
|
| 130 |
+
|
| 131 |
+
def buka(kind: str, page: int, section_no=None, heading=None) -> Chunk:
|
| 132 |
+
return Chunk(
|
| 133 |
+
chunk_id=f"{doc_id}::{len(chunks):04d}",
|
| 134 |
+
doc_id=doc_id, kind=kind, text="",
|
| 135 |
+
page_idx=page, page_idxs=[page],
|
| 136 |
+
section_no=section_no, heading=heading,
|
| 137 |
+
chapters=[bab_per_halaman[page]] if page in bab_per_halaman else [],
|
| 138 |
+
heading_path=[t for _, t in tumpukan],
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
for i, item in enumerate(items):
|
| 142 |
+
tipe = item.get("type")
|
| 143 |
+
if tipe in _DIBUANG or tipe == "header":
|
| 144 |
+
continue
|
| 145 |
+
page = item.get("page_idx", 0)
|
| 146 |
+
|
| 147 |
+
if tipe == "table":
|
| 148 |
+
tutup()
|
| 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"):
|
| 155 |
+
c.images = [item["img_path"]]
|
| 156 |
+
if c.text or c.images:
|
| 157 |
+
chunks.append(c)
|
| 158 |
+
continue
|
| 159 |
+
|
| 160 |
+
if tipe == "chart":
|
| 161 |
+
tutup()
|
| 162 |
+
c = buka("chart", page)
|
| 163 |
+
c.text = _teks_chart(item)
|
| 164 |
+
c.source_items = [i]
|
| 165 |
+
c.bbox = item.get("bbox")
|
| 166 |
+
if item.get("img_path"):
|
| 167 |
+
c.images = [item["img_path"]]
|
| 168 |
+
chunks.append(c)
|
| 169 |
+
continue
|
| 170 |
+
|
| 171 |
+
if tipe == "equation":
|
| 172 |
+
latex = (item.get("text") or "").strip()
|
| 173 |
+
if berjalan is None:
|
| 174 |
+
berjalan = buka("text", page)
|
| 175 |
+
berjalan.has_formula = True
|
| 176 |
+
berjalan.source_items.append(i)
|
| 177 |
+
berjalan.page_idxs.append(page)
|
| 178 |
+
if item.get("img_path"):
|
| 179 |
+
berjalan.images.append(item["img_path"])
|
| 180 |
+
if latex:
|
| 181 |
+
potongan.append(latex)
|
| 182 |
+
continue
|
| 183 |
+
|
| 184 |
+
# sisanya: teks
|
| 185 |
+
teks = (item.get("text") or "").strip()
|
| 186 |
+
if not teks:
|
| 187 |
+
continue
|
| 188 |
+
|
| 189 |
+
nomor, judul, level = _judul(item)
|
| 190 |
+
if judul is not None:
|
| 191 |
+
# BREADCRUMB: banyak dokumen mencetak ulang jalur judulnya di atas
|
| 192 |
+
# setiap halaman (BUMA mengulang "2. PENJELASAN PARAMETER /
|
| 193 |
+
# 2.1. Production Parameter" di hal. 2-8). Judul yang SUDAH ada di
|
| 194 |
+
# tumpukan berarti section yang sedang berjalan atau induknya —
|
| 195 |
+
# bukan section baru. Tanpa aturan ini, section yang sama pecah
|
| 196 |
+
# berkali-kali dan semua angka di hilir ikut rusak.
|
| 197 |
+
if any(teks == t for _, t in tumpukan):
|
| 198 |
+
if berjalan is not None:
|
| 199 |
+
berjalan.page_idxs.append(page) # section berlanjut, halaman meluas
|
| 200 |
+
continue
|
| 201 |
+
tutup()
|
| 202 |
+
# Judul didorong SEBELUM chunk dibuka, supaya chunk isinya membawa
|
| 203 |
+
# judulnya sendiri di ujung heading_path.
|
| 204 |
+
dorong_judul(level or 1, teks)
|
| 205 |
+
berjalan = buka("text", page, section_no=nomor, heading=judul)
|
| 206 |
+
berjalan.source_items = [i]
|
| 207 |
+
berjalan.bbox = item.get("bbox")
|
| 208 |
+
# Baris judul ikut masuk `text`, verbatim, sebagai potongan pertama.
|
| 209 |
+
#
|
| 210 |
+
# Alasannya bukan kerapian. Di standar BUMA, judul menyebut istilahnya
|
| 211 |
+
# ("2.1.3. Physical of Availability (PA)") lalu badan section mulai
|
| 212 |
+
# dengan "Adalah ..." TANPA mengulang istilahnya. Kalau judul dipisah
|
| 213 |
+
# dari teks, chunk yang justru mendefinisikan sebuah istilah tidak
|
| 214 |
+
# memuat istilah itu sama sekali — jadi kalah ranking dari section
|
| 215 |
+
# perhitungan yang cuma menyebutnya berkali-kali.
|
| 216 |
+
#
|
| 217 |
+
# Menaruhnya di sini juga membuat `text` jadi satu-satunya tempat
|
| 218 |
+
# pengecekan span: `source_wording` bisa dicari harfiah di dalamnya,
|
| 219 |
+
# tidak perlu melihat dua field.
|
| 220 |
+
potongan.append(teks)
|
| 221 |
+
continue
|
| 222 |
+
|
| 223 |
+
if berjalan is None:
|
| 224 |
+
berjalan = buka("text", page)
|
| 225 |
+
berjalan.bbox = item.get("bbox")
|
| 226 |
+
berjalan.source_items.append(i)
|
| 227 |
+
berjalan.page_idxs.append(page)
|
| 228 |
+
bab = bab_per_halaman.get(page)
|
| 229 |
+
if bab and bab not in berjalan.chapters:
|
| 230 |
+
berjalan.chapters.append(bab)
|
| 231 |
+
potongan.append(teks) # verbatim, tanpa dirapikan
|
| 232 |
+
|
| 233 |
+
# Pengaman ukuran: hanya berlaku untuk dokumen tanpa judul terdeteksi.
|
| 234 |
+
# Chunk dipotong di batas item, jadi teksnya tetap verbatim.
|
| 235 |
+
if sum(len(x) for x in potongan) >= MAKS_KARAKTER_CHUNK:
|
| 236 |
+
lanjutan_dari = berjalan
|
| 237 |
+
tutup()
|
| 238 |
+
berjalan = buka("text", page,
|
| 239 |
+
section_no=lanjutan_dari.section_no,
|
| 240 |
+
heading=lanjutan_dari.heading)
|
| 241 |
+
|
| 242 |
+
tutup()
|
| 243 |
+
return chunks
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def normalisasi_dari_berkas(content_list: Path, doc_id: str) -> list[Chunk]:
|
| 247 |
+
items = json.loads(content_list.read_text(encoding="utf-8"))
|
| 248 |
+
return normalisasi(items, doc_id)
|
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tahap 2 — parsing dokumen dengan MinerU, dengan cache berbasis isi.
|
| 2 |
+
|
| 3 |
+
Kenapa cache-nya dikunci ke ISI dokumen, bukan ke nomor run: parsing adalah
|
| 4 |
+
tahap termahal (19 dtk/halaman di CPU). Kalau cache-nya per run, tiap kali
|
| 5 |
+
normalizer diubah dan pipeline dijalankan ulang, parsing ikut terulang percuma.
|
| 6 |
+
Dengan kunci isi+pengaturan+versi, dokumen yang sama hanya diparse sekali —
|
| 7 |
+
mau normalizer diubah 20 kali sekalipun.
|
| 8 |
+
|
| 9 |
+
Kunci cache = sha256(isi berkas) + sidik jari pengaturan + versi MinerU.
|
| 10 |
+
Versi ikut supaya angka hasil MinerU lama tidak diam-diam tercampur dengan
|
| 11 |
+
hasil versi baru — ini penting karena angkanya dipakai untuk justifikasi biaya.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import hashlib
|
| 17 |
+
import json
|
| 18 |
+
import shutil
|
| 19 |
+
import time
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
from .config import PipelineConfig
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class ParseResult:
|
| 28 |
+
doc_id: str
|
| 29 |
+
source: Path
|
| 30 |
+
cache_dir: Path # folder berisi output MinerU apa adanya
|
| 31 |
+
content_list: Path # *_content_list.json
|
| 32 |
+
middle_json: Path | None
|
| 33 |
+
pages: int
|
| 34 |
+
seconds: float
|
| 35 |
+
from_cache: bool
|
| 36 |
+
backend_tercatat: str | None # dibaca dari _middle.json, BUKAN dari config
|
| 37 |
+
mineru_version: str | None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def versi_mineru() -> str:
|
| 41 |
+
try:
|
| 42 |
+
from mineru.version import __version__ # type: ignore
|
| 43 |
+
return str(__version__)
|
| 44 |
+
except Exception:
|
| 45 |
+
try:
|
| 46 |
+
from importlib.metadata import version
|
| 47 |
+
return version("mineru")
|
| 48 |
+
except Exception:
|
| 49 |
+
return "unknown"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def hash_berkas(path: Path, potong: int = 16) -> str:
|
| 53 |
+
h = hashlib.sha256()
|
| 54 |
+
with path.open("rb") as f:
|
| 55 |
+
for blok in iter(lambda: f.read(1 << 20), b""):
|
| 56 |
+
h.update(blok)
|
| 57 |
+
return h.hexdigest()[:potong]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def kunci_cache(source: Path, cfg: PipelineConfig) -> str:
|
| 61 |
+
return f"{hash_berkas(source)}-{cfg.fingerprint()}-{versi_mineru()}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _cari_output(root: Path) -> tuple[Path | None, Path | None]:
|
| 65 |
+
"""Cari content_list & middle json di bawah root.
|
| 66 |
+
|
| 67 |
+
Sengaja pakai glob, bukan path tetap: struktur subfolder MinerU berbeda
|
| 68 |
+
antar backend ('auto' untuk pipeline, lain untuk vlm). Glob membuat modul
|
| 69 |
+
ini tidak perlu diubah saat backend diganti.
|
| 70 |
+
"""
|
| 71 |
+
content = next(iter(sorted(root.rglob("*_content_list.json"))), None)
|
| 72 |
+
middle = next(iter(sorted(root.rglob("*_middle.json"))), None)
|
| 73 |
+
return content, middle
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _baca_middle(middle: Path | None) -> tuple[str | None, str | None]:
|
| 77 |
+
"""Ambil backend & versi yang BENAR-BENAR dipakai, dari output MinerU."""
|
| 78 |
+
if not middle or not middle.exists():
|
| 79 |
+
return None, None
|
| 80 |
+
try:
|
| 81 |
+
d = json.loads(middle.read_text(encoding="utf-8"))
|
| 82 |
+
return d.get("_backend"), d.get("_version_name")
|
| 83 |
+
except Exception:
|
| 84 |
+
return None, None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def parse_dokumen(source: Path, cfg: PipelineConfig) -> ParseResult:
|
| 88 |
+
"""Parse satu dokumen. Kalau sudah ada di cache, tidak dijalankan ulang."""
|
| 89 |
+
doc_id = source.stem
|
| 90 |
+
tujuan = cfg.cache_dir / "parse" / kunci_cache(source, cfg)
|
| 91 |
+
penanda = tujuan / ".selesai"
|
| 92 |
+
|
| 93 |
+
if penanda.exists():
|
| 94 |
+
content, middle = _cari_output(tujuan)
|
| 95 |
+
if content:
|
| 96 |
+
meta = json.loads(penanda.read_text(encoding="utf-8"))
|
| 97 |
+
backend_tercatat, versi = _baca_middle(middle)
|
| 98 |
+
return ParseResult(
|
| 99 |
+
doc_id=doc_id, source=source, cache_dir=tujuan,
|
| 100 |
+
content_list=content, middle_json=middle,
|
| 101 |
+
pages=meta.get("pages", 0), seconds=meta.get("seconds", 0.0),
|
| 102 |
+
from_cache=True,
|
| 103 |
+
backend_tercatat=backend_tercatat, mineru_version=versi,
|
| 104 |
+
)
|
| 105 |
+
shutil.rmtree(tujuan, ignore_errors=True) # cache rusak, ulangi
|
| 106 |
+
|
| 107 |
+
from mineru.cli.common import do_parse, read_fn
|
| 108 |
+
|
| 109 |
+
# Playground melakukan ini sebelum parsing; alias seperti "latin"/"en"
|
| 110 |
+
# diterjemahkan ke kode kanonik. Disamakan supaya hasilnya identik.
|
| 111 |
+
lang = cfg.lang_kanonik()
|
| 112 |
+
|
| 113 |
+
sedang = tujuan.with_suffix(".sedang")
|
| 114 |
+
shutil.rmtree(sedang, ignore_errors=True)
|
| 115 |
+
sedang.mkdir(parents=True, exist_ok=True)
|
| 116 |
+
|
| 117 |
+
pdf_bytes = read_fn(source)
|
| 118 |
+
mulai = time.perf_counter()
|
| 119 |
+
do_parse(
|
| 120 |
+
output_dir=str(sedang),
|
| 121 |
+
pdf_file_names=[doc_id],
|
| 122 |
+
pdf_bytes_list=[pdf_bytes],
|
| 123 |
+
p_lang_list=[lang],
|
| 124 |
+
backend=cfg.backend,
|
| 125 |
+
formula_enable=cfg.formula_enable,
|
| 126 |
+
table_enable=cfg.table_enable,
|
| 127 |
+
f_draw_layout_bbox=cfg.write_debug_pdf,
|
| 128 |
+
f_draw_span_bbox=cfg.write_debug_pdf,
|
| 129 |
+
start_page_id=cfg.start_page,
|
| 130 |
+
end_page_id=cfg.end_page,
|
| 131 |
+
**({"effort": cfg.effort} if cfg.backend == "hybrid" else {}),
|
| 132 |
+
)
|
| 133 |
+
detik = time.perf_counter() - mulai
|
| 134 |
+
|
| 135 |
+
content, middle = _cari_output(sedang)
|
| 136 |
+
if content is None:
|
| 137 |
+
raise RuntimeError(
|
| 138 |
+
f"MinerU selesai tapi *_content_list.json tidak ditemukan di {sedang}"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
halaman = _hitung_halaman(source, cfg)
|
| 142 |
+
sedang.rename(tujuan)
|
| 143 |
+
content = tujuan / content.relative_to(sedang)
|
| 144 |
+
middle = tujuan / middle.relative_to(sedang) if middle else None
|
| 145 |
+
|
| 146 |
+
penanda.write_text(
|
| 147 |
+
json.dumps({"pages": halaman, "seconds": detik, "doc_id": doc_id}),
|
| 148 |
+
encoding="utf-8",
|
| 149 |
+
)
|
| 150 |
+
backend_tercatat, versi = _baca_middle(middle)
|
| 151 |
+
|
| 152 |
+
return ParseResult(
|
| 153 |
+
doc_id=doc_id, source=source, cache_dir=tujuan,
|
| 154 |
+
content_list=content, middle_json=middle,
|
| 155 |
+
pages=halaman, seconds=detik, from_cache=False,
|
| 156 |
+
backend_tercatat=backend_tercatat, mineru_version=versi,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _hitung_halaman(source: Path, cfg: PipelineConfig) -> int:
|
| 161 |
+
if cfg.end_page is not None:
|
| 162 |
+
return cfg.end_page - cfg.start_page + 1
|
| 163 |
+
try:
|
| 164 |
+
from pypdf import PdfReader
|
| 165 |
+
return len(PdfReader(str(source)).pages)
|
| 166 |
+
except Exception:
|
| 167 |
+
return 0
|
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rangkum semua run jadi angka siap pakai untuk justifikasi resource.
|
| 2 |
+
|
| 3 |
+
py -m src.knowledge_parsing.report
|
| 4 |
+
py -m src.knowledge_parsing.report --scale 6800
|
| 5 |
+
|
| 6 |
+
`--scale` mengekstrapolasi ke jumlah halaman tertentu (mis. skala BUMA
|
| 7 |
+
±6.700-6.800 halaman) memakai detik/halaman yang TERUKUR — dokumen yang
|
| 8 |
+
diambil dari cache tidak ikut dihitung, karena akan membuat angkanya
|
| 9 |
+
terlihat jauh lebih cepat dari kenyataan.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from .config import PipelineConfig
|
| 19 |
+
|
| 20 |
+
# Di bawah ini, ongkos tetap pemuatan model mendominasi sehingga detik/halaman
|
| 21 |
+
# jauh lebih besar dari kenyataan pada dokumen besar. Terukur di mesin ini:
|
| 22 |
+
# 1 hal -> 54,0 dtk/hal, 5 hal -> 33,2, 20 hal -> 19,0.
|
| 23 |
+
MIN_HALAMAN_SAHIH = 20
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _jam(detik: float) -> str:
|
| 27 |
+
if detik < 90:
|
| 28 |
+
return f"{detik:.0f} dtk"
|
| 29 |
+
if detik < 5400:
|
| 30 |
+
return f"{detik/60:.1f} mnt"
|
| 31 |
+
return f"{detik/3600:.1f} jam"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def kumpulkan(runs_dir: Path) -> list[dict]:
|
| 35 |
+
hasil = []
|
| 36 |
+
for m in sorted(runs_dir.glob("*/manifest.json")):
|
| 37 |
+
try:
|
| 38 |
+
hasil.append(json.loads(m.read_text(encoding="utf-8")))
|
| 39 |
+
except (OSError, json.JSONDecodeError) as e:
|
| 40 |
+
# Manifest rusak/separuh tertulis (run yang mati di tengah). Dilewati,
|
| 41 |
+
# tapi disebutkan — laporan yang diam-diam kehilangan satu run akan
|
| 42 |
+
# memberi angka detik/halaman yang salah tanpa ada tandanya.
|
| 43 |
+
print(f" ! lewati manifest tidak terbaca: {m} ({type(e).__name__})")
|
| 44 |
+
return hasil
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def cetak(runs: list[dict], scale: int | None) -> None:
|
| 48 |
+
if not runs:
|
| 49 |
+
print("Belum ada run. Jalankan: py -m src.knowledge_parsing.run --input dokumen/")
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
print(f"{'run':<17} {'backend':<9} {'ver':<8} {'dok':>4} {'hal':>5} "
|
| 53 |
+
f"{'dtk/hal':>8} {'peringatan':>11}")
|
| 54 |
+
print("-" * 68)
|
| 55 |
+
|
| 56 |
+
for r in runs:
|
| 57 |
+
s = r.get("ringkasan", {})
|
| 58 |
+
cfg = r.get("config", {})
|
| 59 |
+
# backend yang tercatat di output MinerU lebih dipercaya daripada config
|
| 60 |
+
tercatat = {d.get("backend_tercatat") for d in r.get("dokumen", [])} - {None}
|
| 61 |
+
backend = "/".join(sorted(tercatat)) if tercatat else cfg.get("backend", "?")
|
| 62 |
+
dph = s.get("detik_per_halaman")
|
| 63 |
+
print(f"{r.get('run_id',''):<17} {backend:<9} "
|
| 64 |
+
f"{str(r.get('mineru_version') or '?')[:7]:<8} "
|
| 65 |
+
f"{s.get('dokumen_ok',0):>4} {s.get('total_halaman',0):>5} "
|
| 66 |
+
f"{(f'{dph:.2f}' if dph else '-'):>8} {s.get('total_peringatan',0):>11}")
|
| 67 |
+
|
| 68 |
+
terukur = [r for r in runs if (r.get("ringkasan") or {}).get("detik_per_halaman")]
|
| 69 |
+
if not terukur:
|
| 70 |
+
print("\n(belum ada run yang benar-benar diparse — semua dari cache)")
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
print("\n--- ekstrapolasi ---")
|
| 74 |
+
target = scale or 6800
|
| 75 |
+
meragukan = False
|
| 76 |
+
for r in terukur:
|
| 77 |
+
s = r["ringkasan"]
|
| 78 |
+
dph = s["detik_per_halaman"]
|
| 79 |
+
hal = s.get("halaman_terukur", 0)
|
| 80 |
+
cfg = r.get("config", {})
|
| 81 |
+
tanda = ""
|
| 82 |
+
if hal < MIN_HALAMAN_SAHIH:
|
| 83 |
+
tanda = f" ⚠ hanya {hal} hal."
|
| 84 |
+
meragukan = True
|
| 85 |
+
print(f" {cfg.get('backend','?'):<9} {dph:>6.2f} dtk/hal → "
|
| 86 |
+
f"{target} halaman = {_jam(dph * target)}{tanda}")
|
| 87 |
+
|
| 88 |
+
if meragukan:
|
| 89 |
+
print(
|
| 90 |
+
f"\n ⚠ Run dengan < {MIN_HALAMAN_SAHIH} halaman TIDAK layak diekstrapolasi.\n"
|
| 91 |
+
" Pemuatan model adalah ongkos tetap, jadi dokumen kecil terlihat jauh\n"
|
| 92 |
+
" lebih lambat per halaman. Terukur sebelumnya di mesin ini:\n"
|
| 93 |
+
" 1 hal -> 54,0 dtk/hal | 5 hal -> 33,2 | 20 hal -> 19,0\n"
|
| 94 |
+
" Pakai dokumen >= 20 halaman sebelum angkanya dibawa ke luar."
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
if len(terukur) >= 2:
|
| 98 |
+
cepat = min(terukur, key=lambda r: r["ringkasan"]["detik_per_halaman"])
|
| 99 |
+
lambat = max(terukur, key=lambda r: r["ringkasan"]["detik_per_halaman"])
|
| 100 |
+
a = lambat["ringkasan"]["detik_per_halaman"]
|
| 101 |
+
b = cepat["ringkasan"]["detik_per_halaman"]
|
| 102 |
+
if b:
|
| 103 |
+
print(f"\n selisih tercepat vs terlambat: {a/b:.1f}×")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def main() -> None:
|
| 107 |
+
p = argparse.ArgumentParser(description="Ringkasan angka dari semua run")
|
| 108 |
+
p.add_argument("--runs-dir", type=Path, default=None)
|
| 109 |
+
p.add_argument("--scale", type=int, default=None,
|
| 110 |
+
help="ekstrapolasi ke berapa halaman (mis. 6800)")
|
| 111 |
+
a = p.parse_args()
|
| 112 |
+
cfg = PipelineConfig()
|
| 113 |
+
cetak(kumpulkan(a.runs_dir or cfg.runs_dir), a.scale)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
main()
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Perangkai tahap 1-3: satu folder dokumen masuk, chunk + manifest keluar.
|
| 2 |
+
|
| 3 |
+
py -m src.knowledge_parsing.run --input dokumen/
|
| 4 |
+
py -m src.knowledge_parsing.run --input dokumen/ --backend vlm # setelah GPU ada
|
| 5 |
+
|
| 6 |
+
Sifat yang disengaja:
|
| 7 |
+
- satu dokumen gagal TIDAK menghentikan sisanya (dicatat di failures.jsonl)
|
| 8 |
+
- bisa dilanjut dari tengah: dokumen yang sudah selesai dilewati (--no-resume
|
| 9 |
+
untuk memaksa ulang)
|
| 10 |
+
- output MinerU asli tidak disentuh, hanya ditunjuk dari runs/
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import time
|
| 18 |
+
import traceback
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
from .checks import periksa_items
|
| 23 |
+
from .config import PipelineConfig
|
| 24 |
+
from .contracts import ParsedDocument
|
| 25 |
+
from .manifest import CatatanDokumen, Manifest
|
| 26 |
+
from .normalize import normalisasi
|
| 27 |
+
from .parse import hash_berkas, parse_dokumen, versi_mineru
|
| 28 |
+
|
| 29 |
+
EKSTENSI = {".pdf", ".docx", ".pptx", ".doc", ".ppt"}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def kumpulkan_dokumen(folder: Path) -> list[Path]:
|
| 33 |
+
return sorted(p for p in folder.rglob("*") if p.suffix.lower() in EKSTENSI)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def jalankan(cfg: PipelineConfig) -> Manifest:
|
| 37 |
+
cfg.periksa()
|
| 38 |
+
dokumen = kumpulkan_dokumen(cfg.input_dir)
|
| 39 |
+
if not dokumen:
|
| 40 |
+
raise SystemExit(f"Tidak ada dokumen di {cfg.input_dir}")
|
| 41 |
+
|
| 42 |
+
manifest = Manifest.baru(cfg.to_dict())
|
| 43 |
+
manifest.mineru_version = versi_mineru()
|
| 44 |
+
|
| 45 |
+
run_dir = cfg.runs_dir / manifest.run_id
|
| 46 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
failures = run_dir / "failures.jsonl"
|
| 48 |
+
|
| 49 |
+
print(f"Run {manifest.run_id} | backend={cfg.backend} | {len(dokumen)} dokumen")
|
| 50 |
+
print(f"Hasil ke: {run_dir}\n")
|
| 51 |
+
|
| 52 |
+
for n, source in enumerate(dokumen, 1):
|
| 53 |
+
doc_id = source.stem
|
| 54 |
+
tujuan = run_dir / doc_id
|
| 55 |
+
chunk_file = tujuan / "chunks.json"
|
| 56 |
+
|
| 57 |
+
if cfg.resume and chunk_file.exists():
|
| 58 |
+
print(f"[{n}/{len(dokumen)}] {doc_id} — dilewati (sudah ada)")
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
print(f"[{n}/{len(dokumen)}] {doc_id} … ", end="", flush=True)
|
| 62 |
+
catatan = CatatanDokumen(doc_id=doc_id, source=str(source), status="ok")
|
| 63 |
+
try:
|
| 64 |
+
hasil = parse_dokumen(source, cfg)
|
| 65 |
+
catatan.pages = hasil.pages
|
| 66 |
+
catatan.detik_parse = hasil.seconds
|
| 67 |
+
catatan.dari_cache = hasil.from_cache
|
| 68 |
+
catatan.backend_tercatat = hasil.backend_tercatat
|
| 69 |
+
|
| 70 |
+
items = json.loads(hasil.content_list.read_text(encoding="utf-8"))
|
| 71 |
+
# PDF asli ikut dikirim: tanpa pembanding, angka yang berubah
|
| 72 |
+
# diam-diam (bug EOQ) tidak akan pernah terdeteksi.
|
| 73 |
+
catatan.pemeriksaan = periksa_items(
|
| 74 |
+
items, cfg.min_latex_len,
|
| 75 |
+
pdf_sumber=source, offset_halaman=cfg.start_page,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
t0 = time.perf_counter()
|
| 79 |
+
chunks = normalisasi(items, doc_id)
|
| 80 |
+
catatan.detik_normalisasi = time.perf_counter() - t0
|
| 81 |
+
catatan.jumlah_chunk = len(chunks)
|
| 82 |
+
|
| 83 |
+
artifact = ParsedDocument(
|
| 84 |
+
doc_id=doc_id,
|
| 85 |
+
chunks=chunks,
|
| 86 |
+
source_path=str(source),
|
| 87 |
+
content_hash=hash_berkas(source, potong=64),
|
| 88 |
+
n_pages=hasil.pages,
|
| 89 |
+
parser_version=hasil.mineru_version or versi_mineru(),
|
| 90 |
+
# backend dari _middle.json (yang benar-benar jalan), bukan dari config
|
| 91 |
+
parser_backend=hasil.backend_tercatat or cfg.backend,
|
| 92 |
+
parser_config=cfg.fingerprint(),
|
| 93 |
+
created_at=datetime.now().isoformat(timespec="seconds"),
|
| 94 |
+
raw_output_dir=str(hasil.cache_dir),
|
| 95 |
+
)
|
| 96 |
+
tujuan.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
chunk_file.write_text(
|
| 98 |
+
artifact.model_dump_json(indent=2),
|
| 99 |
+
encoding="utf-8",
|
| 100 |
+
)
|
| 101 |
+
# Penunjuk ke output MinerU asli — tidak disalin supaya tidak dobel.
|
| 102 |
+
(tujuan / "sumber-mineru.txt").write_text(
|
| 103 |
+
str(hasil.cache_dir), encoding="utf-8"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
tanda = " (cache)" if hasil.from_cache else f" {hasil.seconds:.1f}s"
|
| 107 |
+
peringatan = catatan.pemeriksaan.get("jumlah_peringatan", 0)
|
| 108 |
+
tanda += f" | {len(chunks)} chunk"
|
| 109 |
+
if peringatan:
|
| 110 |
+
tanda += f" | ⚠ {peringatan} peringatan"
|
| 111 |
+
print("ok" + tanda)
|
| 112 |
+
|
| 113 |
+
except Exception as e: # satu gagal tidak menghentikan sisanya
|
| 114 |
+
catatan.status = "gagal"
|
| 115 |
+
catatan.galat = f"{type(e).__name__}: {e}"
|
| 116 |
+
print(f"GAGAL — {catatan.galat}")
|
| 117 |
+
with failures.open("a", encoding="utf-8") as f:
|
| 118 |
+
f.write(json.dumps({
|
| 119 |
+
"doc_id": doc_id, "source": str(source),
|
| 120 |
+
"galat": catatan.galat, "trace": traceback.format_exc(),
|
| 121 |
+
}, ensure_ascii=False) + "\n")
|
| 122 |
+
|
| 123 |
+
manifest.dokumen.append(catatan)
|
| 124 |
+
manifest.simpan(run_dir / "manifest.json")
|
| 125 |
+
|
| 126 |
+
r = manifest.ringkasan()
|
| 127 |
+
print("\n--- ringkasan ---")
|
| 128 |
+
print(f" ok / gagal : {r['dokumen_ok']} / {r['dokumen_gagal']}")
|
| 129 |
+
print(f" total halaman : {r['total_halaman']}")
|
| 130 |
+
if r["detik_per_halaman"]:
|
| 131 |
+
print(f" detik/halaman : {r['detik_per_halaman']} "
|
| 132 |
+
f"(dari {r['halaman_terukur']} hal. yang benar-benar diparse)")
|
| 133 |
+
if r["total_peringatan"]:
|
| 134 |
+
print(f" ⚠ peringatan mutu: {r['total_peringatan']} — lihat manifest.json")
|
| 135 |
+
print(f" manifest : {run_dir / 'manifest.json'}")
|
| 136 |
+
return manifest
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def main() -> None:
|
| 140 |
+
p = argparse.ArgumentParser(description="Pipeline parsing dokumen (MinerU)")
|
| 141 |
+
p.add_argument("--input", type=Path, help="folder berisi dokumen")
|
| 142 |
+
p.add_argument("--backend", choices=["pipeline", "vlm", "hybrid"],
|
| 143 |
+
help="pipeline=CPU, vlm=butuh GPU (target resmi)")
|
| 144 |
+
p.add_argument("--lang", default=None)
|
| 145 |
+
p.add_argument("--no-resume", action="store_true", help="proses ulang semua")
|
| 146 |
+
p.add_argument("--start-page", type=int, default=None)
|
| 147 |
+
p.add_argument("--end-page", type=int, default=None)
|
| 148 |
+
a = p.parse_args()
|
| 149 |
+
|
| 150 |
+
cfg = PipelineConfig()
|
| 151 |
+
if a.input:
|
| 152 |
+
cfg.input_dir = a.input
|
| 153 |
+
if a.backend:
|
| 154 |
+
cfg.backend = a.backend
|
| 155 |
+
if a.lang:
|
| 156 |
+
cfg.lang = a.lang
|
| 157 |
+
if a.no_resume:
|
| 158 |
+
cfg.resume = False
|
| 159 |
+
if a.start_page is not None:
|
| 160 |
+
cfg.start_page = a.start_page
|
| 161 |
+
if a.end_page is not None:
|
| 162 |
+
cfg.end_page = a.end_page
|
| 163 |
+
|
| 164 |
+
jalankan(cfg)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
if __name__ == "__main__":
|
| 168 |
+
main()
|