File size: 16,285 Bytes
b68816f f07443e b68816f 7124acf b68816f f07443e b68816f 7124acf b68816f 7124acf b68816f f07443e b68816f 7124acf b68816f 7124acf b68816f f07443e b68816f f07443e b68816f f07443e b68816f f07443e b68816f f07443e b68816f | 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 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 | """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 dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from .assets import AssetSink, LocalAssetSink, NullAssetSink
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 .normalize_paddle import normalise_paddle, unknown_labels
from .normalize_paddle import normaliser_version as paddle_normaliser_version
from .parse import ParseResult, 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.")
@dataclass
class _ParseOutcome:
"""What `_parse_one` produces: the artifact plus the run-level numbers.
`parse_one` (the library entry point) returns only `.artifact`; `run()` reads
the rest β parse timing, cache hit, quality checks β for its per-document
manifest entry. Those live on `ParseResult`/`checks`, not on the artifact, so
surfacing them here keeps `run()`'s manifest exactly as rich as before without
recomputing anything on a second, drift-prone path.
"""
artifact: ParsedDocument
parse_result: ParseResult
checks: dict[str, Any]
normalise_seconds: float
def _parse_one(
source: Path,
cfg: PipelineConfig,
doc_id: str,
asset_sink: AssetSink | None = None,
) -> _ParseOutcome:
"""The per-document core: parse, normalise, assemble the `ParsedDocument`.
The single place a `ParsedDocument` is built. Both `parse_one` and `run()` go
through here, so the two callers cannot drift β a change to the artifact is a
change in one spot, and the seam tests catch it because the shape is versioned.
"""
result = parse_document(source, cfg)
if cfg.backend == "paddleocr":
# Added 2026-09-24, reached from v2 document processing only (the CLI's
# --backend choices are unchanged). Unknown layout labels are kept as prose
# and listed here so a new label gets noticed.
response = json.loads(result.content_list.read_text(encoding="utf-8"))
checks: dict[str, Any] = {
"backend": "paddleocr-vl", "unknown_labels": unknown_labels(response),
}
t0 = time.perf_counter()
chunks = normalise_paddle(response, doc_id, asset_sink=asset_sink)
normalise_seconds = time.perf_counter() - t0
elif 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.
checks = {"backend": "mistral-ocr"}
t0 = time.perf_counter()
chunks = normalise_mistral(response, doc_id, asset_sink=asset_sink)
normalise_seconds = time.perf_counter() - t0
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.
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)
normalise_seconds = time.perf_counter() - t0
artifact = ParsedDocument(
doc_id=doc_id,
# The human-readable label stays the file stem even once `doc_id` becomes
# a stable key β 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": "mistral-ocr", "paddleocr": "paddleocr-vl"}.get(
cfg.backend, "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 paddle_normaliser_version() if cfg.backend == "paddleocr"
else normaliser_version()
),
created_at=datetime.now().isoformat(timespec="seconds"),
raw_output_dir=str(result.cache_dir),
)
return _ParseOutcome(
artifact=artifact,
parse_result=result,
checks=checks,
normalise_seconds=normalise_seconds,
)
def parse_one(
source: Path,
cfg: PipelineConfig,
doc_id: str | None = None,
asset_sink: AssetSink | None = None,
) -> ParsedDocument:
"""Parse one document and return the artifact in memory. Raises on failure.
The single-document entry point for callers that persist the artifact
themselves β the HTTP ingestion service, which parses one `documents` row at
a time. No folder scan, no manifest, no file written here; `run()` owns all of
that and calls the same core for its parse, so there is one artifact builder.
`doc_id` is the identity every downstream `entity_id` derives from. Passing it
explicitly is what lets ingestion supply a STABLE key (`documents.id`) so a
file rename no longer mints all-new ids and orphans the expert approvals
already recorded against the old ones. `None` keeps today's behaviour β the
file stem β which is what `run()` passes.
"""
if doc_id is None:
doc_id = source.stem
return _parse_one(source, cfg, doc_id, asset_sink).artifact
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:
# Same core the ingestion service calls β the artifact is built once,
# here and there. run() adds only the folder-batch concerns around it:
# the manifest record and writing the result to disk.
# Assets land beside the artifact they belong to, so a run folder is
# self-describing and movable. `--no-assets` keeps the metadata and
# drops only the bytes (see assets.NullAssetSink).
sink: AssetSink = (
NullAssetSink() if cfg.no_assets else LocalAssetSink(target)
)
outcome = _parse_one(source, cfg, doc_id, asset_sink=sink)
artifact = outcome.artifact
result = outcome.parse_result
record.pages = result.pages
record.parse_seconds = result.seconds
record.from_cache = result.from_cache
record.backend_recorded = result.backend_recorded
record.checks = outcome.checks
record.normalise_seconds = outcome.normalise_seconds
record.chunk_count = len(artifact.chunks)
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(artifact.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("--no-assets", action="store_true",
help="keep asset metadata but do not write image bytes to disk")
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.no_assets:
cfg.no_assets = True
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()
|