sofhiaazzhr Claude Opus 5 commited on
Commit
5840d20
·
1 Parent(s): 6038b2b

[NOTICKET] feat(knowledge-parsing): document parsing half of the knowledge pipeline

Browse files

Implements §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 CHANGED
@@ -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/
pyproject.toml CHANGED
@@ -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"
src/knowledge_parsing/__init__.py ADDED
@@ -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
+ ]
src/knowledge_parsing/checks.py ADDED
@@ -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
+ }
src/knowledge_parsing/config.py ADDED
@@ -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
src/knowledge_parsing/contracts.py ADDED
@@ -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"
src/knowledge_parsing/manifest.py ADDED
@@ -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")
src/knowledge_parsing/normalize.py ADDED
@@ -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)
src/knowledge_parsing/parse.py ADDED
@@ -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
src/knowledge_parsing/report.py ADDED
@@ -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()
src/knowledge_parsing/run.py ADDED
@@ -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()