"""Incrementally convert nonferrous reports with per-file timeout.""" from __future__ import annotations import argparse import csv import multiprocessing as mp import re import zipfile from pathlib import Path def clean_text(text: str) -> str: text = text.replace("\x00", " ") text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def worker(raw_path_s: str, detected_type: str, queue: mp.Queue) -> None: raw_path = Path(raw_path_s) try: if detected_type == "PDF": from pypdf import PdfReader reader = PdfReader(str(raw_path)) parts: list[str] = [] for idx, page in enumerate(reader.pages, start=1): parts.append(f"\n\n[[PAGE {idx}]]\n{page.extract_text() or ''}") queue.put(("OK", clean_text("\n".join(parts)), len(reader.pages), "")) return if detected_type == "ZIP_OR_OFFICE": parts: list[str] = [] with zipfile.ZipFile(raw_path) as zf: for name in zf.namelist(): lower = name.lower() if not ( lower.endswith(".xml") and ( lower.startswith("word/") or lower.startswith("ppt/") or lower.startswith("xl/sharedstrings") ) ): continue raw = zf.read(name).decode("utf-8", errors="ignore") raw = re.sub(r"<[^>]+>", " ", raw) raw = re.sub(r"\s+", " ", raw).strip() if raw: parts.append(f"[[{name}]]\n{raw}") text = clean_text("\n\n".join(parts)) if text: queue.put(("OK", text, 0, "")) else: queue.put(("FAIL", "", 0, "no readable Office XML text found")) return queue.put(("FAIL", "", 0, f"unsupported detected_type={detected_type}")) except Exception as exc: queue.put(("FAIL", "", 0, f"{type(exc).__name__}: {exc}")) def convert_one(root: Path, row: dict[str, str], converted_dir: Path, timeout: int) -> dict[str, str | int]: raw_path = root / row["raw_file_path"] out_path = converted_dir / f"{raw_path.name}.txt" base = { "conversion_id": row["inventory_id"].replace("YS-RAW-INV", "YS-B3-CONV"), "source_doc_id": row["inventory_id"], "case_id": row["case_id"], "batch_id": row["batch_id"], "sub_batch_id": row.get("sub_batch_id", ""), "run_id": row["run_id"], "raw_file_path": row["raw_file_path"], "raw_sha256": row["sha256"], "detected_type": row["detected_type"], "conversion_tool": "pypdf_or_office_zip_xml_timeout", "converted_text_path": "", "page_count": 0, "char_count": 0, "conversion_status": "FAILED", "error_message": "", "review_status": "DRAFT_FOR_REVIEW", } if out_path.exists() and out_path.stat().st_size > 0: text_len = out_path.stat().st_size base.update( { "converted_text_path": out_path.relative_to(root).as_posix(), "char_count": text_len, "conversion_status": "TEXT_CONVERTED", "error_message": "preexisting converted text reused", } ) return base queue: mp.Queue = mp.Queue() proc = mp.Process(target=worker, args=(str(raw_path), row["detected_type"], queue)) proc.start() proc.join(timeout) if proc.is_alive(): proc.terminate() proc.join(5) base["error_message"] = f"timeout after {timeout}s" return base if queue.empty(): base["error_message"] = "worker returned no result" return base status, text, pages, error = queue.get() if status == "OK" and text: out_path.write_text(text, encoding="utf-8") base.update( { "converted_text_path": out_path.relative_to(root).as_posix(), "page_count": pages, "char_count": len(text), "conversion_status": "TEXT_CONVERTED", } ) else: base["error_message"] = error or "empty converted text" return base def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--project-root", default=".") parser.add_argument("--inventory", required=True) parser.add_argument("--status-out", required=True) parser.add_argument("--converted-dir", required=True) parser.add_argument("--timeout-seconds", type=int, default=60) parser.add_argument("--start-index", type=int, default=1) parser.add_argument("--limit", type=int, default=0) parser.add_argument("--sub-batch-size", type=int, default=30) args = parser.parse_args() root = Path(args.project_root).resolve() converted_dir = root / args.converted_dir converted_dir.mkdir(parents=True, exist_ok=True) with (root / args.inventory).open("r", encoding="utf-8-sig", newline="") as f: rows = [r for r in csv.DictReader(f) if r.get("already_in_source_document") == "NO"] for idx, row in enumerate(rows, start=1): row["sub_batch_id"] = f"SB{((idx - 1) // args.sub_batch_size) + 1:03d}" row["run_id"] = f"RUN-ANA-YS-INDUSTRY-001-BATCH-003-CONVERT-001-{row['sub_batch_id']}" rows = rows[args.start_index - 1 :] if args.limit: rows = rows[: args.limit] status_path = root / args.status_out status_path.parent.mkdir(parents=True, exist_ok=True) fields = [ "conversion_id", "source_doc_id", "case_id", "batch_id", "sub_batch_id", "run_id", "raw_file_path", "raw_sha256", "detected_type", "conversion_tool", "converted_text_path", "page_count", "char_count", "conversion_status", "error_message", "review_status", ] write_header = not status_path.exists() with status_path.open("a", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=fields) if write_header: writer.writeheader() for i, row in enumerate(rows, start=args.start_index): result = convert_one(root, row, converted_dir, args.timeout_seconds) writer.writerow(result) f.flush() print(i, result["conversion_status"], row["raw_file_path"]) return 0 if __name__ == "__main__": raise SystemExit(main())