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,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"