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
receipts/cli.py ADDED
@@ -0,0 +1,2062 @@
1
+ """Typer entrypoint for the receipts CLI.
2
+
3
+ Subcommands arrive phase by phase; ``check`` works today and verifies your
4
+ provider configuration end to end without sending any content anywhere.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import typer
13
+
14
+ from receipts.errors import ReceiptsError
15
+
16
+ app = typer.Typer(
17
+ help="Receipts — resume claim verifier & interview prep, grounded in your code.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+
22
+ @app.command()
23
+ def check() -> None:
24
+ """Verify provider configuration and connectivity (sends no content)."""
25
+ from receipts.config import load_settings
26
+ from receipts.llm.factory import get_provider
27
+
28
+ settings = load_settings()
29
+ typer.echo(f"Configured provider: {settings.provider}")
30
+ try:
31
+ provider = get_provider(settings)
32
+ except ReceiptsError as exc:
33
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
34
+ raise typer.Exit(1) from exc
35
+
36
+ # Show the RESOLVED models — real environment variables silently beat
37
+ # .env, so this line is how a stale shell override gets spotted.
38
+ chat_model = getattr(provider, "chat_model", None)
39
+ embed_model = getattr(provider, "embed_model", None)
40
+ if chat_model:
41
+ typer.echo(f"Chat model: {chat_model}")
42
+ if embed_model:
43
+ embed_via = getattr(provider, "embed_provider_name", None)
44
+ suffix = f" (via {embed_via})" if embed_via else ""
45
+ typer.echo(f"Embed model: {embed_model}{suffix}")
46
+ typer.secho(f"OK - provider '{provider.name}' is ready.", fg=typer.colors.GREEN)
47
+
48
+
49
+ @app.command()
50
+ def setup() -> None:
51
+ """First-time setup wizard — pick a provider, store your key, validate.
52
+
53
+ Writes user-level config to ~/.receipts/.env so it works from any
54
+ folder. A project-local .env still overrides it when present.
55
+ """
56
+ from receipts.config import GLOBAL_CONFIG_PATH
57
+
58
+ typer.secho("Receipts setup", bold=True)
59
+ typer.echo("Config will be saved to: " + str(GLOBAL_CONFIG_PATH))
60
+ typer.echo("")
61
+ typer.echo("Pick your LLM provider:")
62
+ typer.echo(
63
+ " [1] ollama — fully private: nothing leaves your machine."
64
+ " Best with a GPU or a small model; slow on plain laptop CPUs"
65
+ )
66
+ typer.echo(
67
+ " [2] gemini — fastest on ordinary laptops, free API key from"
68
+ " https://aistudio.google.com/apikey (recommended for public code)"
69
+ )
70
+ typer.echo(" [3] anthropic — Claude, paid key (embeddings via a second provider)")
71
+ typer.echo(" [4] openai — OpenAI or any compatible host (Groq/OpenRouter)")
72
+
73
+ choice = typer.prompt("Provider [1-4]", default="1").strip()
74
+ provider = {"1": "ollama", "2": "gemini", "3": "anthropic", "4": "openai"}.get(
75
+ choice, choice.lower()
76
+ )
77
+ if provider not in ("ollama", "gemini", "anthropic", "openai"):
78
+ typer.secho(f"Unknown provider '{provider}'.", fg=typer.colors.RED, err=True)
79
+ raise typer.Exit(1)
80
+
81
+ values: dict[str, str] = {"RECEIPTS_PROVIDER": provider}
82
+
83
+ if provider == "ollama":
84
+ typer.echo("")
85
+ typer.echo("Ollama runs locally — no key needed. Make sure it's installed")
86
+ typer.echo("(https://ollama.com) and you've pulled models, e.g.:")
87
+ typer.echo(" ollama pull gemma3:4b && ollama pull nomic-embed-text")
88
+ values["OLLAMA_CHAT_MODEL"] = typer.prompt(
89
+ "Chat model", default="gemma3:4b"
90
+ ).strip()
91
+ host = typer.prompt(
92
+ "Ollama endpoint (Enter for the standard local install)",
93
+ default="http://localhost:11434",
94
+ ).strip()
95
+ if host and host != "http://localhost:11434":
96
+ values["OLLAMA_HOST"] = host
97
+ elif provider == "gemini":
98
+ typer.echo("")
99
+ typer.echo("Get a FREE key at https://aistudio.google.com/apikey")
100
+ values["GEMINI_API_KEY"] = typer.prompt(
101
+ "GEMINI_API_KEY", hide_input=True
102
+ ).strip()
103
+ elif provider == "anthropic":
104
+ typer.echo("")
105
+ typer.echo("Get a key at https://console.anthropic.com/ (paid).")
106
+ values["ANTHROPIC_API_KEY"] = typer.prompt(
107
+ "ANTHROPIC_API_KEY", hide_input=True
108
+ ).strip()
109
+ typer.echo("")
110
+ typer.echo("Claude has no embeddings API — pick an embed provider:")
111
+ embed = (
112
+ typer.prompt("Embed provider (ollama/gemini)", default="ollama")
113
+ .strip()
114
+ .lower()
115
+ )
116
+ values["RECEIPTS_EMBED_PROVIDER"] = embed
117
+ if embed == "gemini":
118
+ values["GEMINI_API_KEY"] = typer.prompt(
119
+ "GEMINI_API_KEY (free, https://aistudio.google.com/apikey)",
120
+ hide_input=True,
121
+ ).strip()
122
+ elif provider == "openai":
123
+ typer.echo("")
124
+ values["OPENAI_API_KEY"] = typer.prompt(
125
+ "OPENAI_API_KEY", hide_input=True
126
+ ).strip()
127
+ base = typer.prompt(
128
+ "OPENAI_BASE_URL (Enter for official OpenAI;"
129
+ " e.g. https://api.groq.com/openai/v1)",
130
+ default="",
131
+ ).strip()
132
+ if base:
133
+ values["OPENAI_BASE_URL"] = base
134
+ values["OPENAI_CHAT_MODEL"] = typer.prompt(
135
+ "Chat model id on that host", default="gpt-4o-mini"
136
+ ).strip()
137
+
138
+ _write_global_config(values)
139
+ typer.echo("")
140
+ typer.secho(f"Saved {GLOBAL_CONFIG_PATH}", fg=typer.colors.GREEN)
141
+
142
+ typer.echo("Validating...")
143
+ from receipts.config import load_settings
144
+ from receipts.llm.factory import get_provider
145
+
146
+ try:
147
+ provider_obj = get_provider(load_settings())
148
+ except ReceiptsError as exc:
149
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
150
+ typer.echo("Fix the issue and re-run 'receipts setup' or edit the file above.")
151
+ raise typer.Exit(1) from exc
152
+
153
+ typer.secho(
154
+ f"OK — provider '{provider_obj.name}' is ready."
155
+ " Next: receipts ingest <your-project-folder>",
156
+ fg=typer.colors.GREEN,
157
+ )
158
+
159
+
160
+ def _write_global_config(values: dict[str, str]) -> None:
161
+ """Create or update ~/.receipts/.env, preserving unrelated lines."""
162
+ from receipts.config import GLOBAL_CONFIG_PATH
163
+
164
+ GLOBAL_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
165
+ lines: list[str] = []
166
+ seen: set[str] = set()
167
+
168
+ if GLOBAL_CONFIG_PATH.is_file():
169
+ for line in GLOBAL_CONFIG_PATH.read_text(encoding="utf-8").splitlines():
170
+ key = line.split("=", 1)[0].strip() if "=" in line else ""
171
+ if key in values:
172
+ lines.append(f"{key}={values[key]}")
173
+ seen.add(key)
174
+ else:
175
+ lines.append(line)
176
+
177
+ for key, value in values.items():
178
+ if key not in seen:
179
+ lines.append(f"{key}={value}")
180
+
181
+ GLOBAL_CONFIG_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
182
+
183
+
184
+ def _resolve_kb_source(spec: str) -> tuple[Path, str]:
185
+ """Resolve a codebase spec to ``(kb_dir, display_label)``.
186
+
187
+ Accepts, in priority order: a git URL (durable URL-keyed KB), an existing
188
+ local folder (classic behavior), or a manifest alias recorded by a
189
+ previous ingest. Raises ReceiptsError with the known aliases otherwise.
190
+ """
191
+ from receipts.ingest.git_source import (
192
+ kb_dir_for_url,
193
+ looks_like_git_url,
194
+ repo_slug,
195
+ )
196
+ from receipts.ingest.manifest import known_aliases, lookup_source
197
+ from receipts.verify.kb import default_kb_dir
198
+
199
+ if looks_like_git_url(spec):
200
+ return kb_dir_for_url(spec), repo_slug(spec)
201
+
202
+ path = Path(spec)
203
+ if path.is_dir():
204
+ return default_kb_dir(path), path.resolve().name
205
+
206
+ entry = lookup_source(spec)
207
+ if entry is not None:
208
+ return Path(entry.kb_dir), entry.alias or spec
209
+
210
+ aliases = known_aliases()
211
+ hint = f"\n Known sources: {', '.join(aliases)}" if aliases else ""
212
+ raise ReceiptsError(
213
+ f"'{spec}' is not a folder, a git URL, or a known alias.{hint}\n"
214
+ " Ingest it first: receipts ingest <folder-or-url>"
215
+ )
216
+
217
+
218
+ def _resolve_jd(jd: str | None, jd_url: str | None) -> str | None:
219
+ """Turn --jd / --jd-url into a JD file path (fetching when needed)."""
220
+ if jd and jd_url:
221
+ raise ReceiptsError("Pass either --jd or --jd-url, not both.")
222
+ if not jd_url:
223
+ return jd
224
+
225
+ import hashlib
226
+
227
+ from receipts.verify.jd_fetch import fetch_jd_text
228
+
229
+ typer.echo(f"Fetching JD from {jd_url} ...")
230
+ text = fetch_jd_text(jd_url)
231
+ cache_dir = Path.home() / ".receipts" / "jd"
232
+ cache_dir.mkdir(parents=True, exist_ok=True)
233
+ name = hashlib.sha256(jd_url.encode()).hexdigest()[:8]
234
+ jd_path = cache_dir / f"jd_{name}.txt"
235
+ jd_path.write_text(text, encoding="utf-8")
236
+ typer.echo(f" {len(text):,} chars of text extracted → {jd_path}")
237
+ return str(jd_path)
238
+
239
+
240
+ @app.command()
241
+ def ingest(
242
+ source: str = typer.Argument(
243
+ ..., help="Local folder or git URL of the codebase to ingest."
244
+ ),
245
+ token_budget: int | None = typer.Option(
246
+ None,
247
+ "--budget",
248
+ "--token-budget",
249
+ help="Token cap for extracted artifacts."
250
+ " Default: auto-scaled to the corpus size (up to 1M tokens).",
251
+ ),
252
+ branch: str | None = typer.Option(
253
+ None,
254
+ "--branch",
255
+ help="Branch or tag to clone (git URLs only).",
256
+ ),
257
+ full_history: bool = typer.Option(
258
+ False,
259
+ "--full-history",
260
+ help="Clone the full git history instead of a shallow clone"
261
+ " (git URLs only; needed for commit-timespan/contributor evidence).",
262
+ ),
263
+ use_tui: bool = typer.Option(
264
+ False, "--tui", help="Use the full-screen TUI with a progress bar."
265
+ ),
266
+ ) -> None:
267
+ """Scan a codebase, report likely secrets, and extract key code artifacts."""
268
+ from receipts.ingest.git_source import (
269
+ canonical_git_url,
270
+ cloned_repo,
271
+ kb_dir_for_url,
272
+ looks_like_git_url,
273
+ repo_slug,
274
+ )
275
+ from receipts.ingest.manifest import record_source
276
+ from receipts.verify.kb import default_kb_dir
277
+
278
+ if use_tui:
279
+ root = Path(source)
280
+ if not root.is_dir():
281
+ typer.secho(f"Not a folder: {source}", fg=typer.colors.RED, err=True)
282
+ raise typer.Exit(1)
283
+ from receipts.tui.ingest_app import IngestApp
284
+
285
+ IngestApp(root, token_budget=token_budget).run()
286
+ return
287
+
288
+ try:
289
+ if looks_like_git_url(source):
290
+ kb_dir = kb_dir_for_url(source)
291
+ alias = repo_slug(source)
292
+ typer.echo(f"Cloning {source} ...")
293
+ with cloned_repo(source, branch=branch, full_history=full_history) as root:
294
+ _run_ingest(root, token_budget, kb_dir=kb_dir, source_label=alias)
295
+ record_source(canonical_git_url(source), kb_dir, alias=alias, kind="remote")
296
+ typer.secho(
297
+ f"Registered alias '{alias}' — use it (or the URL) anywhere a"
298
+ " codebase folder is accepted.",
299
+ fg=typer.colors.GREEN,
300
+ )
301
+ else:
302
+ root = Path(source)
303
+ if not root.is_dir():
304
+ typer.secho(f"Not a folder: {source}", fg=typer.colors.RED, err=True)
305
+ raise typer.Exit(1)
306
+ _run_ingest(root, token_budget)
307
+ record_source(
308
+ str(root.resolve()),
309
+ default_kb_dir(root),
310
+ alias=root.resolve().name,
311
+ kind="local",
312
+ )
313
+ except ReceiptsError as exc:
314
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
315
+ raise typer.Exit(1) from exc
316
+
317
+
318
+ def _run_ingest(
319
+ root: Path,
320
+ token_budget: int | None,
321
+ *,
322
+ kb_dir: Path | None = None,
323
+ source_label: str | None = None,
324
+ ) -> None:
325
+ from receipts.ingest import artifact_extractor, scanner, secrets_scanner
326
+
327
+ result = scanner.scan(root)
328
+ typer.echo(result.render_tree())
329
+ if result.skipped_dirs:
330
+ typer.echo(f"Skipped dependency dirs: {', '.join(result.skipped_dirs)}")
331
+ if result.ignored_count:
332
+ typer.echo(f"{result.ignored_count} entries excluded by .gitignore.")
333
+
334
+ report = secrets_scanner.SecretsReport(
335
+ findings=secrets_scanner.flag_special_paths(
336
+ [f.rel_path for f in result.files], result.venv_dirs
337
+ )
338
+ )
339
+ for file in result.text_files:
340
+ report.findings.extend(
341
+ secrets_scanner.scan_text(
342
+ file.rel_path, scanner.read_text(root, file.rel_path)
343
+ )
344
+ )
345
+ typer.echo("")
346
+ color = typer.colors.RED if report.has_high_severity else None
347
+ typer.secho(report.render(), fg=color)
348
+
349
+ extraction = artifact_extractor.extract_artifacts(
350
+ root, result.files, token_budget=token_budget
351
+ )
352
+ typer.echo("")
353
+ budget_label = (
354
+ f"auto {extraction.budget:,}"
355
+ if extraction.auto_budget
356
+ else f"{extraction.budget:,}"
357
+ )
358
+ typer.echo(
359
+ f"Extracted {len(extraction.artifacts)} artifact(s), "
360
+ f"~{extraction.used_tokens:,} tokens (budget {budget_label})."
361
+ )
362
+ if extraction.dropped_over_budget:
363
+ by_dir = ", ".join(
364
+ f"{d}/ {n}"
365
+ for d, n in sorted(extraction.dropped_by_dir.items(), key=lambda kv: -kv[1])
366
+ )
367
+ typer.secho(
368
+ f" {extraction.dropped_over_budget} artifact(s)"
369
+ f" (~{extraction.dropped_tokens:,} tokens) dropped over budget:"
370
+ f" {by_dir}.",
371
+ fg=typer.colors.YELLOW,
372
+ )
373
+ typer.secho(
374
+ f" Corpus is ~{extraction.corpus_tokens:,} tokens — raise --budget"
375
+ " to include everything.",
376
+ fg=typer.colors.YELLOW,
377
+ )
378
+ if extraction.redaction_count:
379
+ typer.echo(
380
+ f" {extraction.redaction_count} likely secret(s) redacted from"
381
+ " artifact content."
382
+ )
383
+
384
+ _store_in_kb(root, extraction.artifacts, kb_dir=kb_dir, source_label=source_label)
385
+
386
+
387
+ def _store_in_kb(
388
+ root: Path,
389
+ artifacts: list,
390
+ *,
391
+ kb_dir: Path | None = None,
392
+ source_label: str | None = None,
393
+ ) -> None:
394
+ """Embed artifacts and store in the knowledge base.
395
+
396
+ ``kb_dir``/``source_label`` override the path-derived defaults — used by
397
+ remote ingests so the KB is keyed by the URL, not the temp clone dir.
398
+ """
399
+ if not artifacts:
400
+ typer.echo("No artifacts to store in the knowledge base.")
401
+ return
402
+
403
+ from receipts.config import load_settings
404
+ from receipts.llm.factory import get_provider
405
+ from receipts.verify.kb import KnowledgeBase, default_kb_dir
406
+
407
+ settings = load_settings()
408
+ try:
409
+ provider = get_provider(settings, verify=True)
410
+ except ReceiptsError as exc:
411
+ typer.secho(
412
+ f"Skipping knowledge-base storage: {exc}",
413
+ fg=typer.colors.YELLOW,
414
+ )
415
+ typer.echo(
416
+ "Run 'receipts check' to diagnose, then re-run ingest to populate the KB."
417
+ )
418
+ return
419
+
420
+ import time
421
+
422
+ kb_dir = kb_dir or default_kb_dir(root)
423
+ kb = KnowledgeBase(kb_dir)
424
+ source_label = source_label or root.resolve().name
425
+
426
+ # --- Phase 22C: corpus-level artifacts (zero LLM, always on) ---
427
+ from receipts.verify.summaries import (
428
+ build_facts_artifact,
429
+ build_hierarchical_summaries,
430
+ build_import_map_artifact,
431
+ summaries_enabled,
432
+ )
433
+
434
+ artifacts = list(artifacts)
435
+ facts_art = build_facts_artifact(root)
436
+ if facts_art is not None:
437
+ artifacts.append(facts_art)
438
+ import_art = build_import_map_artifact(artifacts)
439
+ if import_art is not None:
440
+ artifacts.append(import_art)
441
+
442
+ # --- Phase 22C: hierarchical summaries (LLM, opt-in, cached) ---
443
+ if summaries_enabled():
444
+ typer.echo("")
445
+ typer.echo("Building hierarchical summaries (RECEIPTS_SUMMARIES=1) ...")
446
+
447
+ def _sum_progress(done: int, total: int) -> None:
448
+ if done % 25 == 0 or done == total:
449
+ typer.echo(f" summarized {done}/{total} file(s)")
450
+
451
+ summary_arts = build_hierarchical_summaries(
452
+ artifacts,
453
+ provider,
454
+ cache_path=kb_dir / "summary_cache.json",
455
+ on_progress=_sum_progress,
456
+ )
457
+ typer.echo(f" {len(summary_arts)} summary artifact(s) added")
458
+ artifacts.extend(summary_arts)
459
+
460
+ # --- Phase 22B: contextual chunk enrichment (LLM, opt-in, cached) ---
461
+ from receipts.verify.enrich import contextualize_artifacts, enrich_enabled
462
+
463
+ if enrich_enabled():
464
+ typer.echo("")
465
+ typer.echo("Enriching chunks with context lines (RECEIPTS_ENRICH=1) ...")
466
+
467
+ def _enrich_progress(done: int, total: int) -> None:
468
+ if done % 25 == 0 or done == total:
469
+ typer.echo(f" contextualized {done}/{total}")
470
+
471
+ artifacts, new_lines = contextualize_artifacts(
472
+ artifacts,
473
+ provider,
474
+ cache_path=kb_dir / "enrich_cache.json",
475
+ on_progress=_enrich_progress,
476
+ )
477
+ typer.echo(
478
+ f" {new_lines} context line(s) generated,"
479
+ f" {len(artifacts) - new_lines} from cache/skipped"
480
+ )
481
+
482
+ typer.echo("")
483
+ typer.echo(
484
+ f"Syncing {len(artifacts)} artifact(s) into the knowledge base"
485
+ f" via '{provider.name}' ..."
486
+ )
487
+ if provider.name == "gemini":
488
+ typer.echo(
489
+ " (free-tier quota pacing may pause between batches — this can"
490
+ " take a few minutes for large codebases)"
491
+ )
492
+
493
+ start_time = time.monotonic()
494
+
495
+ def _progress(done: int, total: int) -> None:
496
+ elapsed = time.monotonic() - start_time
497
+ if 0 < done < total:
498
+ eta = elapsed / done * (total - done)
499
+ typer.echo(
500
+ f" embedded {done}/{total} — ETA {int(eta // 60)}:{int(eta % 60):02d}"
501
+ )
502
+ else:
503
+ typer.echo(f" embedded {done}/{total}")
504
+
505
+ stats = kb.sync_artifacts(
506
+ artifacts, provider, source=source_label, on_progress=_progress
507
+ )
508
+ typer.secho(
509
+ f"Knowledge base: {stats.added} new, {stats.changed} changed,"
510
+ f" {stats.unchanged} unchanged (embedding skipped),"
511
+ f" {stats.removed} stale removed — {kb.count()} artifact(s) in {kb_dir}",
512
+ fg=typer.colors.GREEN,
513
+ )
514
+
515
+
516
+ def _resolve_entries(
517
+ resume_path: Path,
518
+ sections: list[str] | None,
519
+ entries: list[str] | None,
520
+ scope_all: bool,
521
+ ) -> list[str] | None:
522
+ """Decide which resume entries to verify against the ingested codebase.
523
+
524
+ Explicit filters (--section / --entry / --all) win. Otherwise, in an
525
+ interactive terminal, list the project & experience entries and let the
526
+ user pick the one(s) that match the ingested codebase — verifying an
527
+ unrelated project's claims wastes tokens and produces misleading
528
+ "unsupported" noise. Skills sections are always kept for JD matching.
529
+ """
530
+ if entries or sections or scope_all:
531
+ return entries
532
+ if not sys.stdin.isatty():
533
+ return None
534
+
535
+ from receipts.resume.claim_extractor import classify_section
536
+ from receipts.resume.loader import load_resume
537
+
538
+ resume = load_resume(resume_path)
539
+ candidates: list[tuple[str, str]] = [] # (section, heading)
540
+ for sec in resume.sections:
541
+ if classify_section(sec.name) not in ("project", "experience"):
542
+ continue
543
+ for entry in sec.entries:
544
+ if entry.heading:
545
+ candidates.append((sec.name, entry.heading))
546
+
547
+ if not candidates:
548
+ return None
549
+
550
+ typer.echo("")
551
+ typer.secho("Which entries match the codebase you ingested?", bold=True)
552
+ typer.echo(
553
+ " (skills sections are always included for JD matching;"
554
+ " education/certs are skipped)"
555
+ )
556
+ typer.echo("")
557
+ for i, (sec_name, heading) in enumerate(candidates, 1):
558
+ typer.echo(f" [{i}] {heading} ({sec_name})")
559
+ typer.echo(" [a] everything — the whole resume (uses the most tokens)")
560
+ typer.echo("")
561
+
562
+ raw = typer.prompt("Pick numbers (comma-separated) or 'a'", default="a")
563
+ raw = raw.strip().lower()
564
+ if raw in ("a", "all", ""):
565
+ return None
566
+
567
+ chosen: list[str] = []
568
+ for part in raw.split(","):
569
+ part = part.strip()
570
+ if part.isdigit() and 1 <= int(part) <= len(candidates):
571
+ chosen.append(candidates[int(part) - 1][1])
572
+ if not chosen:
573
+ typer.secho(
574
+ " No valid selection — verifying everything.", fg=typer.colors.YELLOW
575
+ )
576
+ return None
577
+
578
+ typer.echo("")
579
+ typer.echo(f" Scoped to: {', '.join(chosen)} (+ skills sections)")
580
+ return chosen
581
+
582
+
583
+ @app.command()
584
+ def verify(
585
+ resume: str = typer.Argument(
586
+ ..., help="Path to the resume: .tex, text-based .pdf, or .txt."
587
+ ),
588
+ source: str | None = typer.Argument(
589
+ None,
590
+ help="Codebase to verify against: a local folder, a git URL, or a"
591
+ " manifest alias (must have been ingested). Optional when --map"
592
+ " is used.",
593
+ ),
594
+ rewrite: bool = typer.Option(
595
+ False, "--rewrite", help="Propose rewrites for weak bullets."
596
+ ),
597
+ jd: str | None = typer.Option(
598
+ None, "--jd", help="Path to a job description text file for gap analysis."
599
+ ),
600
+ jd_url: str | None = typer.Option(
601
+ None,
602
+ "--jd-url",
603
+ help="Fetch the job description from a URL instead of a file.",
604
+ ),
605
+ threshold: float = typer.Option(
606
+ 0.5, "--threshold", help="Cosine distance threshold for evidence relevance."
607
+ ),
608
+ section: list[str] | None = typer.Option(
609
+ None,
610
+ "--section",
611
+ help="Only verify claims from these resume sections (substring match, repeatable).",
612
+ ),
613
+ entry: list[str] | None = typer.Option(
614
+ None,
615
+ "--entry",
616
+ help="Only verify claims from entries whose heading matches (substring, repeatable). Skills sections stay included.",
617
+ ),
618
+ scope_all: bool = typer.Option(
619
+ False,
620
+ "--all",
621
+ help="Verify the whole resume without the entry picker.",
622
+ ),
623
+ kb_map: list[str] | None = typer.Option(
624
+ None,
625
+ "--map",
626
+ help="Map a resume entry to its own ingested codebase (folder, git"
627
+ ' URL, or alias), repeatable: --map "one more light=./oml"'
628
+ ' --map "x-iq=owner/repo". Skills claims check across ALL mapped'
629
+ " codebases.",
630
+ ),
631
+ ) -> None:
632
+ """Verify resume claims against the ingested knowledge base."""
633
+ try:
634
+ kb_maps = None
635
+ if kb_map:
636
+ from receipts.verify.router import parse_map_options
637
+
638
+ kb_maps = parse_map_options(kb_map, resolve=_resolve_kb_source)
639
+
640
+ if kb_maps and not entry and not scope_all:
641
+ # Scope to the mapped entries (+ skills) — the picker would be
642
+ # redundant when the user already said which entries matter.
643
+ entries = [m.entry_filter for m in kb_maps]
644
+ else:
645
+ entries = _resolve_entries(Path(resume), section, entry, scope_all)
646
+
647
+ if kb_maps is None and source is None:
648
+ typer.secho(
649
+ "Provide a codebase folder, or map entries to codebases"
650
+ ' with --map "entry=./path".',
651
+ fg=typer.colors.RED,
652
+ err=True,
653
+ )
654
+ raise typer.Exit(1)
655
+
656
+ _run_verify(
657
+ Path(resume),
658
+ source,
659
+ rewrite,
660
+ _resolve_jd(jd, jd_url),
661
+ threshold,
662
+ section,
663
+ entries,
664
+ kb_maps=kb_maps,
665
+ )
666
+ except ReceiptsError as exc:
667
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
668
+ raise typer.Exit(1) from exc
669
+
670
+
671
+ def _run_verify(
672
+ resume_path: Path,
673
+ source_spec: str | Path | None,
674
+ do_rewrite: bool,
675
+ jd_path: str | None,
676
+ threshold: float,
677
+ sections: list[str] | None = None,
678
+ entries: list[str] | None = None,
679
+ *,
680
+ kb_maps: list | None = None,
681
+ ) -> None:
682
+ from receipts.config import load_settings
683
+ from receipts.llm.factory import get_provider
684
+ from receipts.resume.claim_extractor import extract_claims
685
+ from receipts.resume.loader import load_resume
686
+ from receipts.verify.kb import KnowledgeBase
687
+ from receipts.verify.verifier import verify_claims
688
+
689
+ if not resume_path.is_file():
690
+ typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
691
+ raise typer.Exit(1)
692
+
693
+ router = None
694
+ kb = None
695
+ if kb_maps:
696
+ from receipts.verify.router import KBRouter
697
+
698
+ for m in kb_maps:
699
+ if m.kb_dir is None and not m.source_path.is_dir():
700
+ typer.secho(
701
+ f"Mapped folder not found: {m.source_path}",
702
+ fg=typer.colors.RED,
703
+ err=True,
704
+ )
705
+ raise typer.Exit(1)
706
+
707
+ router = KBRouter(kb_maps)
708
+ empty = router.empty_sources()
709
+ if empty:
710
+ for label in empty:
711
+ typer.secho(
712
+ f"No knowledge base for {label} — run"
713
+ f" 'receipts ingest {label}' first.",
714
+ fg=typer.colors.RED,
715
+ err=True,
716
+ )
717
+ raise typer.Exit(1)
718
+
719
+ typer.echo("Entry → codebase mapping:")
720
+ for m in kb_maps:
721
+ typer.echo(f' "{m.entry_filter}" → {m.display_label}')
722
+ typer.echo(" (skills claims check across all mapped codebases)")
723
+ else:
724
+ if source_spec is None:
725
+ typer.secho(
726
+ "Provide a codebase (folder, git URL, or alias) or use --map.",
727
+ fg=typer.colors.RED,
728
+ err=True,
729
+ )
730
+ raise typer.Exit(1)
731
+
732
+ kb_dir, source_label = _resolve_kb_source(str(source_spec))
733
+ kb = KnowledgeBase(kb_dir)
734
+ if kb.count() == 0:
735
+ typer.secho(
736
+ f"Knowledge base for '{source_label}' is empty."
737
+ " Run 'receipts ingest' first.",
738
+ fg=typer.colors.RED,
739
+ err=True,
740
+ )
741
+ raise typer.Exit(1)
742
+
743
+ settings = load_settings()
744
+ provider = get_provider(settings, verify=True)
745
+
746
+ typer.echo(f"Parsing {resume_path.name} ...")
747
+ resume = load_resume(resume_path)
748
+ typer.echo(f" Name: {resume.name}")
749
+ typer.echo(f" Sections: {len(resume.sections)}")
750
+
751
+ claims = extract_claims(resume, sections=sections, entries=entries)
752
+ if sections:
753
+ typer.echo(f" Filtering to sections matching: {', '.join(sections)}")
754
+ if entries:
755
+ typer.echo(f" Filtering to entries matching: {', '.join(entries)}")
756
+ typer.echo(f" Claims extracted: {len(claims)}")
757
+ typer.echo("")
758
+
759
+ use_batch = provider.name != "ollama"
760
+ typer.echo(f"Verifying {len(claims)} claim(s) against the knowledge base ...")
761
+ if use_batch:
762
+ typer.echo(" (claims from the same bullet share one LLM call)")
763
+
764
+ if router is not None:
765
+ from receipts.verify.router import verify_claims_routed
766
+
767
+ results = verify_claims_routed(
768
+ claims,
769
+ router,
770
+ provider,
771
+ distance_threshold=threshold,
772
+ batch=use_batch,
773
+ )
774
+ else:
775
+ results = verify_claims(
776
+ claims, kb, provider, distance_threshold=threshold, batch=use_batch
777
+ )
778
+
779
+ _print_verification_report(results)
780
+
781
+ if do_rewrite:
782
+ _run_rewrite(results, provider)
783
+
784
+ if jd_path:
785
+ gap_kb = router.multi_kb() if router is not None else kb
786
+ _run_keyword_gap(Path(jd_path), resume, gap_kb, provider, threshold)
787
+
788
+
789
+ def _print_verification_report(results: list) -> None:
790
+ from receipts.verify.verifier import VerificationResult
791
+
792
+ by_verdict: dict[str, list[VerificationResult]] = {}
793
+ for r in results:
794
+ by_verdict.setdefault(r.verdict, []).append(r)
795
+
796
+ verified = by_verdict.get("verified", [])
797
+ plausible = by_verdict.get("plausible", [])
798
+ unsupported = by_verdict.get("unsupported", [])
799
+
800
+ typer.echo("")
801
+ typer.secho(
802
+ f"Results: {len(verified)} verified, {len(plausible)} plausible, "
803
+ f"{len(unsupported)} unsupported",
804
+ bold=True,
805
+ )
806
+
807
+ if unsupported:
808
+ typer.echo("")
809
+ typer.secho("UNSUPPORTED CLAIMS:", fg=typer.colors.RED, bold=True)
810
+ seen: set[str] = set()
811
+ for r in unsupported:
812
+ key = f"{r.claim.value}|{r.claim.bullet_text}"
813
+ if key in seen:
814
+ continue
815
+ seen.add(key)
816
+ typer.echo(f" [{r.claim.claim_type}] {r.claim.value}")
817
+ typer.echo(f" bullet: {r.claim.bullet_text[:80]}...")
818
+ typer.echo(f" reason: {r.explanation}")
819
+
820
+ if plausible:
821
+ typer.echo("")
822
+ typer.secho("PLAUSIBLE CLAIMS:", fg=typer.colors.YELLOW, bold=True)
823
+ seen_p: set[str] = set()
824
+ for r in plausible:
825
+ key = f"{r.claim.value}|{r.claim.bullet_text}"
826
+ if key in seen_p:
827
+ continue
828
+ seen_p.add(key)
829
+ typer.echo(f" [{r.claim.claim_type}] {r.claim.value}")
830
+ typer.echo(f" reason: {r.explanation}")
831
+
832
+
833
+ def _run_rewrite(results: list, provider) -> None:
834
+ from receipts.verify.rewriter import rewrite_weak_bullets
835
+
836
+ typer.echo("")
837
+ typer.secho("REWRITES:", bold=True)
838
+ rewrites = rewrite_weak_bullets(results, provider)
839
+ if not rewrites:
840
+ typer.echo(" No rewrites needed — all bullets are backed by code.")
841
+ return
842
+
843
+ for rw in rewrites:
844
+ typer.echo("")
845
+ typer.echo(f" ORIGINAL: {rw.original[:100]}")
846
+ typer.secho(f" REWRITTEN: {rw.rewritten[:100]}", fg=typer.colors.GREEN)
847
+ typer.echo(f" why: {rw.explanation}")
848
+
849
+
850
+ def _run_keyword_gap(jd_path: Path, resume, kb, provider, threshold: float) -> None:
851
+ from receipts.verify.keyword_gap import analyze_gap
852
+
853
+ if not jd_path.is_file():
854
+ typer.secho(f"JD file not found: {jd_path}", fg=typer.colors.RED, err=True)
855
+ return
856
+
857
+ jd_text = jd_path.read_text(encoding="utf-8")
858
+ typer.echo("")
859
+ typer.secho("KEYWORD GAP ANALYSIS:", bold=True)
860
+
861
+ report = analyze_gap(jd_text, resume, kb, provider, distance_threshold=threshold)
862
+
863
+ if report.addable:
864
+ typer.echo("")
865
+ typer.secho(" Can add (code supports it):", fg=typer.colors.GREEN)
866
+ for item in report.addable:
867
+ typer.echo(f" + {item.keyword} — {item.suggestion}")
868
+
869
+ if report.ungrounded:
870
+ typer.echo("")
871
+ typer.secho(" Ungrounded (resume claims, no code):", fg=typer.colors.YELLOW)
872
+ for item in report.ungrounded:
873
+ typer.echo(f" ? {item.keyword} — {item.suggestion}")
874
+
875
+ if report.gaps:
876
+ typer.echo("")
877
+ typer.echo(" Gaps (JD wants, you don't have):")
878
+ for item in report.gaps:
879
+ typer.echo(f" - {item.keyword}")
880
+
881
+
882
+ @app.command()
883
+ def tui(
884
+ resume: str = typer.Argument(
885
+ ..., help="Path to the resume: .tex, text-based .pdf, or .txt."
886
+ ),
887
+ source: str = typer.Argument(
888
+ ...,
889
+ help="Codebase: local folder, git URL, or manifest alias"
890
+ " (must have been ingested).",
891
+ ),
892
+ threshold: float = typer.Option(
893
+ 0.5, "--threshold", help="Cosine distance threshold for evidence relevance."
894
+ ),
895
+ section: list[str] | None = typer.Option(
896
+ None,
897
+ "--section",
898
+ help="Only verify claims from these resume sections (substring match, repeatable).",
899
+ ),
900
+ entry: list[str] | None = typer.Option(
901
+ None,
902
+ "--entry",
903
+ help="Only verify claims from entries whose heading matches (substring, repeatable). Skills sections stay included.",
904
+ ),
905
+ scope_all: bool = typer.Option(
906
+ False,
907
+ "--all",
908
+ help="Verify the whole resume without the entry picker.",
909
+ ),
910
+ ) -> None:
911
+ """Launch the interactive TUI for resume verification."""
912
+ resume_path = Path(resume)
913
+ if not resume_path.is_file():
914
+ typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
915
+ raise typer.Exit(1)
916
+ try:
917
+ kb_dir, _ = _resolve_kb_source(source)
918
+ except ReceiptsError as exc:
919
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
920
+ raise typer.Exit(1) from exc
921
+
922
+ entries = _resolve_entries(resume_path, section, entry, scope_all)
923
+
924
+ from receipts.tui.app import ReceiptsApp
925
+
926
+ tui_app = ReceiptsApp(
927
+ resume_path,
928
+ Path(source),
929
+ threshold=threshold,
930
+ sections=section,
931
+ entries=entries,
932
+ kb_dir=kb_dir,
933
+ )
934
+ tui_app.run()
935
+
936
+
937
+ @app.command()
938
+ def ama(
939
+ resume: str = typer.Argument(
940
+ ..., help="Path to the resume: .tex, text-based .pdf, or .txt."
941
+ ),
942
+ source: str = typer.Argument(
943
+ ..., help="Local folder of the codebase (must have been ingested)."
944
+ ),
945
+ rounds: int = typer.Option(5, "--rounds", help="Number of interview rounds."),
946
+ threshold: float = typer.Option(
947
+ 0.5,
948
+ "--threshold",
949
+ help="Cosine distance threshold for evidence relevance.",
950
+ ),
951
+ section: list[str] | None = typer.Option(
952
+ None,
953
+ "--section",
954
+ help="Only verify claims from these resume sections (substring match, repeatable).",
955
+ ),
956
+ entry: list[str] | None = typer.Option(
957
+ None,
958
+ "--entry",
959
+ help="Only quiz on entries whose heading matches (substring, repeatable). Skills sections stay included.",
960
+ ),
961
+ scope_all: bool = typer.Option(
962
+ False,
963
+ "--all",
964
+ help="Quiz on the whole resume without the entry picker.",
965
+ ),
966
+ style: str | None = typer.Option(
967
+ None,
968
+ "--style",
969
+ help="Interview round preset: service | product | startup.",
970
+ ),
971
+ save: bool = typer.Option(
972
+ True,
973
+ "--save/--no-save",
974
+ help="Persist this session for readiness tracking (default on).",
975
+ ),
976
+ use_tui: bool = typer.Option(
977
+ False,
978
+ "--tui",
979
+ help="Use the interactive TUI chat interface instead of plain text.",
980
+ ),
981
+ ) -> None:
982
+ """Mock-interview mode — quiz yourself on your resume claims."""
983
+ if style:
984
+ from receipts.ama.interviewer import INTERVIEW_STYLES
985
+
986
+ if style.strip().lower() not in INTERVIEW_STYLES:
987
+ typer.secho(
988
+ f"Unknown style '{style}'." f" Valid: {', '.join(INTERVIEW_STYLES)}.",
989
+ fg=typer.colors.RED,
990
+ err=True,
991
+ )
992
+ raise typer.Exit(1)
993
+ style = style.strip().lower()
994
+
995
+ entries = _resolve_entries(Path(resume), section, entry, scope_all)
996
+ if use_tui:
997
+ resume_path = Path(resume)
998
+ if not resume_path.is_file():
999
+ typer.secho(
1000
+ f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True
1001
+ )
1002
+ raise typer.Exit(1)
1003
+ try:
1004
+ kb_dir, _ = _resolve_kb_source(source)
1005
+ except ReceiptsError as exc:
1006
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
1007
+ raise typer.Exit(1) from exc
1008
+ from receipts.tui.ama_app import AmaApp
1009
+
1010
+ ama_tui = AmaApp(
1011
+ resume_path,
1012
+ Path(source),
1013
+ n_rounds=rounds,
1014
+ threshold=threshold,
1015
+ sections=section,
1016
+ entries=entries,
1017
+ style=style,
1018
+ save_session=save,
1019
+ kb_dir=kb_dir,
1020
+ )
1021
+ ama_tui.run()
1022
+ return
1023
+ try:
1024
+ _run_ama(
1025
+ Path(resume),
1026
+ source,
1027
+ rounds,
1028
+ threshold,
1029
+ section,
1030
+ entries,
1031
+ style=style,
1032
+ save=save,
1033
+ )
1034
+ except ReceiptsError as exc:
1035
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
1036
+ raise typer.Exit(1) from exc
1037
+
1038
+
1039
+ def _save_ama_session(rounds: list, resume_name: str, source: str) -> None:
1040
+ """Persist an AMA session and print the trend vs the previous one."""
1041
+ from receipts.prep.readiness import ReadinessStore, readiness_delta
1042
+
1043
+ store = ReadinessStore()
1044
+ try:
1045
+ store.save_session(rounds, resume_name=resume_name, source=source)
1046
+ sessions = store.sessions(resume_name=resume_name)
1047
+ finally:
1048
+ store.close()
1049
+
1050
+ typer.echo("")
1051
+ typer.echo("Session saved — run 'receipts readiness' to track progress.")
1052
+ delta = readiness_delta(sessions)
1053
+ if delta:
1054
+ latest, previous = delta
1055
+ change = (latest - previous) * 100
1056
+ color = typer.colors.GREEN if change >= 0 else typer.colors.RED
1057
+ typer.secho(
1058
+ f"Held-up rate: {latest:.0%} ({change:+.0f} pts vs previous session)",
1059
+ fg=color,
1060
+ )
1061
+
1062
+
1063
+ def _run_ama(
1064
+ resume_path: Path,
1065
+ source_spec: str | Path,
1066
+ n_rounds: int,
1067
+ threshold: float,
1068
+ sections: list[str] | None = None,
1069
+ entries: list[str] | None = None,
1070
+ *,
1071
+ style: str | None = None,
1072
+ save: bool = True,
1073
+ ) -> None:
1074
+ from receipts.ama.interviewer import (
1075
+ InterviewRound,
1076
+ assess_answer,
1077
+ generate_follow_up,
1078
+ generate_model_answer,
1079
+ generate_question,
1080
+ generate_summary,
1081
+ select_bullets,
1082
+ )
1083
+ from receipts.config import load_settings
1084
+ from receipts.llm.factory import get_provider
1085
+ from receipts.resume.claim_extractor import extract_claims
1086
+ from receipts.resume.loader import load_resume
1087
+ from receipts.verify.kb import KnowledgeBase
1088
+ from receipts.verify.verifier import verify_claims
1089
+
1090
+ if not resume_path.is_file():
1091
+ typer.secho(
1092
+ f"Resume not found: {resume_path}",
1093
+ fg=typer.colors.RED,
1094
+ err=True,
1095
+ )
1096
+ raise typer.Exit(1)
1097
+
1098
+ kb_dir, source_label = _resolve_kb_source(str(source_spec))
1099
+ kb = KnowledgeBase(kb_dir)
1100
+ if kb.count() == 0:
1101
+ typer.secho(
1102
+ f"Knowledge base for '{source_label}' is empty."
1103
+ " Run 'receipts ingest' first.",
1104
+ fg=typer.colors.RED,
1105
+ err=True,
1106
+ )
1107
+ raise typer.Exit(1)
1108
+
1109
+ settings = load_settings()
1110
+ provider = get_provider(settings, verify=True)
1111
+
1112
+ typer.echo(f"Parsing {resume_path.name} ...")
1113
+ resume = load_resume(resume_path)
1114
+ claims = extract_claims(resume, sections=sections, entries=entries)
1115
+
1116
+ typer.echo(f"Verifying {len(claims)} claim(s) ...")
1117
+ results = verify_claims(
1118
+ claims,
1119
+ kb,
1120
+ provider,
1121
+ distance_threshold=threshold,
1122
+ batch=provider.name != "ollama",
1123
+ )
1124
+
1125
+ selected = select_bullets(results, n_rounds)
1126
+ if not selected:
1127
+ typer.echo("No bullets to interview on.")
1128
+ raise typer.Exit(0)
1129
+
1130
+ typer.echo("")
1131
+ typer.secho(
1132
+ f"Starting mock interview — {len(selected)} round(s).",
1133
+ bold=True,
1134
+ )
1135
+ typer.echo("Answer each question as you would in a real interview.")
1136
+ typer.echo("Type 'quit' at any prompt to end early.")
1137
+ typer.echo("")
1138
+
1139
+ interview_rounds: list[InterviewRound] = []
1140
+
1141
+ for i, result in enumerate(selected, 1):
1142
+ typer.secho(f"--- Round {i}/{len(selected)} ---", bold=True)
1143
+ typer.echo(f" [{result.verdict}] {result.claim.bullet_text[:80]}")
1144
+ typer.echo("")
1145
+
1146
+ question, q_comp = generate_question(result, provider, style=style)
1147
+ typer.secho(f"Q: {question}", fg=typer.colors.CYAN)
1148
+ answer = typer.prompt("Your answer")
1149
+ if answer.strip().lower() == "quit":
1150
+ typer.echo("Ending interview early.")
1151
+ break
1152
+
1153
+ follow_up, fu_comp = generate_follow_up(question, answer, provider)
1154
+ typer.echo("")
1155
+ typer.secho(f"Follow-up: {follow_up}", fg=typer.colors.CYAN)
1156
+ fu_answer = typer.prompt("Your answer")
1157
+ if fu_answer.strip().lower() == "quit":
1158
+ typer.echo("Ending interview early.")
1159
+ break
1160
+
1161
+ combined = f"{answer}\n\nFollow-up: {fu_answer}"
1162
+ held_up, assessment, a_comp = assess_answer(
1163
+ result.claim.bullet_text, question, combined, provider
1164
+ )
1165
+
1166
+ verdict_color = typer.colors.GREEN if held_up else typer.colors.RED
1167
+ typer.echo("")
1168
+ typer.secho(
1169
+ f" {'Held up' if held_up else 'Did not hold up'}: " f"{assessment}",
1170
+ fg=verdict_color,
1171
+ )
1172
+ typer.echo("")
1173
+
1174
+ model_answer, ma_comp = generate_model_answer(result, question, provider)
1175
+ typer.secho(" A strong, honest answer:", fg=typer.colors.BLUE, bold=True)
1176
+ typer.echo(f" {model_answer}")
1177
+ typer.echo("")
1178
+
1179
+ interview_rounds.append(
1180
+ InterviewRound(
1181
+ bullet_text=result.claim.bullet_text,
1182
+ section=result.claim.section,
1183
+ heading=result.claim.entry_heading,
1184
+ verdict=result.verdict,
1185
+ question=question,
1186
+ answer=answer,
1187
+ follow_up=follow_up,
1188
+ follow_up_answer=fu_answer,
1189
+ held_up=held_up,
1190
+ assessment=assessment,
1191
+ model_answer=model_answer,
1192
+ completions=[q_comp, fu_comp, a_comp, ma_comp],
1193
+ )
1194
+ )
1195
+
1196
+ if not interview_rounds:
1197
+ return
1198
+
1199
+ typer.echo("")
1200
+ typer.secho("=== Interview Summary ===", bold=True)
1201
+
1202
+ summary = generate_summary(interview_rounds, provider)
1203
+
1204
+ if summary.strong:
1205
+ typer.secho("Strong points:", fg=typer.colors.GREEN)
1206
+ for pt in summary.strong:
1207
+ typer.echo(f" + {pt}")
1208
+
1209
+ if summary.weak:
1210
+ typer.secho("Weak points:", fg=typer.colors.RED)
1211
+ for pt in summary.weak:
1212
+ typer.echo(f" - {pt}")
1213
+
1214
+ typer.echo("")
1215
+ typer.echo(summary.overall)
1216
+
1217
+ if save:
1218
+ _save_ama_session(interview_rounds, resume.name, source_label)
1219
+
1220
+
1221
+ @app.command()
1222
+ def rewrite(
1223
+ resume: str = typer.Argument(
1224
+ ..., help="Path to the resume: .tex, text-based .pdf, or .txt."
1225
+ ),
1226
+ source: str = typer.Argument(
1227
+ ..., help="Local folder of the codebase (must have been ingested)."
1228
+ ),
1229
+ output: str | None = typer.Option(
1230
+ None,
1231
+ "--output",
1232
+ "-o",
1233
+ help="Output path for the rewritten .tex file. Defaults to <resume>_optimized.tex.",
1234
+ ),
1235
+ threshold: float = typer.Option(
1236
+ 0.5, "--threshold", help="Cosine distance threshold for evidence relevance."
1237
+ ),
1238
+ section: list[str] | None = typer.Option(
1239
+ None,
1240
+ "--section",
1241
+ help="Only rewrite bullets from these resume sections (substring match).",
1242
+ ),
1243
+ entry: list[str] | None = typer.Option(
1244
+ None,
1245
+ "--entry",
1246
+ help="Only rewrite entries whose heading matches (substring, repeatable). Skills sections stay included.",
1247
+ ),
1248
+ scope_all: bool = typer.Option(
1249
+ False,
1250
+ "--all",
1251
+ help="Rewrite across the whole resume without the entry picker.",
1252
+ ),
1253
+ compile_pdf: bool = typer.Option(
1254
+ False,
1255
+ "--pdf",
1256
+ help="Attempt to compile the rewritten .tex to PDF.",
1257
+ ),
1258
+ jd: str | None = typer.Option(
1259
+ None,
1260
+ "--jd",
1261
+ help="Path to a job description text file for ATS-aware rewriting.",
1262
+ ),
1263
+ jd_url: str | None = typer.Option(
1264
+ None,
1265
+ "--jd-url",
1266
+ help="Fetch the job description from a URL instead of a file.",
1267
+ ),
1268
+ use_tui: bool = typer.Option(
1269
+ False,
1270
+ "--tui",
1271
+ help="Use the full-screen TUI dashboard.",
1272
+ ),
1273
+ ) -> None:
1274
+ """Verify claims and generate an optimized .tex resume with rewritten bullets."""
1275
+ if Path(resume).suffix.lower() != ".tex":
1276
+ typer.secho(
1277
+ "rewrite outputs an optimized .tex, so it needs your .tex source."
1278
+ " (verify, ama, dossier, and score all accept .pdf resumes.)",
1279
+ fg=typer.colors.RED,
1280
+ err=True,
1281
+ )
1282
+ raise typer.Exit(1)
1283
+ entries = _resolve_entries(Path(resume), section, entry, scope_all)
1284
+ try:
1285
+ jd = _resolve_jd(jd, jd_url)
1286
+ except ReceiptsError as exc:
1287
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
1288
+ raise typer.Exit(1) from exc
1289
+
1290
+ if use_tui:
1291
+ if not Path(source).is_dir():
1292
+ typer.secho(
1293
+ "rewrite --tui needs a local folder (it mines code metrics"
1294
+ " on screen). Clone the repo first, or drop --tui.",
1295
+ fg=typer.colors.RED,
1296
+ err=True,
1297
+ )
1298
+ raise typer.Exit(1)
1299
+ from receipts.tui.rewrite_app import RewriteApp
1300
+
1301
+ rw_app = RewriteApp(
1302
+ Path(resume),
1303
+ Path(source),
1304
+ threshold=threshold,
1305
+ sections=section,
1306
+ entries=entries,
1307
+ output_path=Path(output) if output else None,
1308
+ compile_pdf=compile_pdf,
1309
+ jd_path=Path(jd) if jd else None,
1310
+ )
1311
+ rw_app.run()
1312
+ return
1313
+ try:
1314
+ from receipts.ingest.git_source import (
1315
+ cloned_repo,
1316
+ kb_dir_for_url,
1317
+ looks_like_git_url,
1318
+ )
1319
+ from receipts.ingest.manifest import lookup_source
1320
+
1321
+ spec = source
1322
+ if not looks_like_git_url(spec) and not Path(spec).is_dir():
1323
+ # Alias: local aliases resolve to their folder; remote aliases
1324
+ # resolve to their canonical URL for a fresh clone.
1325
+ record = lookup_source(spec)
1326
+ if record is not None and record.kind == "local":
1327
+ spec = record.source
1328
+ elif record is not None and record.kind == "remote":
1329
+ spec = f"https://{record.source}"
1330
+ else:
1331
+ _resolve_kb_source(spec) # raises the friendly unknown-source error
1332
+
1333
+ if looks_like_git_url(spec):
1334
+ # Rewriting mines the working tree and git history, so remote
1335
+ # sources are cloned in full — the KB stays keyed by the URL.
1336
+ kb_dir = kb_dir_for_url(spec)
1337
+ typer.echo(f"Cloning {spec} (full history, for git evidence) ...")
1338
+ with cloned_repo(spec, full_history=True) as root:
1339
+ _run_rewrite_pipeline(
1340
+ Path(resume),
1341
+ root,
1342
+ output,
1343
+ threshold,
1344
+ section,
1345
+ compile_pdf,
1346
+ jd,
1347
+ entries,
1348
+ kb_dir=kb_dir,
1349
+ )
1350
+ else:
1351
+ _run_rewrite_pipeline(
1352
+ Path(resume),
1353
+ Path(spec),
1354
+ output,
1355
+ threshold,
1356
+ section,
1357
+ compile_pdf,
1358
+ jd,
1359
+ entries,
1360
+ )
1361
+ except ReceiptsError as exc:
1362
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
1363
+ raise typer.Exit(1) from exc
1364
+
1365
+
1366
+ def _run_rewrite_pipeline(
1367
+ resume_path: Path,
1368
+ source_path: Path,
1369
+ output_path_str: str | None,
1370
+ threshold: float,
1371
+ sections: list[str] | None,
1372
+ compile_pdf: bool,
1373
+ jd_path_str: str | None,
1374
+ entries: list[str] | None = None,
1375
+ *,
1376
+ kb_dir: Path | None = None,
1377
+ ) -> None:
1378
+ from receipts.config import load_settings
1379
+ from receipts.llm.factory import get_provider
1380
+ from receipts.mine.code_metrics import mine_code_facts
1381
+ from receipts.mine.git_evidence import mine_git_evidence
1382
+ from receipts.resume.claim_extractor import extract_claims
1383
+ from receipts.resume.loader import load_resume
1384
+ from receipts.rewrite.optimizer import (
1385
+ group_results_by_bullet,
1386
+ optimize_rewrites,
1387
+ )
1388
+ from receipts.rewrite.tex_rewriter import rewrite_tex
1389
+ from receipts.verify.kb import KnowledgeBase, default_kb_dir
1390
+ from receipts.verify.rewriter import rewrite_weak_bullets
1391
+ from receipts.verify.verifier import verify_claims
1392
+
1393
+ if not resume_path.is_file():
1394
+ typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
1395
+ raise typer.Exit(1)
1396
+ if not source_path.is_dir():
1397
+ typer.secho(
1398
+ f"Source folder not found: {source_path}", fg=typer.colors.RED, err=True
1399
+ )
1400
+ raise typer.Exit(1)
1401
+
1402
+ kb_dir = kb_dir or default_kb_dir(source_path)
1403
+ kb = KnowledgeBase(kb_dir)
1404
+ if kb.count() == 0:
1405
+ typer.secho(
1406
+ "Knowledge base is empty. Run 'receipts ingest' first.",
1407
+ fg=typer.colors.RED,
1408
+ err=True,
1409
+ )
1410
+ raise typer.Exit(1)
1411
+
1412
+ settings = load_settings()
1413
+ provider = get_provider(settings, verify=True)
1414
+
1415
+ # --- Mine provable facts (zero LLM calls) ---
1416
+ typer.echo("Mining provable facts from the codebase ...")
1417
+ code_facts = mine_code_facts(source_path)
1418
+ git_ev = mine_git_evidence(source_path)
1419
+ for line in code_facts.summary_lines():
1420
+ typer.echo(f" {line}")
1421
+ for line in git_ev.summary_lines():
1422
+ typer.echo(f" {line}")
1423
+
1424
+ grounded_facts = code_facts.prompt_facts()
1425
+ if git_ev.available:
1426
+ grounded_facts += "\n" + git_ev.prompt_facts()
1427
+
1428
+ typer.echo("")
1429
+ typer.echo(f"Parsing {resume_path.name} ...")
1430
+ resume = load_resume(resume_path)
1431
+ claims = extract_claims(resume, sections=sections, entries=entries)
1432
+
1433
+ if sections:
1434
+ typer.echo(f" Filtering to sections matching: {', '.join(sections)}")
1435
+ if entries:
1436
+ typer.echo(f" Filtering to entries matching: {', '.join(entries)}")
1437
+ typer.echo(f" Claims extracted: {len(claims)}")
1438
+
1439
+ typer.echo(f"Verifying {len(claims)} claim(s) ...")
1440
+ results = verify_claims(
1441
+ claims,
1442
+ kb,
1443
+ provider,
1444
+ distance_threshold=threshold,
1445
+ batch=provider.name != "ollama",
1446
+ )
1447
+
1448
+ _print_verification_report(results)
1449
+
1450
+ # --- JD keywords the code can justify adding ---
1451
+ addable_keywords: list[str] = []
1452
+ if jd_path_str and Path(jd_path_str).is_file():
1453
+ from receipts.verify.keyword_gap import analyze_gap
1454
+
1455
+ typer.echo("")
1456
+ typer.echo("Analyzing JD keyword gap ...")
1457
+ gap = analyze_gap(
1458
+ Path(jd_path_str).read_text(encoding="utf-8"),
1459
+ resume,
1460
+ kb,
1461
+ provider,
1462
+ distance_threshold=threshold,
1463
+ )
1464
+ addable_keywords = [i.keyword for i in gap.addable]
1465
+ if addable_keywords:
1466
+ typer.echo(
1467
+ f" Code-supported keywords to weave in: {', '.join(addable_keywords)}"
1468
+ )
1469
+
1470
+ typer.echo("")
1471
+ typer.secho("Generating rewrites (grounded in mined facts)...", bold=True)
1472
+ raw_rewrites = rewrite_weak_bullets(
1473
+ results,
1474
+ provider,
1475
+ grounded_facts=grounded_facts or None,
1476
+ addable_keywords=addable_keywords or None,
1477
+ )
1478
+
1479
+ if not raw_rewrites:
1480
+ typer.secho("All bullets verified — no rewrites needed.", fg=typer.colors.GREEN)
1481
+ return
1482
+
1483
+ # --- Score-aware pass: retry rewrites that regressed ATS dimensions ---
1484
+ optimized = optimize_rewrites(
1485
+ raw_rewrites,
1486
+ group_results_by_bullet(results),
1487
+ provider,
1488
+ grounded_facts=grounded_facts or None,
1489
+ addable_keywords=addable_keywords or None,
1490
+ )
1491
+ retried = sum(1 for o in optimized if o.retried)
1492
+ fixed = sum(1 for o in optimized if o.retry_fixed)
1493
+ if retried:
1494
+ typer.echo(
1495
+ f" Score-aware pass: {retried} rewrite(s) had ATS regressions,"
1496
+ f" {fixed} fixed on retry."
1497
+ )
1498
+ rewrites = [o.result for o in optimized]
1499
+
1500
+ for rw in rewrites:
1501
+ if rw.original != rw.rewritten:
1502
+ typer.echo("")
1503
+ typer.echo(f" ORIGINAL: {rw.original[:120]}")
1504
+ typer.secho(f" REWRITTEN: {rw.rewritten[:120]}", fg=typer.colors.GREEN)
1505
+ typer.echo(f" why: {rw.explanation}")
1506
+
1507
+ output_path = Path(output_path_str) if output_path_str else None
1508
+ section_filter = sections[0] if sections else None
1509
+
1510
+ typer.echo("")
1511
+ typer.secho("Generating optimized .tex file...", bold=True)
1512
+ report = rewrite_tex(
1513
+ resume_path,
1514
+ rewrites,
1515
+ output_path=output_path,
1516
+ section_filter=section_filter,
1517
+ )
1518
+
1519
+ typer.echo(f" Replacements: {report.replacements}")
1520
+ if report.skipped:
1521
+ typer.secho(
1522
+ f" Skipped: {report.skipped} (could not match to TeX source)",
1523
+ fg=typer.colors.YELLOW,
1524
+ )
1525
+ typer.secho(f" Output: {report.output_path}", fg=typer.colors.GREEN)
1526
+
1527
+ if compile_pdf:
1528
+ from receipts.rewrite.compiler import compile_tex
1529
+
1530
+ typer.echo("")
1531
+ typer.echo("Compiling to PDF...")
1532
+ comp = compile_tex(report.output_path)
1533
+ if comp.success:
1534
+ typer.secho(
1535
+ f" PDF: {comp.pdf_path} (via {comp.tool_used})", fg=typer.colors.GREEN
1536
+ )
1537
+ else:
1538
+ typer.secho(f" Compilation failed: {comp.error}", fg=typer.colors.YELLOW)
1539
+ typer.echo(" You can compile manually or use Overleaf.")
1540
+
1541
+ if jd_path_str and Path(jd_path_str).is_file():
1542
+ from receipts.ats.comparator import compare_tex_files
1543
+
1544
+ typer.echo("")
1545
+ typer.secho("ATS Score Comparison:", bold=True)
1546
+ comparison = compare_tex_files(
1547
+ resume_path,
1548
+ report.output_path,
1549
+ Path(jd_path_str).read_text(encoding="utf-8"),
1550
+ )
1551
+ typer.echo(comparison.summary)
1552
+ if comparison.improved:
1553
+ typer.secho(
1554
+ f"\n Resume improved by {comparison.composite_delta:+.1f} points.",
1555
+ fg=typer.colors.GREEN,
1556
+ )
1557
+ else:
1558
+ typer.secho(
1559
+ f"\n Resume score changed by {comparison.composite_delta:+.1f} points.",
1560
+ fg=typer.colors.YELLOW,
1561
+ )
1562
+
1563
+
1564
+ @app.command()
1565
+ def score(
1566
+ resume: str = typer.Argument(
1567
+ ..., help="Path to the resume: .tex, text-based .pdf, or .txt."
1568
+ ),
1569
+ jd: str | None = typer.Argument(
1570
+ None,
1571
+ help="Path to a job description text file (or use --jd-url).",
1572
+ ),
1573
+ jd_url: str | None = typer.Option(
1574
+ None,
1575
+ "--jd-url",
1576
+ help="Fetch the job description from a URL instead of a file.",
1577
+ ),
1578
+ compare: str | None = typer.Option(
1579
+ None,
1580
+ "--compare",
1581
+ "-c",
1582
+ help="Path to a second .tex file (rewritten) to compare against.",
1583
+ ),
1584
+ stats: bool = typer.Option(
1585
+ False,
1586
+ "--stats",
1587
+ help="Show statistical confidence intervals on improvement.",
1588
+ ),
1589
+ use_tui: bool = typer.Option(
1590
+ False,
1591
+ "--tui",
1592
+ help="Use the full-screen TUI dashboard.",
1593
+ ),
1594
+ ) -> None:
1595
+ """Score a resume against a job description (ATS scoring engine)."""
1596
+ try:
1597
+ if jd is None and jd_url is None:
1598
+ raise ReceiptsError(
1599
+ "Provide a JD: a text-file path, or --jd-url <posting url>."
1600
+ )
1601
+ jd = _resolve_jd(jd, jd_url)
1602
+ except ReceiptsError as exc:
1603
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
1604
+ raise typer.Exit(1) from exc
1605
+
1606
+ if use_tui:
1607
+ from receipts.tui.score_app import ScoreApp
1608
+
1609
+ score_app = ScoreApp(
1610
+ Path(resume),
1611
+ Path(jd),
1612
+ compare_path=Path(compare) if compare else None,
1613
+ show_stats=stats,
1614
+ )
1615
+ score_app.run()
1616
+ return
1617
+ try:
1618
+ _run_score(Path(resume), Path(jd), compare, stats)
1619
+ except ReceiptsError as exc:
1620
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
1621
+ raise typer.Exit(1) from exc
1622
+
1623
+
1624
+ def _run_score(
1625
+ resume_path: Path,
1626
+ jd_path: Path,
1627
+ compare_path_str: str | None,
1628
+ show_stats: bool,
1629
+ ) -> None:
1630
+ from receipts.ats.scorer import score_tex_file
1631
+
1632
+ if not resume_path.is_file():
1633
+ typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
1634
+ raise typer.Exit(1)
1635
+ if not jd_path.is_file():
1636
+ typer.secho(f"JD file not found: {jd_path}", fg=typer.colors.RED, err=True)
1637
+ raise typer.Exit(1)
1638
+
1639
+ jd_text = jd_path.read_text(encoding="utf-8")
1640
+
1641
+ typer.echo(f"Scoring {resume_path.name} against {jd_path.name} ...")
1642
+ result = score_tex_file(resume_path, jd_text)
1643
+
1644
+ typer.echo("")
1645
+ typer.secho(f"ATS Score: {result.composite:.1f}/100", bold=True)
1646
+ typer.echo("")
1647
+ for dim in result.dimensions:
1648
+ bar = "█" * (dim.score // 5) + "░" * (20 - dim.score // 5)
1649
+ typer.echo(f" {dim.name:<22} {dim.score:>3}/100 {bar}")
1650
+ typer.echo(f" {dim.detail}")
1651
+
1652
+ typer.echo("")
1653
+ typer.echo(f" Bullets analyzed: {len(result.bullet_scores)}")
1654
+
1655
+ if compare_path_str:
1656
+ from receipts.ats.comparator import compare_tex_files
1657
+
1658
+ compare_path = Path(compare_path_str)
1659
+ if not compare_path.is_file():
1660
+ typer.secho(
1661
+ f"Comparison file not found: {compare_path}",
1662
+ fg=typer.colors.RED,
1663
+ err=True,
1664
+ )
1665
+ raise typer.Exit(1)
1666
+
1667
+ typer.echo("")
1668
+ typer.secho("Before/After Comparison:", bold=True)
1669
+ report = compare_tex_files(resume_path, compare_path, jd_text)
1670
+ typer.echo(report.summary)
1671
+
1672
+ if report.improved:
1673
+ typer.secho(
1674
+ f"\n Resume improved by {report.composite_delta:+.1f} points.",
1675
+ fg=typer.colors.GREEN,
1676
+ )
1677
+ else:
1678
+ typer.secho(
1679
+ f"\n Resume score changed by {report.composite_delta:+.1f} points.",
1680
+ fg=typer.colors.YELLOW,
1681
+ )
1682
+
1683
+ if show_stats:
1684
+ from receipts.ats.stats import compute_improvement
1685
+
1686
+ typer.echo("")
1687
+ typer.secho("Statistical Analysis:", bold=True)
1688
+ improvement = compute_improvement(report.before, report.after)
1689
+ typer.echo(improvement.summary)
1690
+
1691
+
1692
+ @app.command()
1693
+ def dossier(
1694
+ resume: str = typer.Argument(
1695
+ ..., help="Path to the resume: .tex, text-based .pdf, or .txt."
1696
+ ),
1697
+ source: str = typer.Argument(
1698
+ ..., help="Local folder of the codebase (must have been ingested)."
1699
+ ),
1700
+ output: str | None = typer.Option(
1701
+ None,
1702
+ "--output",
1703
+ "-o",
1704
+ help="Output path for the dossier. Defaults to dossier.md.",
1705
+ ),
1706
+ threshold: float = typer.Option(
1707
+ 0.5, "--threshold", help="Cosine distance threshold for evidence relevance."
1708
+ ),
1709
+ section: list[str] | None = typer.Option(
1710
+ None,
1711
+ "--section",
1712
+ help="Only cover these resume sections (substring match, repeatable).",
1713
+ ),
1714
+ entry: list[str] | None = typer.Option(
1715
+ None,
1716
+ "--entry",
1717
+ help="Only cover entries whose heading matches (substring, repeatable).",
1718
+ ),
1719
+ scope_all: bool = typer.Option(
1720
+ False,
1721
+ "--all",
1722
+ help="Cover the whole resume without the entry picker.",
1723
+ ),
1724
+ pdf: bool = typer.Option(
1725
+ False,
1726
+ "--pdf",
1727
+ help="Also export the dossier to PDF (needs the [pdf] extra).",
1728
+ ),
1729
+ use_tui: bool = typer.Option(
1730
+ False, "--tui", help="Use the full-screen TUI with a markdown preview."
1731
+ ),
1732
+ ) -> None:
1733
+ """Generate the interview defense dossier — your night-before study doc."""
1734
+ try:
1735
+ entries = _resolve_entries(Path(resume), section, entry, scope_all)
1736
+ if use_tui:
1737
+ from receipts.tui.dossier_app import DossierApp
1738
+
1739
+ DossierApp(
1740
+ Path(resume),
1741
+ Path(source),
1742
+ threshold=threshold,
1743
+ sections=section,
1744
+ entries=entries,
1745
+ output_path=Path(output) if output else None,
1746
+ ).run()
1747
+ return
1748
+ written = _run_dossier(
1749
+ Path(resume), source, output, threshold, section, entries
1750
+ )
1751
+ if pdf and written is not None:
1752
+ from receipts.export import export_markdown_pdf
1753
+
1754
+ result = export_markdown_pdf(written)
1755
+ if result.success:
1756
+ typer.secho(f" PDF: {result.pdf_path}", fg=typer.colors.GREEN)
1757
+ else:
1758
+ typer.secho(
1759
+ f" PDF export failed: {result.error}", fg=typer.colors.YELLOW
1760
+ )
1761
+ except ReceiptsError as exc:
1762
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
1763
+ raise typer.Exit(1) from exc
1764
+
1765
+
1766
+ def _run_dossier(
1767
+ resume_path: Path,
1768
+ source_spec: str | Path,
1769
+ output_str: str | None,
1770
+ threshold: float,
1771
+ sections: list[str] | None,
1772
+ entries: list[str] | None,
1773
+ ) -> Path | None:
1774
+ from datetime import date
1775
+
1776
+ from receipts.config import load_settings
1777
+ from receipts.llm.factory import get_provider
1778
+ from receipts.prep.dossier import build_dossier, render_markdown
1779
+ from receipts.prep.readiness import ReadinessStore
1780
+ from receipts.resume.claim_extractor import extract_claims
1781
+ from receipts.resume.loader import load_resume
1782
+ from receipts.verify.kb import KnowledgeBase
1783
+ from receipts.verify.verifier import verify_claims
1784
+
1785
+ if not resume_path.is_file():
1786
+ typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
1787
+ raise typer.Exit(1)
1788
+
1789
+ kb_dir, source_label = _resolve_kb_source(str(source_spec))
1790
+ kb = KnowledgeBase(kb_dir)
1791
+ if kb.count() == 0:
1792
+ typer.secho(
1793
+ f"Knowledge base for '{source_label}' is empty."
1794
+ " Run 'receipts ingest' first.",
1795
+ fg=typer.colors.RED,
1796
+ err=True,
1797
+ )
1798
+ raise typer.Exit(1)
1799
+
1800
+ settings = load_settings()
1801
+ provider = get_provider(settings, verify=True)
1802
+
1803
+ typer.echo(f"Parsing {resume_path.name} ...")
1804
+ resume = load_resume(resume_path)
1805
+ claims = extract_claims(resume, sections=sections, entries=entries)
1806
+ typer.echo(f" Claims extracted: {len(claims)}")
1807
+
1808
+ typer.echo(f"Verifying {len(claims)} claim(s) ...")
1809
+ results = verify_claims(
1810
+ claims,
1811
+ kb,
1812
+ provider,
1813
+ distance_threshold=threshold,
1814
+ batch=provider.name != "ollama",
1815
+ )
1816
+
1817
+ # Weak spots from past mock-interview sessions feed the drill notes.
1818
+ store = ReadinessStore()
1819
+ try:
1820
+ weak_spots = store.weak_spots()
1821
+ finally:
1822
+ store.close()
1823
+ if weak_spots:
1824
+ typer.echo(f" Including {len(weak_spots)} weak spot(s) from past sessions.")
1825
+
1826
+ typer.echo("")
1827
+ typer.secho("Building dossier (one LLM call per bullet)...", bold=True)
1828
+
1829
+ def _progress(done: int, total: int) -> None:
1830
+ typer.echo(f" bullet {done}/{total}")
1831
+
1832
+ entries_built = build_dossier(
1833
+ results, provider, weak_spots=weak_spots, on_progress=_progress
1834
+ )
1835
+
1836
+ doc = render_markdown(
1837
+ entries_built,
1838
+ resume_name=resume.name,
1839
+ source_name=source_label,
1840
+ generated=date.today().isoformat(),
1841
+ )
1842
+
1843
+ output_path = Path(output_str) if output_str else Path("dossier.md")
1844
+ output_path.write_text(doc, encoding="utf-8")
1845
+
1846
+ typer.echo("")
1847
+ typer.secho(f"Dossier written: {output_path}", fg=typer.colors.GREEN, bold=True)
1848
+ typer.echo(f" Bullets covered: {len(entries_built)}")
1849
+ unsupported = sum(1 for e in entries_built if e.verdict == "unsupported")
1850
+ if unsupported:
1851
+ typer.secho(
1852
+ f" Study the {unsupported} UNSUPPORTED bullet(s) first —"
1853
+ " they're listed at the top of each section.",
1854
+ fg=typer.colors.YELLOW,
1855
+ )
1856
+ return output_path
1857
+
1858
+
1859
+ @app.command()
1860
+ def readiness(
1861
+ use_tui: bool = typer.Option(
1862
+ False, "--tui", help="Open the readiness screen in the TUI hub."
1863
+ ),
1864
+ ) -> None:
1865
+ """Show mock-interview readiness — trends across saved AMA sessions."""
1866
+ if use_tui:
1867
+ from receipts.tui.hub import HubApp
1868
+
1869
+ HubApp(start="readiness").run()
1870
+ return
1871
+
1872
+ from datetime import datetime
1873
+
1874
+ from receipts.prep.readiness import ReadinessStore, readiness_delta
1875
+
1876
+ store = ReadinessStore()
1877
+ try:
1878
+ sessions = store.sessions()
1879
+ weakest = store.weakest_section()
1880
+ weak_spots = store.weak_spots(limit=5)
1881
+ finally:
1882
+ store.close()
1883
+
1884
+ if not sessions:
1885
+ typer.echo(
1886
+ "No saved interview sessions yet."
1887
+ " Run 'receipts ama <resume> <source>' first."
1888
+ )
1889
+ return
1890
+
1891
+ typer.secho("Mock-interview readiness", bold=True)
1892
+ typer.echo("")
1893
+
1894
+ for s in sessions:
1895
+ when = datetime.fromtimestamp(s.timestamp).strftime("%Y-%m-%d %H:%M")
1896
+ typer.echo(
1897
+ f" {when} {s.resume_name:<24} "
1898
+ f"{s.held_rounds}/{s.total_rounds} held up ({s.ratio:.0%})"
1899
+ )
1900
+
1901
+ delta = readiness_delta(sessions)
1902
+ if delta:
1903
+ latest, previous = delta
1904
+ change = (latest - previous) * 100
1905
+ color = typer.colors.GREEN if change >= 0 else typer.colors.RED
1906
+ typer.echo("")
1907
+ typer.secho(
1908
+ f" Trend: {previous:.0%} → {latest:.0%} ({change:+.0f} pts)",
1909
+ fg=color,
1910
+ bold=True,
1911
+ )
1912
+
1913
+ latest_session = sessions[-1]
1914
+ if latest_session.sections:
1915
+ typer.echo("")
1916
+ typer.secho(" Latest session by section:", bold=True)
1917
+ for stat in sorted(latest_session.sections, key=lambda s: s.ratio):
1918
+ bar_len = int(stat.ratio * 10)
1919
+ bar = "#" * bar_len + "-" * (10 - bar_len)
1920
+ typer.echo(f" {stat.section:<24} {stat.held}/{stat.total} [{bar}]")
1921
+
1922
+ if weakest and weakest.ratio < 1.0:
1923
+ typer.echo("")
1924
+ typer.secho(
1925
+ f" Drill next: {weakest.section} "
1926
+ f"({weakest.held}/{weakest.total} held up) — try"
1927
+ f' receipts ama <resume> <source> --section "{weakest.section}"',
1928
+ fg=typer.colors.YELLOW,
1929
+ )
1930
+
1931
+ if weak_spots:
1932
+ typer.echo("")
1933
+ typer.secho(" Recent stumbles:", bold=True)
1934
+ for spot in weak_spots:
1935
+ typer.echo(f" - {spot.bullet_text[:70]}")
1936
+ typer.echo(f" {spot.assessment[:90]}")
1937
+ if spot.model_answer:
1938
+ typer.secho(
1939
+ f" → Better answer: {spot.model_answer[:110]}",
1940
+ fg=typer.colors.BLUE,
1941
+ )
1942
+
1943
+
1944
+ @app.command()
1945
+ def export(
1946
+ source: str = typer.Argument(
1947
+ ..., help="File to export: a .md (dossier/report) or .tex (resume)."
1948
+ ),
1949
+ output: str | None = typer.Option(
1950
+ None, "--output", "-o", help="Output PDF path. Defaults to <source>.pdf."
1951
+ ),
1952
+ ) -> None:
1953
+ """Export a markdown or TeX file to PDF.
1954
+
1955
+ Markdown needs no external programs (pip install codebase-receipts-cli[pdf]).
1956
+ TeX uses latexmk/tectonic/pdflatex when installed.
1957
+ """
1958
+ from receipts.export import export_to_pdf
1959
+
1960
+ source_path = Path(source)
1961
+ if not source_path.is_file():
1962
+ typer.secho(f"File not found: {source_path}", fg=typer.colors.RED, err=True)
1963
+ raise typer.Exit(1)
1964
+
1965
+ try:
1966
+ result = export_to_pdf(source_path, Path(output) if output else None)
1967
+ except ReceiptsError as exc:
1968
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
1969
+ raise typer.Exit(1) from exc
1970
+
1971
+ if result.success:
1972
+ typer.secho(
1973
+ f"PDF written: {result.pdf_path} (via {result.tool_used})",
1974
+ fg=typer.colors.GREEN,
1975
+ )
1976
+ else:
1977
+ typer.secho(f"Export failed: {result.error}", fg=typer.colors.RED, err=True)
1978
+ raise typer.Exit(1)
1979
+
1980
+
1981
+ @app.command()
1982
+ def ledger(
1983
+ session: bool = typer.Option(
1984
+ False,
1985
+ "--session",
1986
+ help="Show only the most recent session (not lifetime totals).",
1987
+ ),
1988
+ use_tui: bool = typer.Option(
1989
+ False, "--tui", help="Open the ledger screen in the TUI hub."
1990
+ ),
1991
+ ) -> None:
1992
+ """Show the token/cost ledger — lifetime totals by default."""
1993
+ if use_tui:
1994
+ from receipts.tui.hub import HubApp
1995
+
1996
+ HubApp(start="ledger").run()
1997
+ return
1998
+
1999
+ from receipts.ledger.token_ledger import TokenLedger
2000
+
2001
+ tl = TokenLedger()
2002
+ try:
2003
+ latest = tl.last_session_summary() if session else None
2004
+ lt = tl.lifetime_summary()
2005
+ finally:
2006
+ tl.close()
2007
+
2008
+ if lt.call_count == 0:
2009
+ typer.echo("No LLM calls recorded yet. Run a command first.")
2010
+ return
2011
+
2012
+ if session:
2013
+ # latest can't be None here: lifetime has calls, so a session exists.
2014
+ typer.secho("Most recent session", bold=True)
2015
+ typer.echo(f" Session id: {latest.session_id}")
2016
+ typer.echo(f" LLM calls: {latest.call_count}")
2017
+ typer.echo(f" Input tokens: {latest.total_input:,}")
2018
+ typer.echo(f" Output tokens: {latest.total_output:,}")
2019
+ typer.echo(f" Est. cost: ${latest.total_cost:.4f}")
2020
+ return
2021
+
2022
+ typer.secho("Lifetime token ledger", bold=True)
2023
+ typer.echo(f" Sessions: {lt.session_count}")
2024
+ typer.echo(f" LLM calls: {lt.call_count}")
2025
+ typer.echo(f" Input tokens: {lt.total_input:,}")
2026
+ typer.echo(f" Output tokens: {lt.total_output:,}")
2027
+ typer.echo(f" Est. cost: ${lt.total_cost:.4f}")
2028
+
2029
+
2030
+ @app.command()
2031
+ def dashboard() -> None:
2032
+ """One-screen TUI: provider status, token spend, interview readiness."""
2033
+ from receipts.tui.hub import HubApp
2034
+
2035
+ HubApp(start="dashboard").run()
2036
+
2037
+
2038
+ @app.command(name="interactive")
2039
+ def interactive() -> None:
2040
+ """Drop into the interactive slash-command REPL."""
2041
+ from receipts.interactive.repl import run_repl
2042
+
2043
+ run_repl()
2044
+
2045
+
2046
+ def main() -> None:
2047
+ # Windows consoles/pipes can default to cp1252, which can't encode the
2048
+ # score bars (█░) or arrows (→). Force UTF-8 with graceful degradation.
2049
+ for stream in (sys.stdout, sys.stderr):
2050
+ if hasattr(stream, "reconfigure") and (stream.encoding or "").lower() not in (
2051
+ "utf-8",
2052
+ "utf8",
2053
+ ):
2054
+ try:
2055
+ stream.reconfigure(encoding="utf-8", errors="replace")
2056
+ except (OSError, ValueError):
2057
+ pass
2058
+ app()
2059
+
2060
+
2061
+ if __name__ == "__main__":
2062
+ main()