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/tui/ama_app.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Textual TUI for the AMA / mock-interview mode.
|
|
2
|
+
|
|
3
|
+
Chat-style interface: questions appear in the log, user types answers
|
|
4
|
+
in an input box at the bottom, verdicts and summaries are color-coded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.containers import Vertical
|
|
13
|
+
from textual.widgets import Footer, Header, Input, RichLog
|
|
14
|
+
|
|
15
|
+
from receipts.tui.widgets.status_bar import StatusBar
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AmaApp(App):
|
|
19
|
+
"""Chat-style TUI for mock interviews."""
|
|
20
|
+
|
|
21
|
+
TITLE = "Receipts"
|
|
22
|
+
SUB_TITLE = "mock interview"
|
|
23
|
+
|
|
24
|
+
CSS = """
|
|
25
|
+
#chat-log {
|
|
26
|
+
height: 1fr;
|
|
27
|
+
border-bottom: solid $primary;
|
|
28
|
+
}
|
|
29
|
+
#answer-input {
|
|
30
|
+
dock: bottom;
|
|
31
|
+
margin: 0 1;
|
|
32
|
+
}
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
BINDINGS = [
|
|
36
|
+
("escape", "quit", "Quit"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
resume_path: Path,
|
|
42
|
+
source_path: Path,
|
|
43
|
+
*,
|
|
44
|
+
n_rounds: int = 5,
|
|
45
|
+
threshold: float = 0.5,
|
|
46
|
+
sections: list[str] | None = None,
|
|
47
|
+
entries: list[str] | None = None,
|
|
48
|
+
style: str | None = None,
|
|
49
|
+
save_session: bool = True,
|
|
50
|
+
kb_dir: Path | None = None,
|
|
51
|
+
**kwargs,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(**kwargs)
|
|
54
|
+
self.resume_path = resume_path
|
|
55
|
+
self.source_path = source_path
|
|
56
|
+
self.n_rounds = n_rounds
|
|
57
|
+
self.threshold = threshold
|
|
58
|
+
self.sections = sections
|
|
59
|
+
self.entries = entries
|
|
60
|
+
self.style = style
|
|
61
|
+
self.save_session = save_session
|
|
62
|
+
# Pre-resolved KB dir (git URLs / manifest aliases); None → derive
|
|
63
|
+
# from source_path like always.
|
|
64
|
+
self.kb_dir = kb_dir
|
|
65
|
+
self._pending_answer: str | None = None
|
|
66
|
+
self._waiting_for_input = False
|
|
67
|
+
|
|
68
|
+
def compose(self) -> ComposeResult:
|
|
69
|
+
yield Header()
|
|
70
|
+
with Vertical():
|
|
71
|
+
yield RichLog(id="chat-log", markup=True, wrap=True)
|
|
72
|
+
yield StatusBar(id="status-bar")
|
|
73
|
+
inp = Input(
|
|
74
|
+
placeholder="Type your answer here...",
|
|
75
|
+
id="answer-input",
|
|
76
|
+
disabled=True,
|
|
77
|
+
)
|
|
78
|
+
yield inp
|
|
79
|
+
yield Footer()
|
|
80
|
+
|
|
81
|
+
def on_mount(self) -> None:
|
|
82
|
+
self.run_worker(self._run_interview, thread=True)
|
|
83
|
+
|
|
84
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
85
|
+
if not self._waiting_for_input:
|
|
86
|
+
return
|
|
87
|
+
self._pending_answer = event.value
|
|
88
|
+
event.input.value = ""
|
|
89
|
+
event.input.disabled = True
|
|
90
|
+
self._waiting_for_input = False
|
|
91
|
+
|
|
92
|
+
def _ask_user(self, log: RichLog, inp: Input) -> str:
|
|
93
|
+
"""Block the worker thread until the user submits an answer."""
|
|
94
|
+
import time
|
|
95
|
+
|
|
96
|
+
self._pending_answer = None
|
|
97
|
+
self._waiting_for_input = True
|
|
98
|
+
self.call_from_thread(self._enable_input)
|
|
99
|
+
while self._pending_answer is None:
|
|
100
|
+
time.sleep(0.05)
|
|
101
|
+
answer = self._pending_answer
|
|
102
|
+
log.write(f"[dim]You:[/dim] {answer}")
|
|
103
|
+
log.write("")
|
|
104
|
+
return answer
|
|
105
|
+
|
|
106
|
+
def _enable_input(self) -> None:
|
|
107
|
+
inp = self.query_one("#answer-input", Input)
|
|
108
|
+
inp.disabled = False
|
|
109
|
+
inp.focus()
|
|
110
|
+
|
|
111
|
+
async def _run_interview(self) -> None:
|
|
112
|
+
log = self.query_one("#chat-log", RichLog)
|
|
113
|
+
status = self.query_one("#status-bar", StatusBar)
|
|
114
|
+
inp = self.query_one("#answer-input", Input)
|
|
115
|
+
|
|
116
|
+
from receipts.ama.interviewer import (
|
|
117
|
+
InterviewRound,
|
|
118
|
+
assess_answer,
|
|
119
|
+
generate_follow_up,
|
|
120
|
+
generate_model_answer,
|
|
121
|
+
generate_question,
|
|
122
|
+
generate_summary,
|
|
123
|
+
select_bullets,
|
|
124
|
+
)
|
|
125
|
+
from receipts.config import load_settings
|
|
126
|
+
from receipts.llm.factory import get_provider
|
|
127
|
+
from receipts.llm.provider import CompletionResult
|
|
128
|
+
from receipts.resume.claim_extractor import extract_claims
|
|
129
|
+
from receipts.resume.loader import load_resume
|
|
130
|
+
from receipts.verify.kb import KnowledgeBase, default_kb_dir
|
|
131
|
+
from receipts.verify.verifier import (
|
|
132
|
+
group_claims_by_bullet,
|
|
133
|
+
verify_bullet_claims,
|
|
134
|
+
verify_claim,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# --- Setup ---
|
|
138
|
+
settings = load_settings()
|
|
139
|
+
try:
|
|
140
|
+
provider = get_provider(settings, verify=True)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
log.write(f"[red]Provider error: {exc}[/red]")
|
|
143
|
+
log.write(
|
|
144
|
+
"[red]Check your .env config and that your provider is running.[/red]"
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
status.provider_name = provider.name
|
|
149
|
+
log.write("[bold]Preparing interview...[/bold]")
|
|
150
|
+
log.write(f"Provider: [green]{provider.name}[/green]")
|
|
151
|
+
|
|
152
|
+
# Surface rate-limit waits in the status bar instead of hanging silently.
|
|
153
|
+
if hasattr(provider, "wait_callback"):
|
|
154
|
+
|
|
155
|
+
def _on_wait(reason: str, wait: int) -> None:
|
|
156
|
+
status.status_text = f"Rate-limited — waiting {wait}s..."
|
|
157
|
+
log.write(
|
|
158
|
+
f"[yellow]{reason} — waiting {wait}s before retrying...[/yellow]"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
provider.wait_callback = _on_wait
|
|
162
|
+
|
|
163
|
+
kb_dir = self.kb_dir or default_kb_dir(self.source_path)
|
|
164
|
+
kb = KnowledgeBase(kb_dir)
|
|
165
|
+
if kb.count() == 0:
|
|
166
|
+
log.write(
|
|
167
|
+
"[red]Knowledge base is empty. Run 'receipts ingest' first.[/red]"
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# --- Verify claims first ---
|
|
172
|
+
status.status_text = "Verifying claims..."
|
|
173
|
+
resume = load_resume(self.resume_path)
|
|
174
|
+
claims = extract_claims(resume, sections=self.sections, entries=self.entries)
|
|
175
|
+
if self.entries:
|
|
176
|
+
log.write(f"Scoped to entries: {', '.join(self.entries)} (+ skills)")
|
|
177
|
+
log.write(f"Verifying {len(claims)} claim(s)...")
|
|
178
|
+
|
|
179
|
+
use_batch = provider.name != "ollama"
|
|
180
|
+
groups = group_claims_by_bullet(claims) if use_batch else [[c] for c in claims]
|
|
181
|
+
|
|
182
|
+
results = []
|
|
183
|
+
errors = 0
|
|
184
|
+
done = 0
|
|
185
|
+
for group in groups:
|
|
186
|
+
done += len(group)
|
|
187
|
+
status.status_text = f"Verifying {done}/{len(claims)}..."
|
|
188
|
+
try:
|
|
189
|
+
if use_batch:
|
|
190
|
+
group_results = verify_bullet_claims(
|
|
191
|
+
group, kb, provider, distance_threshold=self.threshold
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
group_results = [
|
|
195
|
+
verify_claim(
|
|
196
|
+
group[0],
|
|
197
|
+
kb,
|
|
198
|
+
provider,
|
|
199
|
+
distance_threshold=self.threshold,
|
|
200
|
+
)
|
|
201
|
+
]
|
|
202
|
+
for r in group_results:
|
|
203
|
+
results.append(r)
|
|
204
|
+
if r.completion:
|
|
205
|
+
status.add_tokens(
|
|
206
|
+
r.completion.input_tokens, r.completion.output_tokens
|
|
207
|
+
)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
from receipts.errors import ProviderConfigError
|
|
210
|
+
|
|
211
|
+
if isinstance(exc, ProviderConfigError):
|
|
212
|
+
log.write(f"[red]Provider configuration error: {exc}[/red]")
|
|
213
|
+
log.write(
|
|
214
|
+
"[red]Aborting — fix your .env / environment"
|
|
215
|
+
" (run 'receipts check') and retry.[/red]"
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
errors += len(group)
|
|
219
|
+
log.write(
|
|
220
|
+
f"[red]Error verifying '{group[0].bullet_text[:60]}':"
|
|
221
|
+
f" {exc}[/red]"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
verified = sum(1 for r in results if r.verdict == "verified")
|
|
225
|
+
plausible = sum(1 for r in results if r.verdict == "plausible")
|
|
226
|
+
unsupported = sum(1 for r in results if r.verdict == "unsupported")
|
|
227
|
+
|
|
228
|
+
log.write(
|
|
229
|
+
f"[green]{verified} verified[/green], "
|
|
230
|
+
f"[yellow]{plausible} plausible[/yellow], "
|
|
231
|
+
f"[red]{unsupported} unsupported[/red]"
|
|
232
|
+
)
|
|
233
|
+
if errors:
|
|
234
|
+
log.write(f"[red]{errors} claim(s) failed due to API errors.[/red]")
|
|
235
|
+
log.write("")
|
|
236
|
+
|
|
237
|
+
# --- Select bullets ---
|
|
238
|
+
selected = select_bullets(results, self.n_rounds)
|
|
239
|
+
if not selected:
|
|
240
|
+
log.write("[yellow]No bullets to interview on.[/yellow]")
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
log.write(f"[bold]Starting mock interview — {len(selected)} round(s).[/bold]")
|
|
244
|
+
log.write("[dim]Type your answers below. Press Escape to quit early.[/dim]")
|
|
245
|
+
log.write("")
|
|
246
|
+
|
|
247
|
+
# --- Interview loop ---
|
|
248
|
+
interview_rounds: list[InterviewRound] = []
|
|
249
|
+
|
|
250
|
+
for i, result in enumerate(selected, 1):
|
|
251
|
+
status.status_text = f"Round {i}/{len(selected)}"
|
|
252
|
+
|
|
253
|
+
verdict_color = {
|
|
254
|
+
"verified": "green",
|
|
255
|
+
"plausible": "yellow",
|
|
256
|
+
"unsupported": "red",
|
|
257
|
+
}.get(result.verdict, "white")
|
|
258
|
+
|
|
259
|
+
log.write(f"[bold]━━━ Round {i}/{len(selected)} ━━━[/bold]")
|
|
260
|
+
log.write(
|
|
261
|
+
f"[{verdict_color}][{result.verdict}][/{verdict_color}] "
|
|
262
|
+
f"{result.claim.bullet_text[:120]}"
|
|
263
|
+
)
|
|
264
|
+
log.write("")
|
|
265
|
+
|
|
266
|
+
# --- Question ---
|
|
267
|
+
status.status_text = f"Round {i} — generating question..."
|
|
268
|
+
try:
|
|
269
|
+
question, q_comp = generate_question(result, provider, style=self.style)
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
log.write(f"[red]Error generating question: {exc}[/red]")
|
|
272
|
+
log.write("[dim]Skipping this round...[/dim]")
|
|
273
|
+
log.write("")
|
|
274
|
+
continue
|
|
275
|
+
status.add_tokens(q_comp.input_tokens, q_comp.output_tokens)
|
|
276
|
+
|
|
277
|
+
log.write(f"[bold cyan]Q:[/bold cyan] {question}")
|
|
278
|
+
log.write("")
|
|
279
|
+
|
|
280
|
+
# --- User answer ---
|
|
281
|
+
answer = self._ask_user(log, inp)
|
|
282
|
+
if answer.strip().lower() in ("quit", "q"):
|
|
283
|
+
log.write("[dim]Interview ended early.[/dim]")
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
# --- Follow-up ---
|
|
287
|
+
status.status_text = f"Round {i} — follow-up..."
|
|
288
|
+
try:
|
|
289
|
+
follow_up, fu_comp = generate_follow_up(question, answer, provider)
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
log.write(f"[red]Error generating follow-up: {exc}[/red]")
|
|
292
|
+
follow_up = "Could you elaborate on your answer?"
|
|
293
|
+
fu_comp = CompletionResult(
|
|
294
|
+
text="",
|
|
295
|
+
input_tokens=0,
|
|
296
|
+
output_tokens=0,
|
|
297
|
+
provider=provider.name,
|
|
298
|
+
model="fallback",
|
|
299
|
+
)
|
|
300
|
+
status.add_tokens(fu_comp.input_tokens, fu_comp.output_tokens)
|
|
301
|
+
|
|
302
|
+
log.write(f"[bold cyan]Follow-up:[/bold cyan] {follow_up}")
|
|
303
|
+
log.write("")
|
|
304
|
+
|
|
305
|
+
# --- Follow-up answer ---
|
|
306
|
+
fu_answer = self._ask_user(log, inp)
|
|
307
|
+
if fu_answer.strip().lower() in ("quit", "q"):
|
|
308
|
+
log.write("[dim]Interview ended early.[/dim]")
|
|
309
|
+
break
|
|
310
|
+
|
|
311
|
+
# --- Assessment ---
|
|
312
|
+
status.status_text = f"Round {i} — assessing..."
|
|
313
|
+
combined = f"{answer}\n\nFollow-up: {fu_answer}"
|
|
314
|
+
try:
|
|
315
|
+
held_up, assessment, a_comp = assess_answer(
|
|
316
|
+
result.claim.bullet_text, question, combined, provider
|
|
317
|
+
)
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
log.write(f"[red]Error assessing answer: {exc}[/red]")
|
|
320
|
+
held_up = False
|
|
321
|
+
assessment = "Assessment failed due to API error."
|
|
322
|
+
a_comp = CompletionResult(
|
|
323
|
+
text="",
|
|
324
|
+
input_tokens=0,
|
|
325
|
+
output_tokens=0,
|
|
326
|
+
provider=provider.name,
|
|
327
|
+
model="fallback",
|
|
328
|
+
)
|
|
329
|
+
status.add_tokens(a_comp.input_tokens, a_comp.output_tokens)
|
|
330
|
+
|
|
331
|
+
if held_up:
|
|
332
|
+
log.write(f"[bold green]Held up:[/bold green] {assessment}")
|
|
333
|
+
else:
|
|
334
|
+
log.write(f"[bold red]Did not hold up:[/bold red] {assessment}")
|
|
335
|
+
log.write("")
|
|
336
|
+
|
|
337
|
+
# --- Model answer (the reveal) ---
|
|
338
|
+
status.status_text = f"Round {i} — model answer..."
|
|
339
|
+
try:
|
|
340
|
+
model_answer, ma_comp = generate_model_answer(
|
|
341
|
+
result, question, provider
|
|
342
|
+
)
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
log.write(f"[red]Error generating model answer: {exc}[/red]")
|
|
345
|
+
model_answer = ""
|
|
346
|
+
ma_comp = CompletionResult(
|
|
347
|
+
text="",
|
|
348
|
+
input_tokens=0,
|
|
349
|
+
output_tokens=0,
|
|
350
|
+
provider=provider.name,
|
|
351
|
+
model="fallback",
|
|
352
|
+
)
|
|
353
|
+
status.add_tokens(ma_comp.input_tokens, ma_comp.output_tokens)
|
|
354
|
+
|
|
355
|
+
if model_answer:
|
|
356
|
+
log.write(
|
|
357
|
+
f"[bold blue]A strong, honest answer:[/bold blue] {model_answer}"
|
|
358
|
+
)
|
|
359
|
+
log.write("")
|
|
360
|
+
|
|
361
|
+
interview_rounds.append(
|
|
362
|
+
InterviewRound(
|
|
363
|
+
bullet_text=result.claim.bullet_text,
|
|
364
|
+
section=result.claim.section,
|
|
365
|
+
heading=result.claim.entry_heading,
|
|
366
|
+
verdict=result.verdict,
|
|
367
|
+
question=question,
|
|
368
|
+
answer=answer,
|
|
369
|
+
follow_up=follow_up,
|
|
370
|
+
follow_up_answer=fu_answer,
|
|
371
|
+
held_up=held_up,
|
|
372
|
+
assessment=assessment,
|
|
373
|
+
model_answer=model_answer,
|
|
374
|
+
completions=[q_comp, fu_comp, a_comp, ma_comp],
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if not interview_rounds:
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
# --- Summary ---
|
|
382
|
+
status.status_text = "Generating summary..."
|
|
383
|
+
log.write("[bold]━━━ Interview Summary ━━━[/bold]")
|
|
384
|
+
log.write("")
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
summary = generate_summary(interview_rounds, provider)
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
log.write(f"[red]Error generating summary: {exc}[/red]")
|
|
390
|
+
held = sum(1 for r in interview_rounds if r.held_up)
|
|
391
|
+
total = len(interview_rounds)
|
|
392
|
+
log.write(f"[bold]Score: {held}/{total} rounds held up.[/bold]")
|
|
393
|
+
status.status_text = "Interview complete (summary failed)"
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
if summary.completion:
|
|
397
|
+
status.add_tokens(
|
|
398
|
+
summary.completion.input_tokens,
|
|
399
|
+
summary.completion.output_tokens,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if summary.strong:
|
|
403
|
+
log.write("[bold green]Strengths:[/bold green]")
|
|
404
|
+
for pt in summary.strong:
|
|
405
|
+
log.write(f" [green]+[/green] {pt}")
|
|
406
|
+
log.write("")
|
|
407
|
+
|
|
408
|
+
if summary.weak:
|
|
409
|
+
log.write("[bold red]Weaknesses:[/bold red]")
|
|
410
|
+
for pt in summary.weak:
|
|
411
|
+
log.write(f" [red]-[/red] {pt}")
|
|
412
|
+
log.write("")
|
|
413
|
+
|
|
414
|
+
log.write(f"[bold]Overall:[/bold] {summary.overall}")
|
|
415
|
+
log.write("")
|
|
416
|
+
|
|
417
|
+
held = sum(1 for r in interview_rounds if r.held_up)
|
|
418
|
+
total = len(interview_rounds)
|
|
419
|
+
log.write(f"[bold]Score: {held}/{total} rounds held up.[/bold]")
|
|
420
|
+
|
|
421
|
+
# --- Persist for readiness tracking ---
|
|
422
|
+
if self.save_session:
|
|
423
|
+
try:
|
|
424
|
+
from receipts.prep.readiness import ReadinessStore, readiness_delta
|
|
425
|
+
|
|
426
|
+
store = ReadinessStore()
|
|
427
|
+
try:
|
|
428
|
+
store.save_session(
|
|
429
|
+
interview_rounds,
|
|
430
|
+
resume_name=resume.name,
|
|
431
|
+
source=str(self.source_path),
|
|
432
|
+
)
|
|
433
|
+
sessions = store.sessions(resume_name=resume.name)
|
|
434
|
+
finally:
|
|
435
|
+
store.close()
|
|
436
|
+
|
|
437
|
+
log.write("")
|
|
438
|
+
log.write(
|
|
439
|
+
"[dim]Session saved — run 'receipts readiness' to"
|
|
440
|
+
" track progress.[/dim]"
|
|
441
|
+
)
|
|
442
|
+
delta = readiness_delta(sessions)
|
|
443
|
+
if delta:
|
|
444
|
+
latest, previous = delta
|
|
445
|
+
change = (latest - previous) * 100
|
|
446
|
+
color = "green" if change >= 0 else "red"
|
|
447
|
+
log.write(
|
|
448
|
+
f"[{color}]Held-up rate: {latest:.0%}"
|
|
449
|
+
f" ({change:+.0f} pts vs previous session)[/{color}]"
|
|
450
|
+
)
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
log.write(f"[yellow]Could not save session: {exc}[/yellow]")
|
|
453
|
+
|
|
454
|
+
status.status_text = "Interview complete"
|