File size: 12,361 Bytes
7f153d6
5840d20
7f153d6
 
5840d20
7f153d6
 
 
 
5840d20
 
 
 
 
 
df84654
5840d20
 
 
 
 
7f153d6
5840d20
 
7f153d6
c64a0cb
6856ea8
 
7f153d6
5840d20
7f153d6
5840d20
 
7f153d6
 
5840d20
 
d5ed50c
 
 
 
 
9083dd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f153d6
 
 
 
 
5840d20
9083dd1
 
 
 
 
7f153d6
 
5840d20
 
 
 
 
d5ed50c
7f153d6
5840d20
7f153d6
5840d20
7f153d6
 
5840d20
 
7f153d6
5840d20
 
7f153d6
 
5840d20
7f153d6
 
 
 
 
 
6856ea8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5840d20
 
 
df571be
 
 
5840d20
 
7f153d6
 
6856ea8
7f153d6
6856ea8
7f153d6
5840d20
6856ea8
 
 
 
 
 
5840d20
7f153d6
5840d20
7f153d6
5840d20
 
 
 
7f153d6
 
 
5840d20
 
7f153d6
 
d5ed50c
7f153d6
d5ed50c
7f153d6
 
 
 
 
 
5840d20
 
 
7f153d6
5840d20
 
7f153d6
 
 
 
 
 
 
 
 
 
 
 
5840d20
 
 
 
 
7f153d6
 
 
 
 
 
df84654
7f153d6
df84654
 
 
7f153d6
 
6856ea8
 
 
5840d20
7f153d6
 
 
c113bf8
7f153d6
5840d20
 
 
 
 
 
 
 
 
 
 
c113bf8
 
5840d20
 
 
 
 
 
 
7f153d6
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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
"""Stages 1-3 wired together: a folder of documents in, chunks + manifest out.

    py -m src.knowledge_parsing.run --input data/knowledge_docs/
    py -m src.knowledge_parsing.run --input data/knowledge_docs/ --backend pipeline

Deliberate properties:
  - one document failing does NOT stop the rest (recorded in failures.jsonl)
  - resumable: documents already finished are skipped (--no-resume to force)
  - MinerU's own output is never touched, only pointed at from runs/
"""

from __future__ import annotations

import argparse
import json
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path

from .checks import check_items, vocabulary_per_page
from .config import PipelineConfig
from .contracts import ParsedDocument
from .manifest import DocumentRecord, Manifest
from .normalize import normalise, normaliser_version
from .normalize_mistral import normalise_mistral
from .normalize_mistral import normaliser_version as mistral_normaliser_version
from .parse import file_hash, mineru_version, parse_document

EXTENSIONS = {".pdf", ".docx", ".pptx", ".doc", ".ppt"}


def collect_documents(folder: Path) -> list[Path]:
    return sorted(p for p in folder.rglob("*") if p.suffix.lower() in EXTENSIONS)


def _count(n: int, word: str) -> str:
    """`3 chunks` / `1 chunk` β€” console output only, never parsed by anything."""
    return f"{n} {word}" if n == 1 else f"{n} {word}s"


def _reject_duplicate_names(documents: list[Path]) -> None:
    """Two files with one name is ambiguous, and the ambiguity is silent.

    `doc_id` is the file name, `chunk_id` is built from `doc_id`, and every
    `term_id` / `formula_id` / `rule_id` downstream is derived from it in turn.
    Two documents sharing a name therefore claim the same identity β€” and
    because `resume` skips a document whose `chunks.json` already exists, the
    second one is quietly reported as "already present" and never parsed at
    all. The run ends `ok / failed : 2 / 0` with one document missing.

    This fails instead, before any parsing is paid for. It deliberately does
    NOT disambiguate automatically: renaming one to `Laporan_2` would silently
    change the identifier an expert's approve/reject decisions hang on, which
    is a worse failure than stopping and asking.
    """
    by_stem: dict[str, list[Path]] = {}
    for src in documents:
        by_stem.setdefault(src.stem, []).append(src)

    clashes = {stem: paths for stem, paths in by_stem.items() if len(paths) > 1}
    if not clashes:
        return

    lines = ["Two or more documents share one name, and doc_id is built from it:"]
    for stem, paths in sorted(clashes.items()):
        lines.append(f"  '{stem}'")
        lines.extend(f"      {p}" for p in sorted(paths))
    lines.append("  -> rename one, or parse them in separate runs.")
    raise SystemExit("\n".join(lines))


def _warn_identical_content(documents: list[Path]) -> None:
    """Same bytes under two names: legal, wasteful, and worth saying out loud.

    A document downloaded twice arrives as `X.pdf` and `X (2).pdf`. Both are
    real files with different names, so neither this module nor extraction may
    silently drop one β€” which of them is authoritative is a curation decision.

    But both are parsed (the second free, from the content-addressed cache) and
    both reach extraction as separate documents, so the expert reviews the same
    terms twice with nothing indicating why. Detection is cheap: identical
    content means an identical file hash.
    """
    by_hash: dict[str, list[Path]] = {}
    for src in documents:
        by_hash.setdefault(file_hash(src), []).append(src)

    for paths in by_hash.values():
        if len(paths) > 1:
            print(f"⚠ {len(paths)} documents have identical content:")
            for p in sorted(paths):
                print(f"      {p.name}")
            print("  Both are parsed and both reach extraction as separate documents.")


def run(cfg: PipelineConfig) -> Manifest:
    cfg.validate()
    documents = collect_documents(cfg.input_dir)
    if not documents:
        raise SystemExit(f"No documents found in {cfg.input_dir}")

    # Both run before anything is parsed: a 16-hour batch must not discover a
    # name clash on its last document.
    _reject_duplicate_names(documents)
    _warn_identical_content(documents)

    manifest = Manifest.new(cfg.to_dict())
    manifest.mineru_version = mineru_version()

    run_dir = cfg.runs_dir / manifest.run_id
    run_dir.mkdir(parents=True, exist_ok=True)
    failures = run_dir / "failures.jsonl"

    print(f"Run {manifest.run_id} | backend={cfg.backend} | {_count(len(documents), 'document')}")
    print(f"Output to: {run_dir}\n")

    for n, source in enumerate(documents, 1):
        doc_id = source.stem
        target = run_dir / doc_id
        chunk_file = target / "chunks.json"

        if cfg.resume and chunk_file.exists():
            print(f"[{n}/{len(documents)}] {doc_id} β€” skipped (already present)")
            continue

        print(f"[{n}/{len(documents)}] {doc_id} … ", end="", flush=True)
        record = DocumentRecord(doc_id=doc_id, source=str(source), status="ok")
        try:
            result = parse_document(source, cfg)
            record.pages = result.pages
            record.parse_seconds = result.seconds
            record.from_cache = result.from_cache
            record.backend_recorded = result.backend_recorded

            if cfg.backend == "mistral":
                response = json.loads(result.content_list.read_text(encoding="utf-8"))
                # MinerU's content-drift check and the word-boundary vocabulary are
                # both MinerU-specific β€” Mistral emits neither a content_list nor
                # character-spaced formulas, so neither applies.
                record.checks = {"backend": "mistral-ocr"}
                t0 = time.perf_counter()
                chunks = normalise_mistral(response, doc_id)
                record.normalise_seconds = time.perf_counter() - t0
                record.chunk_count = len(chunks)
            else:
                items = json.loads(result.content_list.read_text(encoding="utf-8"))
                # The source PDF goes in too: without something to compare against, a
                # number that changed silently (the EOQ bug) would never be detected.
                record.checks = check_items(
                    items, cfg.min_latex_len,
                    source_pdf=source, page_offset=cfg.start_page,
                )

                # The source page vocabulary, used to restore the word boundaries lost
                # when MinerU writes formulas one character at a time. Re-keyed to
                # MinerU's page indices: page_idx 0 means page `start_page` in the PDF.
                raw_vocabulary = vocabulary_per_page(source)
                vocabulary = (
                    {p - cfg.start_page: words for p, words in raw_vocabulary.items()
                     if p >= cfg.start_page}
                    if raw_vocabulary else None
                )

                t0 = time.perf_counter()
                chunks = normalise(items, doc_id, vocabulary)
                record.normalise_seconds = time.perf_counter() - t0
                record.chunk_count = len(chunks)

            artifact = ParsedDocument(
                doc_id=doc_id,
                # Equal to doc_id today, deliberately separate anyway β€” see the
                # field's note in contracts.py.
                source_title=source.stem,
                chunks=chunks,
                source_path=str(source),
                content_hash=file_hash(source, length=64),
                n_pages=result.pages,
                parser_name="mistral-ocr" if cfg.backend == "mistral" else "mineru",
                parser_version=result.mineru_version or mineru_version(),
                # backend as the parser actually recorded it, not the config
                parser_backend=result.backend_recorded or cfg.backend,
                parser_config=cfg.fingerprint(),
                # Which normalisation code produced these chunks β€” the mistral and
                # MinerU normalisers are fingerprinted separately.
                normaliser_version=(
                    mistral_normaliser_version() if cfg.backend == "mistral"
                    else normaliser_version()
                ),
                created_at=datetime.now().isoformat(timespec="seconds"),
                raw_output_dir=str(result.cache_dir),
            )
            target.mkdir(parents=True, exist_ok=True)
            chunk_file.write_text(
                artifact.model_dump_json(indent=2),
                encoding="utf-8",
            )
            # A pointer to MinerU's own output β€” not copied, so it is not doubled.
            (target / "mineru-source.txt").write_text(
                str(result.cache_dir), encoding="utf-8"
            )

            mark = " (cache)" if result.from_cache else f" {result.seconds:.1f}s"
            warnings = record.checks.get("warning_count", 0)
            mark += f" | {_count(len(chunks), 'chunk')}"
            if warnings:
                mark += f" | ⚠ {_count(warnings, 'warning')}"
            print("ok" + mark)

        except Exception as e:  # one failure must not stop the rest
            record.status = "failed"
            record.error = f"{type(e).__name__}: {e}"
            print(f"FAILED β€” {record.error}")
            with failures.open("a", encoding="utf-8") as f:
                f.write(json.dumps({
                    "doc_id": doc_id, "source": str(source),
                    "error": record.error, "trace": traceback.format_exc(),
                }, ensure_ascii=False) + "\n")

        manifest.documents.append(record)
        manifest.save(run_dir / "manifest.json")

    s = manifest.summary()
    print("\n--- summary ---")
    print(f"  ok / failed     : {s['documents_ok']} / {s['documents_failed']}")
    print(f"  total pages     : {s['total_pages']}")
    if s["seconds_per_page"]:
        print(f"  seconds/page    : {s['seconds_per_page']}  "
              f"(from {s['pages_measured']} page(s) actually parsed)")
    if s["total_warnings"]:
        print(f"  ⚠ quality warnings: {s['total_warnings']} β€” see manifest.json")
    print(f"  manifest        : {run_dir / 'manifest.json'}")
    return manifest


def main() -> None:
    # A Windows console defaults to cp1252, which has no "⚠". Without this,
    # printing a quality warning raises UnicodeEncodeError INSIDE the per-document
    # try block β€” so a document carrying warnings is marked FAILED even though its
    # parse succeeded and was written. The inverse of the intent: the documents
    # whose quality needs attention are the ones thrown away.
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[union-attr]
        except (AttributeError, OSError):
            pass

    p = argparse.ArgumentParser(description="Document parsing pipeline (MinerU)")
    p.add_argument("--input", type=Path, help="folder of documents")
    p.add_argument("--backend", choices=["pipeline", "vlm", "hybrid", "mistral"],
                   help="mistral=API, no GPU (default); pipeline=CPU MinerU; "
                        "vlm/hybrid=GPU MinerU")
    p.add_argument("--lang", default=None)
    # `hybrid` only. Not merely fast-vs-thorough: MinerU FORCE-DISABLES image
    # analysis at `medium`, so descriptions exist only at `high` β€” and always
    # on `vlm`.
    p.add_argument("--effort", choices=["medium", "high"], default=None)
    p.add_argument("--no-resume", action="store_true", help="reprocess everything")
    p.add_argument("--start-page", type=int, default=None)
    p.add_argument("--end-page", type=int, default=None)
    a = p.parse_args()

    cfg = PipelineConfig()
    if a.input:
        cfg.input_dir = a.input
    if a.backend:
        cfg.backend = a.backend
    if a.lang:
        cfg.lang = a.lang
    if a.effort:
        cfg.effort = a.effort
    if a.no_resume:
        cfg.resume = False
    if a.start_page is not None:
        cfg.start_page = a.start_page
    if a.end_page is not None:
        cfg.end_page = a.end_page

    run(cfg)


if __name__ == "__main__":
    main()