File size: 5,784 Bytes
5840d20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
"""Tahap 2 — parsing dokumen dengan MinerU, dengan cache berbasis isi.

Kenapa cache-nya dikunci ke ISI dokumen, bukan ke nomor run: parsing adalah
tahap termahal (19 dtk/halaman di CPU). Kalau cache-nya per run, tiap kali
normalizer diubah dan pipeline dijalankan ulang, parsing ikut terulang percuma.
Dengan kunci isi+pengaturan+versi, dokumen yang sama hanya diparse sekali —
mau normalizer diubah 20 kali sekalipun.

Kunci cache = sha256(isi berkas) + sidik jari pengaturan + versi MinerU.
Versi ikut supaya angka hasil MinerU lama tidak diam-diam tercampur dengan
hasil versi baru — ini penting karena angkanya dipakai untuk justifikasi biaya.
"""

from __future__ import annotations

import hashlib
import json
import shutil
import time
from dataclasses import dataclass
from pathlib import Path

from .config import PipelineConfig


@dataclass
class ParseResult:
    doc_id: str
    source: Path
    cache_dir: Path          # folder berisi output MinerU apa adanya
    content_list: Path       # *_content_list.json
    middle_json: Path | None
    pages: int
    seconds: float
    from_cache: bool
    backend_tercatat: str | None    # dibaca dari _middle.json, BUKAN dari config
    mineru_version: str | None


def versi_mineru() -> str:
    try:
        from mineru.version import __version__  # type: ignore
        return str(__version__)
    except Exception:
        try:
            from importlib.metadata import version
            return version("mineru")
        except Exception:
            return "unknown"


def hash_berkas(path: Path, potong: int = 16) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for blok in iter(lambda: f.read(1 << 20), b""):
            h.update(blok)
    return h.hexdigest()[:potong]


def kunci_cache(source: Path, cfg: PipelineConfig) -> str:
    return f"{hash_berkas(source)}-{cfg.fingerprint()}-{versi_mineru()}"


def _cari_output(root: Path) -> tuple[Path | None, Path | None]:
    """Cari content_list & middle json di bawah root.

    Sengaja pakai glob, bukan path tetap: struktur subfolder MinerU berbeda
    antar backend ('auto' untuk pipeline, lain untuk vlm). Glob membuat modul
    ini tidak perlu diubah saat backend diganti.
    """
    content = next(iter(sorted(root.rglob("*_content_list.json"))), None)
    middle = next(iter(sorted(root.rglob("*_middle.json"))), None)
    return content, middle


def _baca_middle(middle: Path | None) -> tuple[str | None, str | None]:
    """Ambil backend & versi yang BENAR-BENAR dipakai, dari output MinerU."""
    if not middle or not middle.exists():
        return None, None
    try:
        d = json.loads(middle.read_text(encoding="utf-8"))
        return d.get("_backend"), d.get("_version_name")
    except Exception:
        return None, None


def parse_dokumen(source: Path, cfg: PipelineConfig) -> ParseResult:
    """Parse satu dokumen. Kalau sudah ada di cache, tidak dijalankan ulang."""
    doc_id = source.stem
    tujuan = cfg.cache_dir / "parse" / kunci_cache(source, cfg)
    penanda = tujuan / ".selesai"

    if penanda.exists():
        content, middle = _cari_output(tujuan)
        if content:
            meta = json.loads(penanda.read_text(encoding="utf-8"))
            backend_tercatat, versi = _baca_middle(middle)
            return ParseResult(
                doc_id=doc_id, source=source, cache_dir=tujuan,
                content_list=content, middle_json=middle,
                pages=meta.get("pages", 0), seconds=meta.get("seconds", 0.0),
                from_cache=True,
                backend_tercatat=backend_tercatat, mineru_version=versi,
            )
        shutil.rmtree(tujuan, ignore_errors=True)   # cache rusak, ulangi

    from mineru.cli.common import do_parse, read_fn

    # Playground melakukan ini sebelum parsing; alias seperti "latin"/"en"
    # diterjemahkan ke kode kanonik. Disamakan supaya hasilnya identik.
    lang = cfg.lang_kanonik()

    sedang = tujuan.with_suffix(".sedang")
    shutil.rmtree(sedang, ignore_errors=True)
    sedang.mkdir(parents=True, exist_ok=True)

    pdf_bytes = read_fn(source)
    mulai = time.perf_counter()
    do_parse(
        output_dir=str(sedang),
        pdf_file_names=[doc_id],
        pdf_bytes_list=[pdf_bytes],
        p_lang_list=[lang],
        backend=cfg.backend,
        formula_enable=cfg.formula_enable,
        table_enable=cfg.table_enable,
        f_draw_layout_bbox=cfg.write_debug_pdf,
        f_draw_span_bbox=cfg.write_debug_pdf,
        start_page_id=cfg.start_page,
        end_page_id=cfg.end_page,
        **({"effort": cfg.effort} if cfg.backend == "hybrid" else {}),
    )
    detik = time.perf_counter() - mulai

    content, middle = _cari_output(sedang)
    if content is None:
        raise RuntimeError(
            f"MinerU selesai tapi *_content_list.json tidak ditemukan di {sedang}"
        )

    halaman = _hitung_halaman(source, cfg)
    sedang.rename(tujuan)
    content = tujuan / content.relative_to(sedang)
    middle = tujuan / middle.relative_to(sedang) if middle else None

    penanda.write_text(
        json.dumps({"pages": halaman, "seconds": detik, "doc_id": doc_id}),
        encoding="utf-8",
    )
    backend_tercatat, versi = _baca_middle(middle)

    return ParseResult(
        doc_id=doc_id, source=source, cache_dir=tujuan,
        content_list=content, middle_json=middle,
        pages=halaman, seconds=detik, from_cache=False,
        backend_tercatat=backend_tercatat, mineru_version=versi,
    )


def _hitung_halaman(source: Path, cfg: PipelineConfig) -> int:
    if cfg.end_page is not None:
        return cfg.end_page - cfg.start_page + 1
    try:
        from pypdf import PdfReader
        return len(PdfReader(str(source)).pages)
    except Exception:
        return 0