diff --git a/CHANGELOG.md b/CHANGELOG.md index c582c62d..2e5a72cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,10 @@ All notable changes to vouch are documented here. Format follows artifact the caller could not already retrieve, and it touches no write path. ### Fixed +- **`vouch stats` / `kb.stats` no longer crash on one corrupt `decided/*.yaml`**: + `_list_decided` parsed every decided proposal strictly, so a single bad file + aborted `review_summary` / `collect_stats`. It now uses `_load_or_skip` — + same resilience as `list_proposals` / `list_pages`. - **rerank / recency / triage quoted `"true"` stays off** (#658): `retrieval.rerank.enabled`, `retrieval.recency.enabled` and `triage.enabled` were the last three readers still on the diff --git a/src/vouch/stats.py b/src/vouch/stats.py index cdd0cc9b..b898e6e6 100644 --- a/src/vouch/stats.py +++ b/src/vouch/stats.py @@ -15,7 +15,7 @@ from . import audit, health from .models import Proposal, ProposalStatus from .proposals import EXPIRE_REASON -from .storage import KBStore, _yaml_load +from .storage import KBStore, _load_or_skip if TYPE_CHECKING: from .scoping import ViewerContext @@ -50,12 +50,19 @@ def _decision_bucket(proposal: Proposal) -> str: def _list_decided(store: KBStore) -> list[Proposal]: + """Load decided proposals for review_summary. + + Uses ``_load_or_skip`` so one corrupt ``decided/*.yaml`` cannot take down + ``vouch stats`` / ``kb.stats`` — same resilience as ``list_proposals``. + """ ddir = store.kb_dir / "decided" if not ddir.is_dir(): return [] out: list[Proposal] = [] for path in sorted(ddir.glob("*.yaml")): - out.append(Proposal.model_validate(_yaml_load(path.read_text(encoding="utf-8")))) + pr = _load_or_skip(path, Proposal, "proposal") + if pr is not None: + out.append(pr) return out diff --git a/tests/test_stats.py b/tests/test_stats.py index 16b26938..553ed375 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -97,6 +97,20 @@ def test_review_summary_counts_decisions(store: KBStore) -> None: assert review["by_agent"]["a2"]["rejected"] == 1 +def test_review_summary_skips_corrupt_decided_file(store: KBStore) -> None: + """One bad decided/*.yaml must not crash vouch stats / kb.stats.""" + src = store.put_source(b"x") + pr = propose_claim(store, text="ok", evidence=[src.id], proposed_by="a") + approve(store, pr.id, approved_by="human") + (store.kb_dir / "decided" / "corrupt.yaml").write_text( + "not: [valid\n", encoding="utf-8", + ) + review = stats.review_summary(store, since_days=None) + assert review["approved"] == 1 + body = stats.collect_stats(store, since_days=None) + assert body["review"]["approved"] == 1 + + def test_review_summary_respects_window(store: KBStore) -> None: src = store.put_source(b"x") pr = propose_claim(store, text="old", evidence=[src.id], proposed_by="a")