From f8ee3f97c1cef593d4ca1cd36d1c580237f6e2d1 Mon Sep 17 00:00:00 2001 From: boskodev790 <233739930+boskodev790@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:45:49 -0400 Subject: [PATCH] feat(reviewers): per-reviewer throughput and turnaround report `vouch stats` buckets the review queue by the proposing agent and `vouch digest` lists decisions one at a time; neither aggregates by the human at the gate. `vouch reviewers` reports, per `decided_by` actor, how many proposals they approved versus rejected, their approval rate, and the turnaround from `proposed_at` to `decided_at` (median and max). an auto-expiry is a system action, not a review, so expiries are excluded from reviewer counts and reported once as `expired_total`. strictly a viewport: composes store.list_proposals, writes nothing, logs no audit event. cli-only, no kb.* method surface. adds src/vouch/reviewers.py and tests/test_reviewers.py (10 tests). --- CHANGELOG.md | 9 ++ src/vouch/cli.py | 32 +++++++ src/vouch/reviewers.py | 191 ++++++++++++++++++++++++++++++++++++++++ tests/test_reviewers.py | 183 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 415 insertions(+) create mode 100644 src/vouch/reviewers.py create mode 100644 tests/test_reviewers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..036eff89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `vouch reviewers` — a read-only throughput report keyed on the human at the + gate (`decided_by`), which `vouch stats` (bucketed by the proposing agent) + and `vouch digest` don't surface: per reviewer, approvals vs rejections, + approval rate, and turnaround from `proposed_at` to `decided_at` (median and + max). auto-expiries are a system action, not a review, so they are excluded + from reviewer counts and reported once as `expired_total`. `--since` window, + `--format text|json|markdown`; writes nothing. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 198d32f4..f99d6ba4 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -38,6 +38,7 @@ from . import pr_cache as prc_mod from . import provenance as prov_mod from . import recall as recall_mod +from . import reviewers as reviewers_mod from . import sessions as sess_mod from . import stats as stats_mod from . import sync as sync_mod @@ -365,6 +366,37 @@ def digest_cmd(since: str, stale_days: int, limit: int, fmt: str) -> None: click.echo(digest_mod.render_text(d)) +@cli.command(name="reviewers") +@click.option( + "--since", + default=reviewers_mod.DEFAULT_SINCE_SPEC, + show_default=True, + help="Window: a duration (30d, 12h), an ISO date, or 'all'.", +) +@click.option( + "--format", + "fmt", + default="text", + show_default=True, + type=click.Choice(["text", "json", "markdown"]), +) +def reviewers_cmd(since: str, fmt: str) -> None: + """Read-only throughput per human at the gate: decisions, approval rate, + and turnaround by decided_by. Writes nothing — safe to run from cron.""" + store = _load_store() + try: + since_dt = metrics_mod.parse_since(since) + except metrics_mod.MetricsError as e: + raise click.UsageError(str(e)) from e + report = reviewers_mod.build(store, since=since_dt) + if fmt == "json": + _emit_json(report.to_dict()) + elif fmt == "markdown": + click.echo(reviewers_mod.render_markdown(report)) + else: + click.echo(reviewers_mod.render_text(report)) + + def _findings_json(report) -> list[dict[str, Any]]: return [ { diff --git a/src/vouch/reviewers.py b/src/vouch/reviewers.py new file mode 100644 index 00000000..5a9b6fb6 --- /dev/null +++ b/src/vouch/reviewers.py @@ -0,0 +1,191 @@ +"""Read-only reviewer-throughput report: who works the gate, and how. + +`vouch stats` buckets the review queue by the *proposing* agent (`proposed_by`) +and `vouch digest` lists recent decisions one at a time. Neither aggregates by +the human at the gate — the `decided_by` actor who actually approves or rejects. +This viewport does: per reviewer, how many proposals they approved versus +rejected, their approval rate, and how long proposals waited before they decided +(turnaround, from `proposed_at` to `decided_at`). + +An auto-expiry is not a review decision — a proposal that ages out is closed by +the `vouch-expire` actor, not a human — so expiries are excluded from every +reviewer's counts and reported once, separately, as `expired_total`. + +Strictly a viewport: it composes `store.list_proposals`, writes nothing, logs no +audit event, and never touches a proposal — there is nothing here for the review +gate to gate. +""" + +from __future__ import annotations + +import statistics +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime +from typing import Any + +from .models import ProposalStatus +from .proposals import EXPIRE_REASON +from .storage import KBStore + +DEFAULT_SINCE_SPEC = "30d" + +_UNKNOWN_REVIEWER = "(unknown)" + + +@dataclass(frozen=True) +class ReviewerRow: + reviewer: str + decisions: int + approved: int + rejected: int + approval_rate: float | None + turnaround_hours_median: float | None + turnaround_hours_max: float | None + + +@dataclass(frozen=True) +class ReviewerReport: + """Stable `to_dict()` schema — the `--format json` contract.""" + + generated_at: str + since: str | None + reviewers_total: int + decisions_total: int + expired_total: int + reviewers: list[ReviewerRow] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _as_utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC) + + +@dataclass +class _Acc: + approved: int = 0 + rejected: int = 0 + turnarounds: list[float] = field(default_factory=list) + + +def build( + store: KBStore, + *, + since: datetime | None = None, + now: datetime | None = None, +) -> ReviewerReport: + """Compose the reviewer-throughput report. Read-only by construction.""" + now = _as_utc(now) or datetime.now(UTC) + since = _as_utc(since) + + accs: dict[str, _Acc] = {} + decisions_total = 0 + expired_total = 0 + + decided = [ + *store.list_proposals(ProposalStatus.APPROVED), + *store.list_proposals(ProposalStatus.REJECTED), + ] + for p in decided: + decided_at = _as_utc(p.decided_at) + if decided_at is None: + # An APPROVED/REJECTED proposal with no decided_at can't be placed + # in the window or attributed in time — skip rather than guess. + continue + if since is not None and decided_at < since: + continue + if p.decision_reason == EXPIRE_REASON: + expired_total += 1 + continue + + decisions_total += 1 + acc = accs.setdefault(p.decided_by or _UNKNOWN_REVIEWER, _Acc()) + if p.status == ProposalStatus.APPROVED: + acc.approved += 1 + else: + acc.rejected += 1 + + proposed_at = _as_utc(p.proposed_at) + if proposed_at is not None and decided_at >= proposed_at: + acc.turnarounds.append((decided_at - proposed_at).total_seconds() / 3600.0) + + rows = [ + ReviewerRow( + reviewer=reviewer, + decisions=acc.approved + acc.rejected, + approved=acc.approved, + rejected=acc.rejected, + approval_rate=( + round(acc.approved / (acc.approved + acc.rejected), 4) + if (acc.approved + acc.rejected) + else None + ), + turnaround_hours_median=( + round(statistics.median(acc.turnarounds), 2) if acc.turnarounds else None + ), + turnaround_hours_max=( + round(max(acc.turnarounds), 2) if acc.turnarounds else None + ), + ) + for reviewer, acc in accs.items() + ] + rows.sort(key=lambda r: (-r.decisions, r.reviewer)) + + return ReviewerReport( + generated_at=now.isoformat(timespec="seconds"), + since=since.isoformat(timespec="seconds") if since else None, + reviewers_total=len(rows), + decisions_total=decisions_total, + expired_total=expired_total, + reviewers=rows, + ) + + +def _rate(rate: float | None) -> str: + return f"{rate:.0%}" if rate is not None else "n/a" + + +def _hours(value: float | None) -> str: + return f"{value:g}h" if value is not None else "n/a" + + +def render_text(report: ReviewerReport) -> str: + lines = [ + f"reviewer throughput @ {report.generated_at} " + f"(window since: {report.since or 'all'})", + f"reviewers: {report.reviewers_total} decisions: {report.decisions_total} " + f"expired (excluded): {report.expired_total}", + "", + ] + for r in report.reviewers: + lines.append( + f" {r.reviewer} {r.decisions} decision(s) " + f"{r.approved} approved / {r.rejected} rejected " + f"approval {_rate(r.approval_rate)} " + f"turnaround med {_hours(r.turnaround_hours_median)} / " + f"max {_hours(r.turnaround_hours_max)}" + ) + if not report.reviewers: + lines.append(" none") + return "\n".join(lines) + + +def render_markdown(report: ReviewerReport) -> str: + lines = [ + f"# reviewer throughput — {report.generated_at}", + "", + f"window since: {report.since or 'all'} · reviewers: {report.reviewers_total} · " + f"decisions: {report.decisions_total} · expired (excluded): {report.expired_total}", + "", + ] + lines += [ + f"- **{r.reviewer}** — {r.decisions} decision(s), {r.approved} approved / " + f"{r.rejected} rejected, approval {_rate(r.approval_rate)}, " + f"turnaround med {_hours(r.turnaround_hours_median)} / " + f"max {_hours(r.turnaround_hours_max)}" + for r in report.reviewers + ] or ["- none"] + return "\n".join(lines) diff --git a/tests/test_reviewers.py b/tests/test_reviewers.py new file mode 100644 index 00000000..1cc69d0d --- /dev/null +++ b/tests/test_reviewers.py @@ -0,0 +1,183 @@ +"""Read-only reviewer-throughput report — vouch reviewers.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from vouch import reviewers +from vouch.cli import cli +from vouch.proposals import approve, expire_one, propose_claim, reject +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +def _row(report: reviewers.ReviewerReport, reviewer: str) -> reviewers.ReviewerRow: + for r in report.reviewers: + if r.reviewer == reviewer: + return r + raise AssertionError(f"{reviewer} not in report") + + +def _retime(store: KBStore, pid: str, *, proposed: datetime, decided: datetime) -> None: + path = store.kb_dir / "decided" / f"{pid}.yaml" + raw = yaml.safe_load(path.read_text()) + raw["proposed_at"] = proposed.isoformat() + raw["decided_at"] = decided.isoformat() + path.write_text(yaml.safe_dump(raw, sort_keys=False)) + + +def test_counts_and_approval_rate_by_reviewer(store: KBStore) -> None: + src = store.put_source(b"x") + a = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent") + b = propose_claim(store, text="b", evidence=[src.id], proposed_by="agent") + c = propose_claim(store, text="c", evidence=[src.id], proposed_by="agent") + approve(store, a.id, approved_by="alice") + reject(store, b.id, rejected_by="alice", reason="dup") + approve(store, c.id, approved_by="bob") + + report = reviewers.build(store) + assert report.decisions_total == 3 + assert report.reviewers_total == 2 + alice = _row(report, "alice") + assert (alice.decisions, alice.approved, alice.rejected) == (2, 1, 1) + assert alice.approval_rate == 0.5 + assert _row(report, "bob").approval_rate == 1.0 + # most decisions first. + assert report.reviewers[0].reviewer == "alice" + + +def test_expiry_is_excluded_from_reviewers(store: KBStore) -> None: + src = store.put_source(b"x") + live = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent") + stale = propose_claim(store, text="b", evidence=[src.id], proposed_by="agent") + approve(store, live.id, approved_by="alice") + # age the pending proposal out, then expire it — a system action, not a review. + ppath = store.kb_dir / "proposed" / f"{stale.id}.yaml" + raw = yaml.safe_load(ppath.read_text()) + raw["proposed_at"] = (datetime.now(UTC) - timedelta(days=30)).isoformat() + ppath.write_text(yaml.safe_dump(raw, sort_keys=False)) + expire_one(store, stale.id) + + report = reviewers.build(store) + assert report.expired_total == 1 + assert report.decisions_total == 1 + assert [r.reviewer for r in report.reviewers] == ["alice"] + + +def test_turnaround_hours(store: KBStore) -> None: + src = store.put_source(b"x") + p = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent") + approve(store, p.id, approved_by="alice") + now = datetime.now(UTC) + _retime(store, p.id, proposed=now - timedelta(hours=3), decided=now) + + row = _row(reviewers.build(store), "alice") + assert row.turnaround_hours_median == 3.0 + assert row.turnaround_hours_max == 3.0 + + +def test_window_filters_old_decisions(store: KBStore) -> None: + src = store.put_source(b"x") + old = propose_claim(store, text="old", evidence=[src.id], proposed_by="agent") + fresh = propose_claim(store, text="fresh", evidence=[src.id], proposed_by="agent") + approve(store, old.id, approved_by="alice") + approve(store, fresh.id, approved_by="alice") + now = datetime.now(UTC) + _retime(store, old.id, proposed=now - timedelta(days=11), decided=now - timedelta(days=10)) + _retime(store, fresh.id, proposed=now - timedelta(hours=2), decided=now) + + report = reviewers.build(store, since=now - timedelta(days=7)) + assert report.decisions_total == 1 + assert _row(report, "alice").decisions == 1 + + +def test_missing_reviewer_falls_back_to_unknown(store: KBStore) -> None: + src = store.put_source(b"x") + p = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent") + approve(store, p.id, approved_by="alice") + path = store.kb_dir / "decided" / f"{p.id}.yaml" + raw = yaml.safe_load(path.read_text()) + raw["decided_by"] = None + path.write_text(yaml.safe_dump(raw, sort_keys=False)) + + report = reviewers.build(store) + assert [r.reviewer for r in report.reviewers] == ["(unknown)"] + + +def test_negative_turnaround_from_clock_skew_is_dropped(store: KBStore) -> None: + src = store.put_source(b"x") + p = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent") + approve(store, p.id, approved_by="alice") + now = datetime.now(UTC) + # decided before proposed — skew; the decision still counts, turnaround does not. + _retime(store, p.id, proposed=now, decided=now - timedelta(hours=1)) + + row = _row(reviewers.build(store), "alice") + assert row.decisions == 1 + assert row.turnaround_hours_median is None + + +def test_to_dict_schema(store: KBStore) -> None: + src = store.put_source(b"x") + p = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent") + approve(store, p.id, approved_by="alice") + body = reviewers.build(store).to_dict() + assert set(body) == { + "generated_at", + "since", + "reviewers_total", + "decisions_total", + "expired_total", + "reviewers", + } + assert set(body["reviewers"][0]) == { + "reviewer", + "decisions", + "approved", + "rejected", + "approval_rate", + "turnaround_hours_median", + "turnaround_hours_max", + } + + +def test_empty_kb(store: KBStore) -> None: + report = reviewers.build(store) + assert report.reviewers == [] + assert report.decisions_total == 0 + assert "reviewers: 0" in reviewers.render_text(report) + + +def test_cli_reviewers_json(store: KBStore) -> None: + src = store.put_source(b"x") + p = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent") + approve(store, p.id, approved_by="alice") + result = CliRunner().invoke(cli, ["reviewers", "--format", "json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["decisions_total"] == 1 + assert data["reviewers"][0]["reviewer"] == "alice" + + +def test_cli_reviewers_text_and_markdown(store: KBStore) -> None: + src = store.put_source(b"x") + p = propose_claim(store, text="a", evidence=[src.id], proposed_by="agent") + approve(store, p.id, approved_by="alice") + text = CliRunner().invoke(cli, ["reviewers"]) + assert text.exit_code == 0, text.output + assert "alice" in text.output + md = CliRunner().invoke(cli, ["reviewers", "--format", "markdown"]) + assert md.exit_code == 0, md.output + assert "# reviewer throughput" in md.output