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

1"""Optional local result cache for repeated jury runs (issue #33). 

2 

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. 

8 

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 

16 

17import contextlib 

18import hashlib 

19import json 

20import os 

21from dataclasses import asdict 

22from pathlib import Path 

23 

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 

31 

32CACHE_SCHEMA = 1 

33_ENV_DIR = "JURY_CACHE_DIR" 

34 

35 

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" 

43 

44 

45def _policy_fingerprint(policy) -> str: 

46 """Stable fingerprint of a review policy for the cache key (issue #122). 

47 

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 

55 

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() 

60 

61 

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. 

72 

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() 

99 

100 

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 ) 

112 

113 

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 ) 

122 

123 

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 ) 

139 

140 

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 ) 

151 

152 

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 ) 

160 

161 

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) 

165 

166 

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 ) 

189 

190 

191class Cache: 

192 """A simple on-disk JSON cache of jury outcomes.""" 

193 

194 def __init__(self, directory: Path | str | None = None): 

195 self.dir = Path(directory) if directory else default_cache_dir() 

196 

197 def _path(self, key: str) -> Path: 

198 return self.dir / f"{key}.json" 

199 

200 def load(self, key: str) -> JuryOutcome | None: 

201 """Return the cached outcome for ``key`` (marked ``from_cache``), or None. 

202 

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 

218 

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 ) 

227 

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