From 0c3ecb2bc358940f9e49696609ea6a7d2c6427f4 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Fri, 5 Jun 2026 11:28:34 +0800 Subject: [PATCH] feat: surface OCR version runtime state in status and doctor --- paperforge/commands/ocr.py | 39 +++++++++++++++++++++++++ paperforge/worker/status.py | 39 +++++++++++++++++++++++++ tests/test_ocr_doctor.py | 33 +++++++++++++++++++++ tests/test_status.py | 58 +++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+) diff --git a/paperforge/commands/ocr.py b/paperforge/commands/ocr.py index a0a56b3f..086a0aca 100644 --- a/paperforge/commands/ocr.py +++ b/paperforge/commands/ocr.py @@ -89,6 +89,31 @@ def _diagnose(vault: Path, live: bool = False, json_output: bool = False) -> int level = result.get("level", 0) passed = result.get("passed", False) + # Collect OCR version state + _version_state_summary: dict = {"total_papers": 0, "derived_stale": [], "raw_upgradable": []} + try: + from paperforge.config import load_vault_config + cfg = load_vault_config(vault) + ocr_root = vault / cfg["system_dir"] / "PaperForge" / "ocr" + if ocr_root.exists(): + from paperforge.worker._utils import read_json + _version_papers = [] + for paper_dir in ocr_root.iterdir(): + if not paper_dir.is_dir(): + continue + meta_path = paper_dir / "meta.json" + if meta_path.exists(): + meta = read_json(meta_path) + if "raw_version" in meta or "derived_version" in meta: + _version_papers.append(meta) + _version_state_summary = { + "total_papers": len(_version_papers), + "derived_stale": [m.get("zotero_key", "?") for m in _version_papers if m.get("derived_stale")], + "raw_upgradable": [m.get("zotero_key", "?") for m in _version_papers if m.get("raw_upgradable")], + } + except Exception: + pass + if json_output: queue_data = _collect_ocr_queue_data(vault) structured_health = _collect_ocr_health_summary(vault) @@ -118,6 +143,7 @@ def _diagnose(vault: Path, live: bool = False, json_output: bool = False) -> int "message": result.get("message", result.get("error", "")), }, "structured_health": structured_health, + "ocr_version_state": _version_state_summary, **queue_data, }, error=pf_error, @@ -147,6 +173,19 @@ def _diagnose(vault: Path, live: bool = False, json_output: bool = False) -> int f"{entry['figure_count']} figures, {entry['table_count']} tables" ) + if _version_state_summary["total_papers"] > 0: + print() + print("--- OCR Version State ---") + print(f" ocr_version_state: {_version_state_summary['total_papers']} paper(s)") + if _version_state_summary["derived_stale"]: + print(f" derived_stale: {len(_version_state_summary['derived_stale'])} paper(s)") + for k in _version_state_summary["derived_stale"]: + print(f" - {k}") + if _version_state_summary["raw_upgradable"]: + print(f" raw_upgradable: {len(_version_state_summary['raw_upgradable'])} paper(s)") + for k in _version_state_summary["raw_upgradable"]: + print(f" - {k}") + return 0 if passed else 1 diff --git a/paperforge/worker/status.py b/paperforge/worker/status.py index 1b505b8e..9cdcc135 100644 --- a/paperforge/worker/status.py +++ b/paperforge/worker/status.py @@ -1114,6 +1114,36 @@ def run_status(vault: Path, verbose: bool = False, json_output: bool = False) -> from paperforge.commands.ocr import _collect_ocr_health_summary as _collect_health _structured_health = _collect_health(vault) + # OCR version state + _ocr_version_state = { + "total_papers": 0, "derived_stale_count": 0, "raw_upgradable_count": 0, + "derived_stale_keys": [], "raw_upgrade_keys": [], + } + try: + ocr_root = vault / cfg["system_dir"] / "PaperForge" / "ocr" + if ocr_root.exists(): + papers = [] + for paper_dir in ocr_root.iterdir(): + if not paper_dir.is_dir(): + continue + meta_path = paper_dir / "meta.json" + if meta_path.exists(): + meta = read_json(meta_path) + if "raw_version" in meta or "derived_version" in meta: + papers.append(meta) + if papers: + _ocr_version_state["total_papers"] = len(papers) + _ocr_version_state["derived_stale_count"] = sum(1 for m in papers if m.get("derived_stale")) + _ocr_version_state["raw_upgradable_count"] = sum(1 for m in papers if m.get("raw_upgradable")) + _ocr_version_state["derived_stale_keys"] = [ + m.get("zotero_key", "?") for m in papers if m.get("derived_stale") + ] + _ocr_version_state["raw_upgrade_keys"] = [ + m.get("zotero_key", "?") for m in papers if m.get("raw_upgradable") + ] + except Exception: + pass + if json_output: payload = { "version": __import__("paperforge").__version__, @@ -1133,6 +1163,7 @@ def run_status(vault: Path, verbose: bool = False, json_output: bool = False) -> "failed": ocr_failed, }, "structured_ocr_health": _structured_health, + "ocr_version_state": _ocr_version_state, "path_errors": path_error_count, "env_configured": len(env_found) > 0, # Phase 25: lifecycle/health/maturity from canonical index ({} when unavailable) @@ -1187,6 +1218,14 @@ def run_status(vault: Path, verbose: bool = False, json_output: bool = False) -> f"{entry['page_count']} pages, {entry['blocks_count']} blocks, " f"{entry['figure_count']} figures, {entry['table_count']} tables" ) + if _ocr_version_state["total_papers"] > 0: + stale = _ocr_version_state["derived_stale_count"] + upgradable = _ocr_version_state["raw_upgradable_count"] + print(f"- ocr_version_state: {_ocr_version_state['total_papers']} paper(s)") + if stale: + print(f" derived_stale: {stale} (auto-rebuilt on sync)") + if upgradable: + print(f" raw_upgradable: {upgradable} (run `paperforge ocr redo` to upgrade)") print(f"- path_errors: {path_error_count}") if path_error_count > 0: print(" Tip: Run `paperforge repair --fix-paths` to attempt resolution") diff --git a/tests/test_ocr_doctor.py b/tests/test_ocr_doctor.py index 8c669116..1649494f 100644 --- a/tests/test_ocr_doctor.py +++ b/tests/test_ocr_doctor.py @@ -224,3 +224,36 @@ def test_doctor_reads_structured_ocr_health(tmp_path: Path, capsys) -> None: assert "HLTH001" in captured.out assert "3 figures" in captured.out assert "2 tables" in captured.out + + +# --------------------------------------------------------------------------- +# OCR version state in _diagnose() +# --------------------------------------------------------------------------- +def test_doctor_mentions_version_state(tmp_path: Path, capsys) -> None: + """_diagnose() output includes OCR version state.""" + vault = tmp_path / "vault" + vault.mkdir() + (vault / "paperforge.json").write_text( + json.dumps({"vault_config": {"system_dir": "System", "resources_dir": "Resources"}}), + encoding="utf-8", + ) + ocr_dir = vault / "System" / "PaperForge" / "ocr" / "DOC001" + ocr_dir.mkdir(parents=True) + meta = { + "zotero_key": "DOC001", + "raw_version": {"ocr_model": "PaddleOCR-VL-1.5", "ocr_raw_schema_version": "1.0.0"}, + "derived_version": {"renderer_version": "1.0.0-compat"}, + "raw_upgradable": True, + "derived_stale": True, + } + (ocr_dir / "meta.json").write_text(json.dumps(meta), encoding="utf-8") + + from paperforge.commands.ocr import _diagnose + + with patch("paperforge.ocr_diagnostics.ocr_doctor", + return_value={"level": 3, "passed": True, "message": "All good"}): + exit_code = _diagnose(vault, live=False) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "raw_upgradable" in captured.out or "version" in captured.out or "stale" in captured.out diff --git a/tests/test_status.py b/tests/test_status.py index 1a324bb9..f5be7445 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -300,3 +300,61 @@ def test_status_text_structured_ocr_health(tmp_path: Path, capsys) -> None: assert "structured_ocr_health" in captured assert "HLTH001" in captured assert "yellow" in captured + + +# --------------------------------------------------------------------------- +# OCR version runtime state in status +# --------------------------------------------------------------------------- +def test_status_json_includes_ocr_version_state(tmp_path: Path, capsys) -> None: + """JSON output includes ocr_version_state with version info.""" + from paperforge.worker.status import run_status + + vault = _minimal_vault(tmp_path) + _ensure_domain_config(vault) + _ensure_exports(vault) + + ocr_dir = vault / "99_System" / "PaperForge" / "ocr" / "VST001" + ocr_dir.mkdir(parents=True) + meta = { + "zotero_key": "VST001", + "raw_version": {"ocr_model": "PaddleOCR-VL-1.6", "ocr_raw_schema_version": "1.0.0"}, + "derived_version": {"renderer_version": "2.0.0"}, + "raw_upgradable": False, + "derived_stale": False, + } + (ocr_dir / "meta.json").write_text(json.dumps(meta), encoding="utf-8") + + code = run_status(vault, json_output=True) + assert code == 0 + captured = capsys.readouterr().out + envelope = json.loads(captured) + payload = envelope["data"] + assert "ocr_version_state" in payload + assert payload["ocr_version_state"]["total_papers"] == 1 + assert payload["ocr_version_state"]["derived_stale_count"] == 0 + assert payload["ocr_version_state"]["raw_upgradable_count"] == 0 + + +def test_status_text_ocr_version_state(tmp_path: Path, capsys) -> None: + """Text output includes ocr_version_state line.""" + from paperforge.worker.status import run_status + + vault = _minimal_vault(tmp_path) + _ensure_domain_config(vault) + _ensure_exports(vault) + + ocr_dir = vault / "99_System" / "PaperForge" / "ocr" / "VST001" + ocr_dir.mkdir(parents=True) + meta = { + "zotero_key": "VST001", + "raw_version": {"ocr_model": "PaddleOCR-VL-1.5", "ocr_raw_schema_version": "1.0.0"}, + "derived_version": {"renderer_version": "1.0.0-compat"}, + "raw_upgradable": True, + "derived_stale": True, + } + (ocr_dir / "meta.json").write_text(json.dumps(meta), encoding="utf-8") + + code = run_status(vault) + assert code == 0 + captured = capsys.readouterr().out + assert "ocr_version_state" in captured