Coverage for src/ai_jury/cache.py: 100%
80 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +0000
1"""Optional local result cache for repeated jury runs (issue #33).
3Re-running the jury against an unchanged diff with an unchanged config
4re-spends time and tokens for an identical result. This module adds an opt-in,
5on-disk cache keyed by everything that can change the outcome: the diff, the
6effective config hash, the prompt-template version, the package version, the
7context policy, and the run seed.
9Privacy note: a cache entry stores the full structured outcome — including agent
10review/debate/synthesis text, which is derived from the diff. Treat the cache
11directory as sensitive (same trust level as the diff itself). The cache is OFF
12by default and only writes when explicitly enabled with ``--cache``; clear it
13with ``--clear-cache`` (or ``jury cache clear``).
14"""
15from __future__ import annotations
17import contextlib
18import hashlib
19import json
20import os
21from dataclasses import asdict
22from pathlib import Path
24from . import __version__, prompts
25from .adapters import AgentResult
26from .config import JuryConfig, config_hash
27from .consensus import FindingGroup
28from .findings import Finding, Verdict
29from .injection import InjectionHit
30from .orchestrator import JuryOutcome
32CACHE_SCHEMA = 1
33_ENV_DIR = "JURY_CACHE_DIR"
36def default_cache_dir() -> Path:
37 """Cache directory: ``$JURY_CACHE_DIR`` or ``~/.cache/ai-jury``."""
38 override = os.environ.get(_ENV_DIR)
39 if override:
40 return Path(override)
41 base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
42 return Path(base) / "ai-jury"
45def _policy_fingerprint(policy) -> str:
46 """Stable fingerprint of a review policy for the cache key (issue #122).
48 Returns "none" for no/empty policy. The policy is maintainer-authored review
49 guidance injected into the prompts, so it changes the outcome and must be
50 part of the key.
51 """
52 if policy is None or (hasattr(policy, "is_empty") and policy.is_empty()):
53 return "none"
54 from dataclasses import asdict, is_dataclass
56 data = asdict(policy) if is_dataclass(policy) else policy
57 return hashlib.sha256(
58 json.dumps(data, sort_keys=True, default=str).encode("utf-8")
59 ).hexdigest()
62def cache_key(
63 config: JuryConfig,
64 diff: str,
65 *,
66 seed: int | None = None,
67 mock: bool = False,
68 policy=None,
69 mode: str = "code",
70) -> str:
71 """Stable cache key for a run.
73 A pure function of the inputs that determine the outcome. The seed is part of
74 the key (it changes randomized orchestration), unlike in ``config_hash``
75 which describes configuration independent of seed. ``mock`` is included so a
76 ``--mock`` run (deterministic canned findings) can NEVER be served as a real
77 review for the same diff+config, and vice versa. ``policy`` (the repository
78 review policy) is fingerprinted in too, since it is injected into the prompts
79 and changes the result (issue #122).
80 """
81 payload = {
82 "cache_schema": CACHE_SCHEMA,
83 "package_version": __version__,
84 "prompt_version": prompts.PROMPT_VERSION,
85 "config_hash": config_hash(config),
86 "diff_sha256": hashlib.sha256(diff.encode("utf-8")).hexdigest(),
87 "context_mode": config.context.mode,
88 "redact_secrets": config.context.redact_secrets,
89 "verify": config.verify,
90 "seed": seed if seed is not None else config.seed,
91 "mock": bool(mock),
92 "policy": _policy_fingerprint(policy),
93 # Review mode (issue #221): "code" vs "issue" select different prompt
94 # rubrics, so the same text must never be served across modes.
95 "mode": mode,
96 }
97 blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
98 return hashlib.sha256(blob.encode("utf-8")).hexdigest()
101def _finding(d: dict) -> Finding:
102 return Finding(
103 severity=d.get("severity", "info"),
104 file=d.get("file", ""),
105 claim=d.get("claim", ""),
106 line=d.get("line"),
107 evidence=d.get("evidence", ""),
108 suggested_fix=d.get("suggested_fix", ""),
109 confidence=d.get("confidence", "medium"),
110 reviewer=d.get("reviewer", ""),
111 )
114def _verdict(d: dict) -> Verdict:
115 return Verdict(
116 file=d.get("file"),
117 line=d.get("line"),
118 claim=d.get("claim", ""),
119 status=d.get("status", "needs_human_decision"),
120 reasoning=d.get("reasoning", ""),
121 )
124def _agent_result(d: dict | None) -> AgentResult | None:
125 if d is None:
126 return None
127 return AgentResult(
128 agent=d["agent"],
129 vendor=d["vendor"],
130 ok=d["ok"],
131 output=d["output"],
132 duration_s=d["duration_s"],
133 error=d.get("error"),
134 findings=[_finding(f) for f in d.get("findings", [])],
135 warnings=list(d.get("warnings", [])),
136 error_code=d.get("error_code"),
137 attempts=d.get("attempts", 1),
138 )
141def _group(d: dict) -> FindingGroup:
142 return FindingGroup(
143 representative=_finding(d["representative"]),
144 reviewers=list(d.get("reviewers", [])),
145 severity=d.get("severity", "info"),
146 members=[_finding(m) for m in d.get("members", [])],
147 bucket=d.get("bucket", "single_reviewer"),
148 status=d.get("status", ""),
149 status_reasoning=d.get("status_reasoning", ""),
150 )
153def _hit(d: dict) -> InjectionHit:
154 return InjectionHit(
155 kind=d.get("kind", ""),
156 source=d.get("source", ""),
157 line=d.get("line"),
158 snippet=d.get("snippet", ""),
159 )
162def outcome_to_dict(outcome: JuryOutcome) -> dict:
163 """Serialize a JuryOutcome to a JSON-safe dict (dataclasses all the way down)."""
164 return asdict(outcome)
167def outcome_from_dict(data: dict) -> JuryOutcome:
168 """Rebuild a JuryOutcome from :func:`outcome_to_dict` output."""
169 return JuryOutcome(
170 reviews=[_agent_result(r) for r in data.get("reviews", [])],
171 debate=[_agent_result(r) for r in data.get("debate", [])],
172 synthesis=_agent_result(data.get("synthesis")),
173 chair=data.get("chair", ""),
174 findings=[_finding(f) for f in data.get("findings", [])],
175 warnings=list(data.get("warnings", [])),
176 groups=[_group(g) for g in data.get("groups", [])],
177 verify=_agent_result(data.get("verify")),
178 verdicts=[_verdict(v) for v in data.get("verdicts", [])],
179 context_mode=data.get("context_mode", "diff-only"),
180 redact_secrets=data.get("redact_secrets", True),
181 redaction_count=data.get("redaction_count", 0),
182 injection_hits=[_hit(h) for h in data.get("injection_hits", [])],
183 skipped=[tuple(s) for s in data.get("skipped", [])],
184 budget_exhausted=data.get("budget_exhausted", False),
185 rounds_executed=data.get("rounds_executed", 1),
186 stop_reason=data.get("stop_reason", ""),
187 from_cache=data.get("from_cache", False),
188 )
191class Cache:
192 """A simple on-disk JSON cache of jury outcomes."""
194 def __init__(self, directory: Path | str | None = None):
195 self.dir = Path(directory) if directory else default_cache_dir()
197 def _path(self, key: str) -> Path:
198 return self.dir / f"{key}.json"
200 def load(self, key: str) -> JuryOutcome | None:
201 """Return the cached outcome for ``key`` (marked ``from_cache``), or None.
203 A corrupt or unreadable entry is treated as a miss rather than an error,
204 so a bad cache file never breaks a run.
205 """
206 path = self._path(key)
207 if not path.exists():
208 return None
209 try:
210 data = json.loads(path.read_text(encoding="utf-8"))
211 except (OSError, ValueError):
212 return None
213 if data.get("cache_schema") != CACHE_SCHEMA:
214 return None
215 outcome = outcome_from_dict(data.get("outcome", {}))
216 outcome.from_cache = True
217 return outcome
219 def store(self, key: str, outcome: JuryOutcome) -> None:
220 """Persist ``outcome`` under ``key`` (best-effort; ignores write errors)."""
221 with contextlib.suppress(OSError):
222 self.dir.mkdir(parents=True, exist_ok=True)
223 payload = {"cache_schema": CACHE_SCHEMA, "outcome": outcome_to_dict(outcome)}
224 self._path(key).write_text(
225 json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8"
226 )
228 def clear(self) -> int:
229 """Remove all cache entries; return the number deleted."""
230 if not self.dir.exists():
231 return 0
232 removed = 0
233 for path in self.dir.glob("*.json"):
234 with contextlib.suppress(OSError):
235 path.unlink()
236 removed += 1
237 return removed