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
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Mock-interview loop weighted toward weak resume bullets.
|
|
2
|
+
|
|
3
|
+
The interviewer picks bullets to quiz on — Unsupported and Plausible
|
|
4
|
+
bullets get asked about more often than Verified ones. For each bullet
|
|
5
|
+
it generates a technical question, listens to the user's answer, asks a
|
|
6
|
+
natural follow-up, then moves on. At the end it prints a summary of
|
|
7
|
+
what held up and what didn't.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import random
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from receipts.llm.json_utils import parse_json_loose
|
|
17
|
+
from receipts.llm.provider import CompletionResult, LLMProvider
|
|
18
|
+
from receipts.verify.verifier import VerificationResult
|
|
19
|
+
|
|
20
|
+
_VERDICT_WEIGHT = {
|
|
21
|
+
"unsupported": 3,
|
|
22
|
+
"plausible": 2,
|
|
23
|
+
"verified": 1,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_QUESTION_SYSTEM = """\
|
|
27
|
+
You are a technical interviewer. Given a resume bullet and its \
|
|
28
|
+
verification status, ask ONE pointed technical question that tests \
|
|
29
|
+
whether the candidate truly did what the bullet claims.
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- For "unsupported" or "plausible" bullets, probe the specific claim \
|
|
33
|
+
that lacks evidence — ask for concrete details, numbers, or \
|
|
34
|
+
implementation choices.
|
|
35
|
+
- For "verified" bullets, ask a deeper follow-up that tests real \
|
|
36
|
+
understanding (architecture decisions, trade-offs, failure modes).
|
|
37
|
+
- Be direct and specific. No generic "tell me about" questions.
|
|
38
|
+
- The question should be answerable in 2-3 sentences.
|
|
39
|
+
|
|
40
|
+
Respond in JSON: {"question": "your question here"}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_EXPERIENCE_QUESTION_SYSTEM = """\
|
|
44
|
+
You are a behavioral interviewer. Given a resume bullet from the \
|
|
45
|
+
candidate's work experience, ask ONE pointed behavioral question \
|
|
46
|
+
using the STAR format (Situation, Task, Action, Result).
|
|
47
|
+
|
|
48
|
+
Rules:
|
|
49
|
+
- Ask about a specific aspect of the bullet — a challenge faced, a \
|
|
50
|
+
decision made, a result achieved, or a collaboration required.
|
|
51
|
+
- Probe for concrete details: team size, timeline, measurable impact.
|
|
52
|
+
- Do NOT ask generic questions. Reference the specific technologies, \
|
|
53
|
+
metrics, or responsibilities mentioned in the bullet.
|
|
54
|
+
- The question should be answerable in 3-4 sentences.
|
|
55
|
+
|
|
56
|
+
Respond in JSON: {"question": "your question here"}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
_EDUCATION_QUESTION_SYSTEM = """\
|
|
60
|
+
You are an interviewer testing a candidate's academic knowledge. Given \
|
|
61
|
+
a bullet from the Education section of their resume, ask ONE pointed \
|
|
62
|
+
knowledge question.
|
|
63
|
+
|
|
64
|
+
Rules:
|
|
65
|
+
- If the bullet mentions a GPA, coursework, or degree, ask about a \
|
|
66
|
+
concept they should know from that program.
|
|
67
|
+
- If the bullet mentions a specific course or project, ask about the \
|
|
68
|
+
key concepts or techniques involved.
|
|
69
|
+
- Test understanding, not memorization — ask "how does X work" or \
|
|
70
|
+
"what's the trade-off between X and Y", not "define X".
|
|
71
|
+
- The question should be answerable in 2-3 sentences.
|
|
72
|
+
|
|
73
|
+
Respond in JSON: {"question": "your question here"}
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
_SKILLS_QUESTION_SYSTEM = """\
|
|
77
|
+
You are a technical interviewer. Given a skill claim from the \
|
|
78
|
+
candidate's resume, ask ONE practical question that tests whether \
|
|
79
|
+
they actually use this technology.
|
|
80
|
+
|
|
81
|
+
Rules:
|
|
82
|
+
- Ask about real usage: "how would you do X with this tool" or "when \
|
|
83
|
+
would you choose X over Y".
|
|
84
|
+
- Probe for hands-on experience, not textbook knowledge.
|
|
85
|
+
- If the skill is a language or framework, ask about a specific feature, \
|
|
86
|
+
pattern, or common pitfall.
|
|
87
|
+
- The question should be answerable in 2-3 sentences.
|
|
88
|
+
|
|
89
|
+
Respond in JSON: {"question": "your question here"}
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
_SECTION_QUESTION_SYSTEMS = {
|
|
93
|
+
"experience": _EXPERIENCE_QUESTION_SYSTEM,
|
|
94
|
+
"education": _EDUCATION_QUESTION_SYSTEM,
|
|
95
|
+
"skills": _SKILLS_QUESTION_SYSTEM,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Interview round presets — match the questioning style to the kind of
|
|
99
|
+
# company round the candidate is preparing for.
|
|
100
|
+
_STYLE_MODIFIERS = {
|
|
101
|
+
"service": """\
|
|
102
|
+
|
|
103
|
+
ROUND STYLE — service company (TCS/Infosys/Wipro-type):
|
|
104
|
+
- Favor fundamentals and clarity over depth: "explain your project simply",
|
|
105
|
+
core language/OOP/DBMS concepts behind what they built.
|
|
106
|
+
- Expect the candidate to explain their project to a non-expert panel in
|
|
107
|
+
2-3 sentences before drilling one level down.
|
|
108
|
+
- Keep the question answerable without whiteboarding.""",
|
|
109
|
+
"product": """\
|
|
110
|
+
|
|
111
|
+
ROUND STYLE — product company (design-depth round):
|
|
112
|
+
- Favor architecture decisions, trade-offs, scalability, and failure modes.
|
|
113
|
+
- Ask "why this over the alternative" and "what breaks at 10x load".
|
|
114
|
+
- Reference the specific technologies in the bullet — generic system
|
|
115
|
+
design questions are not allowed.""",
|
|
116
|
+
"startup": """\
|
|
117
|
+
|
|
118
|
+
ROUND STYLE — startup (ownership round):
|
|
119
|
+
- Favor shipping speed, scrappiness, and end-to-end ownership.
|
|
120
|
+
- Ask what the candidate cut, deferred, or hacked to ship — and how they'd
|
|
121
|
+
fix it with more time.
|
|
122
|
+
- Probe whether they understand the WHOLE stack of what they built, not
|
|
123
|
+
just their corner.""",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
INTERVIEW_STYLES = tuple(_STYLE_MODIFIERS)
|
|
127
|
+
|
|
128
|
+
_FOLLOWUP_SYSTEM = """\
|
|
129
|
+
You are a technical interviewer conducting a follow-up. Given the \
|
|
130
|
+
original question and the candidate's answer, ask ONE natural \
|
|
131
|
+
follow-up question that digs deeper.
|
|
132
|
+
|
|
133
|
+
Rules:
|
|
134
|
+
- If the answer was vague, ask for specifics.
|
|
135
|
+
- If the answer was detailed, probe a related edge case or trade-off.
|
|
136
|
+
- Keep it conversational — this should feel like a real interview.
|
|
137
|
+
- One question only, answerable in 2-3 sentences.
|
|
138
|
+
|
|
139
|
+
Respond in JSON: {"follow_up": "your follow-up question here"}
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
_ASSESS_SYSTEM = """\
|
|
143
|
+
You are evaluating a candidate's interview answer. Given the resume \
|
|
144
|
+
bullet, the question asked, and the candidate's answer, assess how \
|
|
145
|
+
well they demonstrated real knowledge.
|
|
146
|
+
|
|
147
|
+
Respond in JSON:
|
|
148
|
+
{"held_up": true/false, "assessment": "one sentence summary"}
|
|
149
|
+
|
|
150
|
+
Set held_up to true if the answer shows genuine understanding, even \
|
|
151
|
+
if imperfect. Set it to false if the answer is vague, evasive, or \
|
|
152
|
+
contradicts the claim.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
_MODEL_ANSWER_SYSTEM = """\
|
|
156
|
+
You are an interview coach. Given a resume bullet, an interview question \
|
|
157
|
+
just asked about it, and the actual code evidence from the candidate's \
|
|
158
|
+
codebase, write the answer the candidate SHOULD give.
|
|
159
|
+
|
|
160
|
+
Rules:
|
|
161
|
+
- 3-5 sentences, first person, spoken the way a candidate would in a real \
|
|
162
|
+
interview — not a written essay.
|
|
163
|
+
- Ground every claim ONLY in the provided evidence. Reference the specific \
|
|
164
|
+
files, functions, or patterns shown. Never invent numbers, files, or \
|
|
165
|
+
capabilities that aren't in the evidence.
|
|
166
|
+
- If the evidence only partially supports the bullet, answer honestly about \
|
|
167
|
+
what the code actually shows rather than overclaiming.
|
|
168
|
+
|
|
169
|
+
Respond in JSON: {"model_answer": "the answer here"}
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
_MODEL_ANSWER_NO_EVIDENCE_SYSTEM = """\
|
|
173
|
+
You are an interview coach. A resume bullet was just asked about in an \
|
|
174
|
+
interview, but there is NO code evidence in the candidate's ingested \
|
|
175
|
+
codebase to back it up — the claim may be about a private repo, a \
|
|
176
|
+
non-code achievement, or something that simply cannot be proven from code.
|
|
177
|
+
|
|
178
|
+
Write the answer the candidate should give: honest, specific where they \
|
|
179
|
+
legitimately can be, framed so they show real understanding WITHOUT \
|
|
180
|
+
fabricating evidence or numbers they cannot support.
|
|
181
|
+
|
|
182
|
+
Rules:
|
|
183
|
+
- 3-5 sentences, first person, conversational.
|
|
184
|
+
- Do NOT invent metrics, file names, or specifics that weren't in the bullet.
|
|
185
|
+
- Show how to speak to the claim credibly and honestly, acknowledging the \
|
|
186
|
+
limits of what can be demonstrated.
|
|
187
|
+
|
|
188
|
+
Respond in JSON: {"model_answer": "the answer here"}
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
_SUMMARY_SYSTEM = """\
|
|
192
|
+
You are wrapping up a mock interview. Given the rounds of Q&A and \
|
|
193
|
+
assessments, write a brief summary.
|
|
194
|
+
|
|
195
|
+
Respond in JSON:
|
|
196
|
+
{"strong": ["point 1", "point 2"], \
|
|
197
|
+
"weak": ["point 1", "point 2"], \
|
|
198
|
+
"overall": "one paragraph overall assessment"}
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class InterviewRound:
|
|
204
|
+
bullet_text: str
|
|
205
|
+
section: str
|
|
206
|
+
heading: str
|
|
207
|
+
verdict: str
|
|
208
|
+
question: str
|
|
209
|
+
answer: str
|
|
210
|
+
follow_up: str
|
|
211
|
+
follow_up_answer: str
|
|
212
|
+
held_up: bool
|
|
213
|
+
assessment: str
|
|
214
|
+
model_answer: str = ""
|
|
215
|
+
completions: list[CompletionResult] = field(default_factory=list)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class InterviewSummary:
|
|
220
|
+
rounds: list[InterviewRound]
|
|
221
|
+
strong: list[str]
|
|
222
|
+
weak: list[str]
|
|
223
|
+
overall: str
|
|
224
|
+
completion: CompletionResult | None = None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def select_bullets(
|
|
228
|
+
results: list[VerificationResult],
|
|
229
|
+
n: int,
|
|
230
|
+
*,
|
|
231
|
+
rng: random.Random | None = None,
|
|
232
|
+
) -> list[VerificationResult]:
|
|
233
|
+
"""Pick *n* bullets to quiz on, weighted toward weak verdicts."""
|
|
234
|
+
by_bullet: dict[str, VerificationResult] = {}
|
|
235
|
+
for r in results:
|
|
236
|
+
key = r.claim.bullet_text
|
|
237
|
+
if key not in by_bullet:
|
|
238
|
+
by_bullet[key] = r
|
|
239
|
+
elif _VERDICT_WEIGHT.get(r.verdict, 1) > _VERDICT_WEIGHT.get(
|
|
240
|
+
by_bullet[key].verdict, 1
|
|
241
|
+
):
|
|
242
|
+
by_bullet[key] = r
|
|
243
|
+
|
|
244
|
+
unique = list(by_bullet.values())
|
|
245
|
+
if not unique:
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
weights = [_VERDICT_WEIGHT.get(r.verdict, 1) for r in unique]
|
|
249
|
+
rng = rng or random.Random()
|
|
250
|
+
n = min(n, len(unique))
|
|
251
|
+
|
|
252
|
+
selected: list[VerificationResult] = []
|
|
253
|
+
remaining = list(zip(unique, weights, strict=True))
|
|
254
|
+
|
|
255
|
+
for _ in range(n):
|
|
256
|
+
if not remaining:
|
|
257
|
+
break
|
|
258
|
+
items, ws = zip(*remaining, strict=True)
|
|
259
|
+
choice = rng.choices(list(items), weights=list(ws), k=1)[0]
|
|
260
|
+
selected.append(choice)
|
|
261
|
+
remaining = [(r, w) for r, w in remaining if r is not choice]
|
|
262
|
+
|
|
263
|
+
return selected
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def generate_question(
|
|
267
|
+
result: VerificationResult,
|
|
268
|
+
provider: LLMProvider,
|
|
269
|
+
*,
|
|
270
|
+
style: str | None = None,
|
|
271
|
+
) -> tuple[str, CompletionResult]:
|
|
272
|
+
"""Ask the LLM to generate an interview question for this bullet.
|
|
273
|
+
|
|
274
|
+
Uses section-aware system prompts: behavioral/STAR for work experience,
|
|
275
|
+
knowledge-check for education, skill-application for skills, and
|
|
276
|
+
technical deep-dive for projects. ``style`` optionally tunes the
|
|
277
|
+
questioning to a company round type ("service" | "product" | "startup").
|
|
278
|
+
"""
|
|
279
|
+
sec_type = getattr(result.claim, "section_type", "project")
|
|
280
|
+
system = _SECTION_QUESTION_SYSTEMS.get(sec_type, _QUESTION_SYSTEM)
|
|
281
|
+
modifier = _STYLE_MODIFIERS.get((style or "").strip().lower())
|
|
282
|
+
if modifier:
|
|
283
|
+
system += modifier
|
|
284
|
+
|
|
285
|
+
user = (
|
|
286
|
+
f"BULLET: {result.claim.bullet_text}\n"
|
|
287
|
+
f"SECTION: {result.claim.section}\n"
|
|
288
|
+
f"VERDICT: {result.verdict}\n"
|
|
289
|
+
f"EXPLANATION: {result.explanation}"
|
|
290
|
+
)
|
|
291
|
+
completion = provider.complete(system, user, json_mode=True)
|
|
292
|
+
try:
|
|
293
|
+
parsed = parse_json_loose(completion.text)
|
|
294
|
+
question = parsed.get("question", completion.text)
|
|
295
|
+
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
296
|
+
question = completion.text
|
|
297
|
+
return question, completion
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def generate_follow_up(
|
|
301
|
+
question: str,
|
|
302
|
+
answer: str,
|
|
303
|
+
provider: LLMProvider,
|
|
304
|
+
) -> tuple[str, CompletionResult]:
|
|
305
|
+
"""Generate a follow-up question based on the candidate's answer."""
|
|
306
|
+
user = f"QUESTION: {question}\nANSWER: {answer}"
|
|
307
|
+
completion = provider.complete(_FOLLOWUP_SYSTEM, user, json_mode=True)
|
|
308
|
+
try:
|
|
309
|
+
parsed = parse_json_loose(completion.text)
|
|
310
|
+
follow_up = parsed.get("follow_up", completion.text)
|
|
311
|
+
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
312
|
+
follow_up = completion.text
|
|
313
|
+
return follow_up, completion
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def assess_answer(
|
|
317
|
+
bullet_text: str,
|
|
318
|
+
question: str,
|
|
319
|
+
answer: str,
|
|
320
|
+
provider: LLMProvider,
|
|
321
|
+
) -> tuple[bool, str, CompletionResult]:
|
|
322
|
+
"""Assess whether the candidate's answer held up."""
|
|
323
|
+
user = f"BULLET: {bullet_text}\n" f"QUESTION: {question}\n" f"ANSWER: {answer}"
|
|
324
|
+
completion = provider.complete(_ASSESS_SYSTEM, user, json_mode=True)
|
|
325
|
+
try:
|
|
326
|
+
parsed = parse_json_loose(completion.text)
|
|
327
|
+
held_up = bool(parsed.get("held_up", False))
|
|
328
|
+
assessment = parsed.get("assessment", "")
|
|
329
|
+
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
330
|
+
held_up = False
|
|
331
|
+
assessment = completion.text[:200]
|
|
332
|
+
return held_up, assessment, completion
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def generate_model_answer(
|
|
336
|
+
result: VerificationResult,
|
|
337
|
+
question: str,
|
|
338
|
+
provider: LLMProvider,
|
|
339
|
+
) -> tuple[str, CompletionResult]:
|
|
340
|
+
"""Generate the strong, honest answer the candidate SHOULD have given.
|
|
341
|
+
|
|
342
|
+
Grounded ONLY in the round's retrieved code evidence. When the bullet has
|
|
343
|
+
no evidence behind it (unsupported claims, non-code sections), the prompt
|
|
344
|
+
switches to honest-reframe guidance instead of inventing support — the
|
|
345
|
+
same honesty constraint the verifier enforces. Evidence chunks are
|
|
346
|
+
truncated so a large ``SearchResult.code`` block never blows the context
|
|
347
|
+
of a small local model.
|
|
348
|
+
"""
|
|
349
|
+
evidence = getattr(result, "evidence", None) or []
|
|
350
|
+
if evidence:
|
|
351
|
+
parts = [
|
|
352
|
+
f"BULLET: {result.claim.bullet_text}",
|
|
353
|
+
f"QUESTION: {question}",
|
|
354
|
+
"",
|
|
355
|
+
"CODE EVIDENCE:",
|
|
356
|
+
]
|
|
357
|
+
for e in evidence[:3]:
|
|
358
|
+
parts.append(f"--- {e.rel_path} :: {e.name} ---")
|
|
359
|
+
parts.append(e.code[:1200])
|
|
360
|
+
user = "\n".join(parts)
|
|
361
|
+
system = _MODEL_ANSWER_SYSTEM
|
|
362
|
+
else:
|
|
363
|
+
user = (
|
|
364
|
+
f"BULLET: {result.claim.bullet_text}\n"
|
|
365
|
+
f"QUESTION: {question}\n"
|
|
366
|
+
f"VERDICT: {result.verdict}\n"
|
|
367
|
+
f"EXPLANATION: {result.explanation}"
|
|
368
|
+
)
|
|
369
|
+
system = _MODEL_ANSWER_NO_EVIDENCE_SYSTEM
|
|
370
|
+
|
|
371
|
+
completion = provider.complete(system, user, json_mode=True)
|
|
372
|
+
try:
|
|
373
|
+
parsed = parse_json_loose(completion.text)
|
|
374
|
+
answer = parsed.get("model_answer", completion.text)
|
|
375
|
+
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
376
|
+
answer = completion.text
|
|
377
|
+
return answer, completion
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def generate_summary(
|
|
381
|
+
rounds: list[InterviewRound],
|
|
382
|
+
provider: LLMProvider,
|
|
383
|
+
) -> InterviewSummary:
|
|
384
|
+
"""Generate a summary of the entire interview."""
|
|
385
|
+
parts = []
|
|
386
|
+
for i, r in enumerate(rounds, 1):
|
|
387
|
+
parts.append(
|
|
388
|
+
f"Round {i}:\n"
|
|
389
|
+
f" Bullet: {r.bullet_text[:80]}\n"
|
|
390
|
+
f" Verdict: {r.verdict}\n"
|
|
391
|
+
f" Q: {r.question}\n"
|
|
392
|
+
f" A: {r.answer[:100]}\n"
|
|
393
|
+
f" Held up: {r.held_up}\n"
|
|
394
|
+
f" Assessment: {r.assessment}"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
completion = provider.complete(_SUMMARY_SYSTEM, "\n\n".join(parts), json_mode=True)
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
parsed = parse_json_loose(completion.text)
|
|
401
|
+
strong = parsed.get("strong", [])
|
|
402
|
+
weak = parsed.get("weak", [])
|
|
403
|
+
overall = parsed.get("overall", "")
|
|
404
|
+
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
405
|
+
strong = []
|
|
406
|
+
weak = []
|
|
407
|
+
overall = completion.text[:300]
|
|
408
|
+
|
|
409
|
+
return InterviewSummary(
|
|
410
|
+
rounds=rounds,
|
|
411
|
+
strong=strong,
|
|
412
|
+
weak=weak,
|
|
413
|
+
overall=overall,
|
|
414
|
+
completion=completion,
|
|
415
|
+
)
|
receipts/ats/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ATS scoring & comparison engine."""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Before/after ATS comparison — score original vs rewritten, report deltas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from receipts.ats.scorer import (
|
|
9
|
+
ATSScore,
|
|
10
|
+
score_tex_file,
|
|
11
|
+
score_tex_string,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DimensionDelta:
|
|
17
|
+
name: str
|
|
18
|
+
before: int
|
|
19
|
+
after: int
|
|
20
|
+
delta: int
|
|
21
|
+
detail_before: str
|
|
22
|
+
detail_after: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ComparisonReport:
|
|
27
|
+
before: ATSScore
|
|
28
|
+
after: ATSScore
|
|
29
|
+
deltas: list[DimensionDelta]
|
|
30
|
+
composite_delta: float
|
|
31
|
+
improved: bool
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def summary(self) -> str:
|
|
35
|
+
direction = "+" if self.composite_delta >= 0 else ""
|
|
36
|
+
lines = [
|
|
37
|
+
f"Composite: {self.before.composite:.1f} → {self.after.composite:.1f} "
|
|
38
|
+
f"({direction}{self.composite_delta:.1f})",
|
|
39
|
+
"",
|
|
40
|
+
]
|
|
41
|
+
for d in self.deltas:
|
|
42
|
+
arrow = "+" if d.delta >= 0 else ""
|
|
43
|
+
lines.append(
|
|
44
|
+
f" {d.name:<22} {d.before:>3} → {d.after:>3} ({arrow}{d.delta})"
|
|
45
|
+
)
|
|
46
|
+
return "\n".join(lines)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compare_scores(before: ATSScore, after: ATSScore) -> ComparisonReport:
|
|
50
|
+
"""Compare two ATSScore objects and produce a delta report."""
|
|
51
|
+
deltas: list[DimensionDelta] = []
|
|
52
|
+
|
|
53
|
+
for dim_before in before.dimensions:
|
|
54
|
+
dim_after = after.dimension(dim_before.name)
|
|
55
|
+
if dim_after is None:
|
|
56
|
+
continue
|
|
57
|
+
deltas.append(
|
|
58
|
+
DimensionDelta(
|
|
59
|
+
name=dim_before.name,
|
|
60
|
+
before=dim_before.score,
|
|
61
|
+
after=dim_after.score,
|
|
62
|
+
delta=dim_after.score - dim_before.score,
|
|
63
|
+
detail_before=dim_before.detail,
|
|
64
|
+
detail_after=dim_after.detail,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
composite_delta = round(after.composite - before.composite, 1)
|
|
69
|
+
|
|
70
|
+
return ComparisonReport(
|
|
71
|
+
before=before,
|
|
72
|
+
after=after,
|
|
73
|
+
deltas=deltas,
|
|
74
|
+
composite_delta=composite_delta,
|
|
75
|
+
improved=composite_delta > 0,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def compare_tex_files(
|
|
80
|
+
before_path: Path,
|
|
81
|
+
after_path: Path,
|
|
82
|
+
jd_text: str,
|
|
83
|
+
) -> ComparisonReport:
|
|
84
|
+
"""Score two .tex files against the same JD and compare."""
|
|
85
|
+
before_score = score_tex_file(before_path, jd_text)
|
|
86
|
+
after_score = score_tex_file(after_path, jd_text)
|
|
87
|
+
return compare_scores(before_score, after_score)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def compare_tex_strings(
|
|
91
|
+
before_tex: str,
|
|
92
|
+
after_tex: str,
|
|
93
|
+
jd_text: str,
|
|
94
|
+
) -> ComparisonReport:
|
|
95
|
+
"""Score two TeX strings against the same JD and compare."""
|
|
96
|
+
before_score = score_tex_string(before_tex, jd_text)
|
|
97
|
+
after_score = score_tex_string(after_tex, jd_text)
|
|
98
|
+
return compare_scores(before_score, after_score)
|