From 22d189e69e0dc58de236d7a110a47baf56de0020 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Fri, 5 Jun 2026 10:35:23 +0800 Subject: [PATCH] feat: surface OCR structured health in doctor and status --- .../snapshots/status_json/empty_vault.json | 1 + paperforge/commands/ocr.py | 48 +++++++++++++++- paperforge/worker/status.py | 16 +++++- .../status_json/empty_vault.json | 1 + tests/cli/test_json_contracts.py | 2 +- tests/test_ocr_doctor.py | 42 ++++++++++++++ tests/test_status.py | 55 +++++++++++++++++++ 7 files changed, 161 insertions(+), 4 deletions(-) diff --git a/fixtures/snapshots/status_json/empty_vault.json b/fixtures/snapshots/status_json/empty_vault.json index b7d4387b..9c15bd27 100644 --- a/fixtures/snapshots/status_json/empty_vault.json +++ b/fixtures/snapshots/status_json/empty_vault.json @@ -15,6 +15,7 @@ "done": 0, "failed": 0 }, + "structured_ocr_health": [], "path_errors": 0, "env_configured": false, "lifecycle_level_counts": {}, diff --git a/paperforge/commands/ocr.py b/paperforge/commands/ocr.py index 7fa5e2a6..a0a56b3f 100644 --- a/paperforge/commands/ocr.py +++ b/paperforge/commands/ocr.py @@ -1,6 +1,7 @@ """OCR command — unifies OCR run and diagnose.""" import argparse +import json import logging from pathlib import Path @@ -51,6 +52,35 @@ def _collect_ocr_queue_data(vault: Path) -> dict: } +def _collect_ocr_health_summary(vault: Path) -> list[dict]: + """Scan OCR directories for health/ocr_health.json files and return summaries.""" + from paperforge.worker._utils import pipeline_paths + + paths = pipeline_paths(vault) + ocr_root = paths.get("ocr") + if not ocr_root or not ocr_root.exists(): + return [] + summaries = [] + for dir_path in sorted(ocr_root.iterdir()): + if not dir_path.is_dir(): + continue + health_path = dir_path / "health" / "ocr_health.json" + if health_path.exists(): + try: + health = json.loads(health_path.read_text(encoding="utf-8")) + summaries.append({ + "key": dir_path.name, + "overall": health.get("overall", "unknown"), + "page_count": health.get("page_count", 0), + "blocks_count": health.get("blocks_count", 0), + "figure_count": health.get("figure_caption_count", 0), + "table_count": health.get("table_caption_count", 0), + }) + except Exception: + continue + return summaries + + def _diagnose(vault: Path, live: bool = False, json_output: bool = False) -> int: """Run OCR diagnostics and print results.""" from paperforge.ocr_diagnostics import ocr_doctor @@ -61,6 +91,7 @@ def _diagnose(vault: Path, live: bool = False, json_output: bool = False) -> int if json_output: queue_data = _collect_ocr_queue_data(vault) + structured_health = _collect_ocr_health_summary(vault) pf_error = None if not passed: if level == 1: @@ -86,6 +117,7 @@ def _diagnose(vault: Path, live: bool = False, json_output: bool = False) -> int "passed": passed, "message": result.get("message", result.get("error", "")), }, + "structured_health": structured_health, **queue_data, }, error=pf_error, @@ -97,13 +129,25 @@ def _diagnose(vault: Path, live: bool = False, json_output: bool = False) -> int print("-" * 40) if passed: print(f"[PASS] {result.get('message', 'All checks passed')}") - return 0 else: print(f"[FAIL] Level {level}: {result.get('error', 'Unknown failure')}") print(f"[FIX] {result.get('fix', 'No fix suggestion available')}") if result.get("raw_response"): print(f"[RAW] {result['raw_response'][:200]}...") - return 1 + + structured_health = _collect_ocr_health_summary(vault) + if structured_health: + print() + print("--- Structured OCR Health ---") + print(f"Papers with health data: {len(structured_health)}") + for entry in structured_health: + print( + f"- {entry['key']}: overall={entry['overall']}, " + f"{entry['page_count']} pages, {entry['blocks_count']} blocks, " + f"{entry['figure_count']} figures, {entry['table_count']} tables" + ) + + return 0 if passed else 1 def _get_run_ocr(): diff --git a/paperforge/worker/status.py b/paperforge/worker/status.py index 0eb087d6..1b505b8e 100644 --- a/paperforge/worker/status.py +++ b/paperforge/worker/status.py @@ -1110,6 +1110,10 @@ def run_status(vault: Path, verbose: bool = False, json_output: bool = False) -> except Exception: pass + # Structured OCR health + from paperforge.commands.ocr import _collect_ocr_health_summary as _collect_health + _structured_health = _collect_health(vault) + if json_output: payload = { "version": __import__("paperforge").__version__, @@ -1128,6 +1132,7 @@ def run_status(vault: Path, verbose: bool = False, json_output: bool = False) -> "done": ocr_done, "failed": ocr_failed, }, + "structured_ocr_health": _structured_health, "path_errors": path_error_count, "env_configured": len(env_found) > 0, # Phase 25: lifecycle/health/maturity from canonical index ({} when unavailable) @@ -1171,8 +1176,17 @@ def run_status(vault: Path, verbose: bool = False, json_output: bool = False) -> f"asset={ha['asset_health']['healthy']}/{ha['asset_health']['unhealthy']}" ) print( - f"- ocr: {ocr_done}/{ocr_total} done (pending: {ocr_pending}, processing: {ocr_processing}, failed: {ocr_failed})" + f"- ocr: {ocr_done}/{ocr_total} done " + f"(pending: {ocr_pending}, processing: {ocr_processing}, failed: {ocr_failed})" ) + if _structured_health: + print(f"- structured_ocr_health: {len(_structured_health)} paper(s)") + for entry in _structured_health: + print( + f" - {entry['key']}: overall={entry['overall']}, " + f"{entry['page_count']} pages, {entry['blocks_count']} blocks, " + f"{entry['figure_count']} figures, {entry['table_count']} tables" + ) 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/cli/snapshots/test_json_contracts/test_status_json_snapshot_regression/status_json/empty_vault.json b/tests/cli/snapshots/test_json_contracts/test_status_json_snapshot_regression/status_json/empty_vault.json index 11ac95d2..d17269a4 100644 --- a/tests/cli/snapshots/test_json_contracts/test_status_json_snapshot_regression/status_json/empty_vault.json +++ b/tests/cli/snapshots/test_json_contracts/test_status_json_snapshot_regression/status_json/empty_vault.json @@ -19,6 +19,7 @@ "done": 0, "failed": 0 }, + "structured_ocr_health": [], "path_errors": 0, "env_configured": true, "lifecycle_level_counts": {}, diff --git a/tests/cli/test_json_contracts.py b/tests/cli/test_json_contracts.py index 24118865..ee480caa 100644 --- a/tests/cli/test_json_contracts.py +++ b/tests/cli/test_json_contracts.py @@ -47,7 +47,7 @@ class TestStatusJson: OPTIONAL_KEYS = { "version", # duplicated from envelope (old format preserved in data) "formal_notes", "exports", "domains", "bases", "path_errors", - "env_configured", "ocr", + "env_configured", "ocr", "structured_ocr_health", "lifecycle_level_counts", "health_aggregate", "maturity_distribution", } diff --git a/tests/test_ocr_doctor.py b/tests/test_ocr_doctor.py index e806847e..8c669116 100644 --- a/tests/test_ocr_doctor.py +++ b/tests/test_ocr_doctor.py @@ -2,6 +2,7 @@ import json import os +from pathlib import Path from unittest.mock import MagicMock, patch from paperforge.ocr_diagnostics import ocr_doctor @@ -182,3 +183,44 @@ def test_l4_live_failure(): assert result["level"] == 4 assert result["passed"] is False assert "bad pdf" in result["error"] + + +# --------------------------------------------------------------------------- +# Structured OCR Health in _diagnose() +# --------------------------------------------------------------------------- +def test_doctor_reads_structured_ocr_health(tmp_path: Path, capsys) -> None: + """_diagnose() reads ocr_health.json and prints structured summary.""" + 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" / "HLTH001" + ocr_dir.mkdir(parents=True) + health_dir = ocr_dir / "health" + health_dir.mkdir(parents=True) + (health_dir / "ocr_health.json").write_text( + json.dumps({ + "overall": "yellow", + "page_count": 5, + "blocks_count": 100, + "figure_caption_count": 3, + "table_caption_count": 2, + }), + 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 "Structured OCR Health" in captured.out + assert "yellow" in captured.out + assert "HLTH001" in captured.out + assert "3 figures" in captured.out + assert "2 tables" in captured.out diff --git a/tests/test_status.py b/tests/test_status.py index e0db7378..1a324bb9 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -150,6 +150,33 @@ class TestStatusJsonIndexSource: assert data["health_aggregate"] == {} assert data["maturity_distribution"] == {} + def test_status_json_includes_structured_ocr_health(self, tmp_path: Path, capsys) -> None: + """JSON output includes structured_ocr_health key.""" + 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" / "HLTH001" + ocr_dir.mkdir(parents=True) + health_dir = ocr_dir / "health" + health_dir.mkdir(parents=True) + (health_dir / "ocr_health.json").write_text( + json.dumps({"overall": "green", "page_count": 3, "blocks_count": 50}), + encoding="utf-8", + ) + + code = run_status(vault, json_output=True) + captured = capsys.readouterr().out + assert code == 0 + envelope = json.loads(captured) + data = envelope["data"] + assert "structured_ocr_health" in data + assert len(data["structured_ocr_health"]) == 1 + assert data["structured_ocr_health"][0]["key"] == "HLTH001" + assert data["structured_ocr_health"][0]["overall"] == "green" + def test_status_text_output_includes_index_section(self, tmp_path: Path, capsys) -> None: """Text output contains 'lifecycle:' line when index is present.""" from paperforge.worker.status import run_status @@ -245,3 +272,31 @@ class TestDoctorIndexHealth: output = self._run_doctor(vault) assert "Index Health" in output assert "No canonical index" in output + + +# --------------------------------------------------------------------------- +# Structured OCR Health in status +# --------------------------------------------------------------------------- +def test_status_text_structured_ocr_health(tmp_path: Path, capsys) -> None: + """run_status() text output shows structured OCR health.""" + 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" / "HLTH001" + ocr_dir.mkdir(parents=True) + health_dir = ocr_dir / "health" + health_dir.mkdir(parents=True) + (health_dir / "ocr_health.json").write_text( + json.dumps({"overall": "yellow", "page_count": 5, "blocks_count": 100}), + encoding="utf-8", + ) + + code = run_status(vault) + captured = capsys.readouterr().out + assert code == 0 + assert "structured_ocr_health" in captured + assert "HLTH001" in captured + assert "yellow" in captured