codebase-receipts-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
  2. codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
  3. codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
  4. codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
  5. codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. receipts/__init__.py +0 -0
  7. receipts/ama/__init__.py +0 -0
  8. receipts/ama/interviewer.py +415 -0
  9. receipts/ats/__init__.py +1 -0
  10. receipts/ats/comparator.py +98 -0
  11. receipts/ats/scorer.py +550 -0
  12. receipts/ats/stats.py +164 -0
  13. receipts/cli.py +2062 -0
  14. receipts/config.py +106 -0
  15. receipts/errors.py +18 -0
  16. receipts/export.py +120 -0
  17. receipts/ingest/__init__.py +0 -0
  18. receipts/ingest/artifact_extractor.py +679 -0
  19. receipts/ingest/git_source.py +159 -0
  20. receipts/ingest/manifest.py +114 -0
  21. receipts/ingest/scanner.py +141 -0
  22. receipts/ingest/secrets_scanner.py +225 -0
  23. receipts/interactive/__init__.py +1 -0
  24. receipts/interactive/repl.py +755 -0
  25. receipts/ledger/__init__.py +0 -0
  26. receipts/ledger/pricing_table.py +54 -0
  27. receipts/ledger/token_ledger.py +226 -0
  28. receipts/llm/__init__.py +0 -0
  29. receipts/llm/anthropic_provider.py +95 -0
  30. receipts/llm/factory.py +124 -0
  31. receipts/llm/fake_provider.py +79 -0
  32. receipts/llm/gemini_provider.py +205 -0
  33. receipts/llm/json_utils.py +46 -0
  34. receipts/llm/metered.py +62 -0
  35. receipts/llm/ollama_provider.py +117 -0
  36. receipts/llm/openai_provider.py +118 -0
  37. receipts/llm/provider.py +42 -0
  38. receipts/llm/split.py +78 -0
  39. receipts/llm/token_estimate.py +35 -0
  40. receipts/mine/__init__.py +1 -0
  41. receipts/mine/code_metrics.py +246 -0
  42. receipts/mine/git_evidence.py +109 -0
  43. receipts/prep/__init__.py +1 -0
  44. receipts/prep/dossier.py +313 -0
  45. receipts/prep/readiness.py +227 -0
  46. receipts/resume/__init__.py +0 -0
  47. receipts/resume/claim_extractor.py +338 -0
  48. receipts/resume/loader.py +35 -0
  49. receipts/resume/pdf_parser.py +259 -0
  50. receipts/resume/tex_parser.py +394 -0
  51. receipts/rewrite/__init__.py +0 -0
  52. receipts/rewrite/compiler.py +180 -0
  53. receipts/rewrite/optimizer.py +140 -0
  54. receipts/rewrite/tex_rewriter.py +227 -0
  55. receipts/tui/__init__.py +0 -0
  56. receipts/tui/ama_app.py +454 -0
  57. receipts/tui/app.py +316 -0
  58. receipts/tui/dossier_app.py +170 -0
  59. receipts/tui/hub.py +367 -0
  60. receipts/tui/ingest_app.py +183 -0
  61. receipts/tui/rewrite_app.py +463 -0
  62. receipts/tui/score_app.py +237 -0
  63. receipts/tui/widgets/__init__.py +0 -0
  64. receipts/tui/widgets/claims_table.py +50 -0
  65. receipts/tui/widgets/diff_view.py +38 -0
  66. receipts/tui/widgets/status_bar.py +38 -0
  67. receipts/verify/__init__.py +0 -0
  68. receipts/verify/bm25.py +112 -0
  69. receipts/verify/enrich.py +128 -0
  70. receipts/verify/jd_fetch.py +101 -0
  71. receipts/verify/kb.py +379 -0
  72. receipts/verify/keyword_gap.py +127 -0
  73. receipts/verify/query_expand.py +88 -0
  74. receipts/verify/rerank.py +74 -0
  75. receipts/verify/rewriter.py +172 -0
  76. receipts/verify/router.py +211 -0
  77. receipts/verify/summaries.py +258 -0
  78. receipts/verify/verifier.py +552 -0
@@ -0,0 +1,313 @@
1
+ """Defense dossier — the night-before-the-interview study document.
2
+
3
+ Assembles everything the pipeline already knows into one file: per bullet,
4
+ the verdict, the exact evidence files that back it, the questions an
5
+ interviewer would most likely ask, a strong grounded model answer, and
6
+ any weak spots from past mock-interview sessions. One LLM call per
7
+ bullet; everything else is assembly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import dataclass
14
+
15
+ from receipts.llm.json_utils import parse_json_loose
16
+ from receipts.llm.provider import CompletionResult, LLMProvider
17
+ from receipts.prep.readiness import WeakSpot
18
+ from receipts.verify.verifier import VerificationResult
19
+
20
+ _VERDICT_WEIGHT = {"unsupported": 3, "plausible": 2, "verified": 1}
21
+
22
+ _DOSSIER_SYSTEM = """\
23
+ You are an interview coach preparing a candidate to defend a resume
24
+ bullet. Given the bullet, its code-verification results, and the actual
25
+ code evidence, produce:
26
+
27
+ 1. "questions": the 2-3 questions an interviewer is MOST likely to ask
28
+ about this bullet. Pointed and specific — reference the technologies,
29
+ numbers, and decisions in the bullet, not generic "tell me about"
30
+ questions. If a claim was unsupported, at least one question must
31
+ probe exactly that weak spot, because a good interviewer will.
32
+ 2. "model_answers": one strong, honest answer (3-5 sentences) for EACH
33
+ question, in the SAME ORDER as "questions" — answers[0] answers
34
+ questions[0], and so on. Ground every answer ONLY in the bullet and the
35
+ provided evidence. Never invent numbers, files, or capabilities that
36
+ aren't in the evidence. Where evidence is thin, the answer should show
37
+ how to respond honestly without overclaiming.
38
+
39
+ Respond in JSON:
40
+ {"questions": ["q1", "q2", "q3"], "model_answers": ["a1", "a2", "a3"]}
41
+ """
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class EvidenceRef:
46
+ rel_path: str
47
+ name: str
48
+ start_line: int
49
+ end_line: int
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class DossierEntry:
54
+ bullet_text: str
55
+ section: str
56
+ heading: str
57
+ verdict: str # worst verdict across the bullet's claims
58
+ claim_notes: list[str] # "verdict — value: explanation" per claim
59
+ evidence: list[EvidenceRef]
60
+ questions: list[str]
61
+ model_answers: list[str] # one grounded answer per question, aligned by index
62
+ weak_note: str = "" # from past AMA sessions, when it exists
63
+ completion: CompletionResult | None = None
64
+
65
+
66
+ def _group_by_bullet(
67
+ results: list[VerificationResult],
68
+ ) -> list[list[VerificationResult]]:
69
+ groups: list[list[VerificationResult]] = []
70
+ index: dict[str, list[VerificationResult]] = {}
71
+ for r in results:
72
+ key = r.claim.bullet_text
73
+ group = index.get(key)
74
+ if group is None:
75
+ group = []
76
+ index[key] = group
77
+ groups.append(group)
78
+ group.append(r)
79
+ return groups
80
+
81
+
82
+ def _worst_verdict(group: list[VerificationResult]) -> str:
83
+ return max(
84
+ (r.verdict for r in group),
85
+ key=lambda v: _VERDICT_WEIGHT.get(v, 1),
86
+ )
87
+
88
+
89
+ def _collect_evidence(group: list[VerificationResult]) -> list[EvidenceRef]:
90
+ seen: set[str] = set()
91
+ out: list[EvidenceRef] = []
92
+ for r in group:
93
+ for e in r.evidence:
94
+ key = f"{e.rel_path}:{e.name}"
95
+ if key in seen:
96
+ continue
97
+ seen.add(key)
98
+ out.append(
99
+ EvidenceRef(
100
+ rel_path=e.rel_path,
101
+ name=e.name,
102
+ start_line=e.start_line,
103
+ end_line=e.end_line,
104
+ )
105
+ )
106
+ return out[:6]
107
+
108
+
109
+ def _build_prompt(group: list[VerificationResult]) -> str:
110
+ first = group[0]
111
+ parts = [
112
+ f"BULLET: {first.claim.bullet_text}",
113
+ f"SECTION: {first.claim.section}",
114
+ "",
115
+ "VERIFICATION RESULTS:",
116
+ ]
117
+ for r in group:
118
+ parts.append(
119
+ f' - {r.claim.claim_type} "{r.claim.value}": {r.verdict}'
120
+ f" — {r.explanation}"
121
+ )
122
+ evidence = _collect_evidence(group)
123
+ if evidence:
124
+ parts.append("")
125
+ parts.append("CODE EVIDENCE:")
126
+ seen_code: set[str] = set()
127
+ for r in group:
128
+ for e in r.evidence:
129
+ key = f"{e.rel_path}:{e.name}"
130
+ if key in seen_code:
131
+ continue
132
+ seen_code.add(key)
133
+ parts.append(f"--- {e.rel_path} :: {e.name} ---")
134
+ parts.append(e.code[:1500])
135
+ if len(seen_code) >= 4:
136
+ break
137
+ if len(seen_code) >= 4:
138
+ break
139
+ return "\n".join(parts)
140
+
141
+
142
+ def build_dossier_entry(
143
+ group: list[VerificationResult],
144
+ provider: LLMProvider,
145
+ *,
146
+ weak_spots: dict[str, WeakSpot] | None = None,
147
+ ) -> DossierEntry:
148
+ """Build one dossier entry from a bullet's verification results."""
149
+ first = group[0]
150
+ completion = provider.complete(
151
+ _DOSSIER_SYSTEM, _build_prompt(group), json_mode=True
152
+ )
153
+
154
+ questions: list[str] = []
155
+ model_answers: list[str] = []
156
+ try:
157
+ parsed = parse_json_loose(completion.text)
158
+ raw_questions = parsed.get("questions", [])
159
+ if isinstance(raw_questions, list):
160
+ questions = [str(q) for q in raw_questions if str(q).strip()][:3]
161
+ raw_answers = parsed.get("model_answers", [])
162
+ if isinstance(raw_answers, list):
163
+ model_answers = [str(a) for a in raw_answers]
164
+ elif parsed.get("model_answer"):
165
+ # Back-compat: a model that still returns a single answer.
166
+ model_answers = [str(parsed.get("model_answer", ""))]
167
+ except (ValueError, json.JSONDecodeError, AttributeError):
168
+ pass
169
+
170
+ # Align answers to questions: pad short, trim long.
171
+ if len(model_answers) < len(questions):
172
+ model_answers += [""] * (len(questions) - len(model_answers))
173
+ else:
174
+ model_answers = model_answers[: len(questions)]
175
+
176
+ weak_note = ""
177
+ if weak_spots:
178
+ spot = weak_spots.get(first.claim.bullet_text)
179
+ if spot:
180
+ weak_note = (
181
+ f"You previously failed a mock question on this bullet"
182
+ f' ("{spot.question[:100]}"): {spot.assessment}'
183
+ )
184
+
185
+ claim_notes = [
186
+ f'{r.verdict} — {r.claim.claim_type} "{r.claim.value}": {r.explanation}'
187
+ for r in group
188
+ ]
189
+
190
+ return DossierEntry(
191
+ bullet_text=first.claim.bullet_text,
192
+ section=first.claim.section,
193
+ heading=first.claim.entry_heading,
194
+ verdict=_worst_verdict(group),
195
+ claim_notes=claim_notes,
196
+ evidence=_collect_evidence(group),
197
+ questions=questions,
198
+ model_answers=model_answers,
199
+ weak_note=weak_note,
200
+ completion=completion,
201
+ )
202
+
203
+
204
+ def build_dossier(
205
+ results: list[VerificationResult],
206
+ provider: LLMProvider,
207
+ *,
208
+ weak_spots: list[WeakSpot] | None = None,
209
+ on_progress=None,
210
+ ) -> list[DossierEntry]:
211
+ """Build dossier entries for every bullet (one LLM call each)."""
212
+ spots_by_bullet: dict[str, WeakSpot] = {}
213
+ for spot in weak_spots or []:
214
+ # keep the most recent spot per bullet (list arrives newest-first)
215
+ spots_by_bullet.setdefault(spot.bullet_text, spot)
216
+
217
+ groups = _group_by_bullet(results)
218
+ entries: list[DossierEntry] = []
219
+ for i, group in enumerate(groups, 1):
220
+ entries.append(build_dossier_entry(group, provider, weak_spots=spots_by_bullet))
221
+ if on_progress is not None:
222
+ on_progress(i, len(groups))
223
+ return entries
224
+
225
+
226
+ _VERDICT_BADGE = {
227
+ "verified": "VERIFIED",
228
+ "plausible": "PLAUSIBLE",
229
+ "unsupported": "UNSUPPORTED",
230
+ }
231
+
232
+
233
+ def render_markdown(
234
+ entries: list[DossierEntry],
235
+ *,
236
+ resume_name: str,
237
+ source_name: str,
238
+ generated: str,
239
+ ) -> str:
240
+ """Render dossier entries as a study-ready markdown document."""
241
+ verified = sum(1 for e in entries if e.verdict == "verified")
242
+ plausible = sum(1 for e in entries if e.verdict == "plausible")
243
+ unsupported = sum(1 for e in entries if e.verdict == "unsupported")
244
+
245
+ lines = [
246
+ f"# Interview Defense Dossier — {resume_name}",
247
+ "",
248
+ f"*Generated {generated} · Codebase: `{source_name}` ·"
249
+ f" {verified} verified / {plausible} plausible /"
250
+ f" {unsupported} unsupported bullets*",
251
+ "",
252
+ "> Study order: unsupported bullets first — those are the ones a"
253
+ " good interviewer will find.",
254
+ "",
255
+ ]
256
+
257
+ by_section: dict[str, list[DossierEntry]] = {}
258
+ for e in entries:
259
+ by_section.setdefault(e.section, []).append(e)
260
+
261
+ for section, section_entries in by_section.items():
262
+ lines.append(f"## {section}")
263
+ lines.append("")
264
+
265
+ # Weakest first within each section — that's the study order.
266
+ section_entries = sorted(
267
+ section_entries,
268
+ key=lambda e: -_VERDICT_WEIGHT.get(e.verdict, 1),
269
+ )
270
+
271
+ for e in section_entries:
272
+ badge = _VERDICT_BADGE.get(e.verdict, e.verdict.upper())
273
+ lines.append(f"### [{badge}] {e.bullet_text}")
274
+ lines.append("")
275
+ if e.heading:
276
+ lines.append(f"*Entry: {e.heading}*")
277
+ lines.append("")
278
+
279
+ if e.claim_notes:
280
+ lines.append("**Claim-by-claim:**")
281
+ for note in e.claim_notes:
282
+ lines.append(f"- {note}")
283
+ lines.append("")
284
+
285
+ if e.evidence:
286
+ lines.append("**Evidence in your code:**")
287
+ for ev in e.evidence:
288
+ loc = (
289
+ f" (lines {ev.start_line}–{ev.end_line})" if ev.end_line else ""
290
+ )
291
+ lines.append(f"- `{ev.rel_path}` :: `{ev.name}`{loc}")
292
+ lines.append("")
293
+
294
+ if e.questions:
295
+ lines.append("**Questions to expect:**")
296
+ for i, q in enumerate(e.questions, 1):
297
+ lines.append(f"{i}. {q}")
298
+ answer = (
299
+ e.model_answers[i - 1] if i - 1 < len(e.model_answers) else ""
300
+ )
301
+ if answer:
302
+ lines.append("")
303
+ lines.append(f" > {answer}")
304
+ lines.append("")
305
+
306
+ if e.weak_note:
307
+ lines.append(f"**⚠ Drill this:** {e.weak_note}")
308
+ lines.append("")
309
+
310
+ lines.append("---")
311
+ lines.append("")
312
+
313
+ return "\n".join(lines)
@@ -0,0 +1,227 @@
1
+ """AMA session persistence and readiness tracking.
2
+
3
+ Every mock-interview session's rounds are saved to a local SQLite file
4
+ (next to the token ledger, under ``~/.receipts/``). Across sessions this
5
+ answers the questions a student actually has the week before placements:
6
+ am I improving, and which section should I drill next?
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sqlite3
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from receipts.ama.interviewer import InterviewRound
18
+
19
+ _DEFAULT_DB_PATH = Path.home() / ".receipts" / "readiness.db"
20
+
21
+ _CREATE_SESSIONS = """\
22
+ CREATE TABLE IF NOT EXISTS sessions (
23
+ id TEXT PRIMARY KEY,
24
+ timestamp REAL NOT NULL,
25
+ resume_name TEXT NOT NULL,
26
+ source TEXT NOT NULL DEFAULT ''
27
+ )"""
28
+
29
+ _CREATE_ROUNDS = """\
30
+ CREATE TABLE IF NOT EXISTS rounds (
31
+ id TEXT PRIMARY KEY,
32
+ session_id TEXT NOT NULL,
33
+ section TEXT NOT NULL,
34
+ heading TEXT NOT NULL,
35
+ bullet_text TEXT NOT NULL,
36
+ verdict TEXT NOT NULL,
37
+ question TEXT NOT NULL,
38
+ answer TEXT NOT NULL,
39
+ follow_up TEXT NOT NULL,
40
+ follow_up_answer TEXT NOT NULL,
41
+ held_up INTEGER NOT NULL,
42
+ assessment TEXT NOT NULL,
43
+ model_answer TEXT NOT NULL DEFAULT ''
44
+ )"""
45
+
46
+ _CREATE_INDEX = """\
47
+ CREATE INDEX IF NOT EXISTS idx_rounds_session ON rounds (session_id)"""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class SectionStat:
52
+ section: str
53
+ held: int
54
+ total: int
55
+
56
+ @property
57
+ def ratio(self) -> float:
58
+ return self.held / self.total if self.total else 0.0
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class SessionRecord:
63
+ session_id: str
64
+ timestamp: float
65
+ resume_name: str
66
+ source: str
67
+ total_rounds: int
68
+ held_rounds: int
69
+ sections: list[SectionStat] = field(default_factory=list)
70
+
71
+ @property
72
+ def ratio(self) -> float:
73
+ return self.held_rounds / self.total_rounds if self.total_rounds else 0.0
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class WeakSpot:
78
+ bullet_text: str
79
+ section: str
80
+ question: str
81
+ assessment: str
82
+ timestamp: float
83
+ model_answer: str = ""
84
+
85
+
86
+ class ReadinessStore:
87
+ """SQLite-backed store of mock-interview sessions."""
88
+
89
+ def __init__(self, db_path: Path | None = None) -> None:
90
+ self.db_path = db_path or _DEFAULT_DB_PATH
91
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
92
+ self._conn = sqlite3.connect(str(self.db_path))
93
+ self._conn.execute(_CREATE_SESSIONS)
94
+ self._conn.execute(_CREATE_ROUNDS)
95
+ self._conn.execute(_CREATE_INDEX)
96
+ self._migrate()
97
+ self._conn.commit()
98
+
99
+ def _migrate(self) -> None:
100
+ """Additive migrations for DBs created by an older version.
101
+
102
+ New columns are added with a default so existing rows stay valid and
103
+ older code that never wrote them keeps working.
104
+ """
105
+ cols = {row[1] for row in self._conn.execute("PRAGMA table_info(rounds)")}
106
+ if "model_answer" not in cols:
107
+ self._conn.execute(
108
+ "ALTER TABLE rounds ADD COLUMN model_answer TEXT NOT NULL DEFAULT ''"
109
+ )
110
+
111
+ def save_session(
112
+ self,
113
+ rounds: list[InterviewRound],
114
+ *,
115
+ resume_name: str,
116
+ source: str = "",
117
+ ) -> str:
118
+ """Persist one AMA session. Returns the session id."""
119
+ session_id = uuid.uuid4().hex[:12]
120
+ self._conn.execute(
121
+ "INSERT INTO sessions (id, timestamp, resume_name, source)"
122
+ " VALUES (?, ?, ?, ?)",
123
+ (session_id, time.time(), resume_name, source),
124
+ )
125
+ for r in rounds:
126
+ self._conn.execute(
127
+ "INSERT INTO rounds (id, session_id, section, heading,"
128
+ " bullet_text, verdict, question, answer, follow_up,"
129
+ " follow_up_answer, held_up, assessment, model_answer)"
130
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
131
+ (
132
+ uuid.uuid4().hex[:16],
133
+ session_id,
134
+ r.section,
135
+ r.heading,
136
+ r.bullet_text,
137
+ r.verdict,
138
+ r.question,
139
+ r.answer,
140
+ r.follow_up,
141
+ r.follow_up_answer,
142
+ int(r.held_up),
143
+ r.assessment,
144
+ getattr(r, "model_answer", ""),
145
+ ),
146
+ )
147
+ self._conn.commit()
148
+ return session_id
149
+
150
+ def sessions(self, *, resume_name: str | None = None) -> list[SessionRecord]:
151
+ """All sessions, oldest first, with per-section held-up stats."""
152
+ query = "SELECT id, timestamp, resume_name, source FROM sessions"
153
+ params: tuple = ()
154
+ if resume_name is not None:
155
+ query += " WHERE resume_name = ?"
156
+ params = (resume_name,)
157
+ query += " ORDER BY timestamp"
158
+
159
+ records: list[SessionRecord] = []
160
+ for sid, ts, rname, source in self._conn.execute(query, params):
161
+ rows = self._conn.execute(
162
+ "SELECT section, SUM(held_up), COUNT(*) FROM rounds"
163
+ " WHERE session_id = ? GROUP BY section",
164
+ (sid,),
165
+ ).fetchall()
166
+ sections = [
167
+ SectionStat(section=sec, held=int(held or 0), total=int(total))
168
+ for sec, held, total in rows
169
+ ]
170
+ total = sum(s.total for s in sections)
171
+ held = sum(s.held for s in sections)
172
+ records.append(
173
+ SessionRecord(
174
+ session_id=sid,
175
+ timestamp=ts,
176
+ resume_name=rname,
177
+ source=source,
178
+ total_rounds=total,
179
+ held_rounds=held,
180
+ sections=sections,
181
+ )
182
+ )
183
+ return records
184
+
185
+ def weakest_section(self) -> SectionStat | None:
186
+ """The section with the lowest held-up ratio in the latest session."""
187
+ latest = self.sessions()
188
+ if not latest:
189
+ return None
190
+ sections = latest[-1].sections
191
+ if not sections:
192
+ return None
193
+ return min(sections, key=lambda s: s.ratio)
194
+
195
+ def weak_spots(self, limit: int = 20) -> list[WeakSpot]:
196
+ """Recent rounds that did NOT hold up — the drill list."""
197
+ rows = self._conn.execute(
198
+ "SELECT r.bullet_text, r.section, r.question, r.assessment,"
199
+ " s.timestamp, r.model_answer"
200
+ " FROM rounds r JOIN sessions s ON r.session_id = s.id"
201
+ " WHERE r.held_up = 0"
202
+ " ORDER BY s.timestamp DESC LIMIT ?",
203
+ (limit,),
204
+ ).fetchall()
205
+ return [
206
+ WeakSpot(
207
+ bullet_text=b,
208
+ section=sec,
209
+ question=q,
210
+ assessment=a,
211
+ timestamp=ts,
212
+ model_answer=ma or "",
213
+ )
214
+ for b, sec, q, a, ts, ma in rows
215
+ ]
216
+
217
+ def close(self) -> None:
218
+ self._conn.close()
219
+
220
+
221
+ def readiness_delta(
222
+ sessions: list[SessionRecord],
223
+ ) -> tuple[float, float] | None:
224
+ """(latest_ratio, previous_ratio) for trend display, or None if <2."""
225
+ if len(sessions) < 2:
226
+ return None
227
+ return sessions[-1].ratio, sessions[-2].ratio
File without changes