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.
- codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
- codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
- codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
- codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
- codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- receipts/__init__.py +0 -0
- receipts/ama/__init__.py +0 -0
- receipts/ama/interviewer.py +415 -0
- receipts/ats/__init__.py +1 -0
- receipts/ats/comparator.py +98 -0
- receipts/ats/scorer.py +550 -0
- receipts/ats/stats.py +164 -0
- receipts/cli.py +2062 -0
- receipts/config.py +106 -0
- receipts/errors.py +18 -0
- receipts/export.py +120 -0
- receipts/ingest/__init__.py +0 -0
- receipts/ingest/artifact_extractor.py +679 -0
- receipts/ingest/git_source.py +159 -0
- receipts/ingest/manifest.py +114 -0
- receipts/ingest/scanner.py +141 -0
- receipts/ingest/secrets_scanner.py +225 -0
- receipts/interactive/__init__.py +1 -0
- receipts/interactive/repl.py +755 -0
- receipts/ledger/__init__.py +0 -0
- receipts/ledger/pricing_table.py +54 -0
- receipts/ledger/token_ledger.py +226 -0
- receipts/llm/__init__.py +0 -0
- receipts/llm/anthropic_provider.py +95 -0
- receipts/llm/factory.py +124 -0
- receipts/llm/fake_provider.py +79 -0
- receipts/llm/gemini_provider.py +205 -0
- receipts/llm/json_utils.py +46 -0
- receipts/llm/metered.py +62 -0
- receipts/llm/ollama_provider.py +117 -0
- receipts/llm/openai_provider.py +118 -0
- receipts/llm/provider.py +42 -0
- receipts/llm/split.py +78 -0
- receipts/llm/token_estimate.py +35 -0
- receipts/mine/__init__.py +1 -0
- receipts/mine/code_metrics.py +246 -0
- receipts/mine/git_evidence.py +109 -0
- receipts/prep/__init__.py +1 -0
- receipts/prep/dossier.py +313 -0
- receipts/prep/readiness.py +227 -0
- receipts/resume/__init__.py +0 -0
- receipts/resume/claim_extractor.py +338 -0
- receipts/resume/loader.py +35 -0
- receipts/resume/pdf_parser.py +259 -0
- receipts/resume/tex_parser.py +394 -0
- receipts/rewrite/__init__.py +0 -0
- receipts/rewrite/compiler.py +180 -0
- receipts/rewrite/optimizer.py +140 -0
- receipts/rewrite/tex_rewriter.py +227 -0
- receipts/tui/__init__.py +0 -0
- receipts/tui/ama_app.py +454 -0
- receipts/tui/app.py +316 -0
- receipts/tui/dossier_app.py +170 -0
- receipts/tui/hub.py +367 -0
- receipts/tui/ingest_app.py +183 -0
- receipts/tui/rewrite_app.py +463 -0
- receipts/tui/score_app.py +237 -0
- receipts/tui/widgets/__init__.py +0 -0
- receipts/tui/widgets/claims_table.py +50 -0
- receipts/tui/widgets/diff_view.py +38 -0
- receipts/tui/widgets/status_bar.py +38 -0
- receipts/verify/__init__.py +0 -0
- receipts/verify/bm25.py +112 -0
- receipts/verify/enrich.py +128 -0
- receipts/verify/jd_fetch.py +101 -0
- receipts/verify/kb.py +379 -0
- receipts/verify/keyword_gap.py +127 -0
- receipts/verify/query_expand.py +88 -0
- receipts/verify/rerank.py +74 -0
- receipts/verify/rewriter.py +172 -0
- receipts/verify/router.py +211 -0
- receipts/verify/summaries.py +258 -0
- receipts/verify/verifier.py +552 -0
receipts/prep/dossier.py
ADDED
|
@@ -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
|