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,463 @@
1
+ """Textual TUI for the rewrite pipeline.
2
+
3
+ Full-screen dashboard: verify claims, generate rewrites, show diffs,
4
+ produce optimized .tex file, optionally compile to PDF.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from textual.app import App, ComposeResult
12
+ from textual.widgets import Footer, Header, RichLog, TabbedContent, TabPane
13
+
14
+ from receipts.tui.widgets.claims_table import ClaimsTable
15
+ from receipts.tui.widgets.diff_view import DiffView
16
+ from receipts.tui.widgets.status_bar import StatusBar
17
+
18
+
19
+ class RewriteApp(App):
20
+ """Full-screen TUI for the ATS rewrite pipeline."""
21
+
22
+ TITLE = "Receipts"
23
+ SUB_TITLE = "rewrite pipeline"
24
+
25
+ CSS = """
26
+ #activity-log {
27
+ height: 1fr;
28
+ }
29
+ """
30
+
31
+ BINDINGS = [
32
+ ("q", "quit", "Quit"),
33
+ ("c", "show_tab('claims-pane')", "Claims"),
34
+ ("r", "show_tab('rewrites-pane')", "Rewrites"),
35
+ ("o", "show_tab('output-pane')", "Output"),
36
+ ("l", "show_tab('log-pane')", "Log"),
37
+ ]
38
+
39
+ def __init__(
40
+ self,
41
+ resume_path: Path,
42
+ source_path: Path,
43
+ *,
44
+ threshold: float = 0.5,
45
+ sections: list[str] | None = None,
46
+ entries: list[str] | None = None,
47
+ output_path: Path | None = None,
48
+ compile_pdf: bool = False,
49
+ jd_path: Path | None = None,
50
+ **kwargs,
51
+ ) -> None:
52
+ super().__init__(**kwargs)
53
+ self.resume_path = resume_path
54
+ self.source_path = source_path
55
+ self.threshold = threshold
56
+ self.sections = sections
57
+ self.entries = entries
58
+ self.output_path = output_path
59
+ self.compile_pdf = compile_pdf
60
+ self.jd_path = jd_path
61
+
62
+ def compose(self) -> ComposeResult:
63
+ yield Header()
64
+ with TabbedContent(id="main-area"):
65
+ with TabPane("Claims", id="claims-pane"):
66
+ yield ClaimsTable(id="claims-table")
67
+ with TabPane("Rewrites", id="rewrites-pane"):
68
+ yield DiffView(id="diff-view")
69
+ with TabPane("Output", id="output-pane"):
70
+ yield RichLog(id="output-log", markup=True, wrap=True)
71
+ with TabPane("Log", id="log-pane"):
72
+ yield RichLog(id="activity-log", markup=True, wrap=True)
73
+ yield StatusBar(id="status-bar")
74
+ yield Footer()
75
+
76
+ def on_mount(self) -> None:
77
+ self.run_worker(self._run_rewrite, thread=True)
78
+
79
+ async def _run_rewrite(self) -> None:
80
+ log = self.query_one("#activity-log", RichLog)
81
+ output_log = self.query_one("#output-log", RichLog)
82
+ status = self.query_one("#status-bar", StatusBar)
83
+ table = self.query_one("#claims-table", ClaimsTable)
84
+ diff = self.query_one("#diff-view", DiffView)
85
+
86
+ from receipts.config import load_settings
87
+ from receipts.llm.factory import get_provider
88
+ from receipts.mine.code_metrics import mine_code_facts
89
+ from receipts.mine.git_evidence import mine_git_evidence
90
+ from receipts.resume.claim_extractor import extract_claims
91
+ from receipts.resume.tex_parser import parse_file
92
+ from receipts.rewrite.optimizer import (
93
+ group_results_by_bullet,
94
+ optimize_rewrites,
95
+ )
96
+ from receipts.rewrite.tex_rewriter import rewrite_tex
97
+ from receipts.verify.kb import KnowledgeBase, default_kb_dir
98
+ from receipts.verify.rewriter import rewrite_weak_bullets
99
+ from receipts.verify.verifier import (
100
+ group_claims_by_bullet,
101
+ verify_bullet_claims,
102
+ verify_claim,
103
+ )
104
+
105
+ log.write("[bold]Starting rewrite pipeline...[/bold]")
106
+
107
+ # --- Provider ---
108
+ settings = load_settings()
109
+ try:
110
+ provider = get_provider(settings, verify=True)
111
+ except Exception as exc:
112
+ log.write(f"[red]Provider error: {exc}[/red]")
113
+ log.write(
114
+ "[red]Check your .env config and that your provider is running.[/red]"
115
+ )
116
+ return
117
+
118
+ status.provider_name = provider.name
119
+ log.write(f"Provider: [green]{provider.name}[/green]")
120
+
121
+ # Surface rate-limit waits in the status bar instead of hanging silently.
122
+ if hasattr(provider, "wait_callback"):
123
+
124
+ def _on_wait(reason: str, wait: int) -> None:
125
+ status.status_text = f"Rate-limited — waiting {wait}s..."
126
+ log.write(
127
+ f"[yellow]{reason} — waiting {wait}s before retrying...[/yellow]"
128
+ )
129
+
130
+ provider.wait_callback = _on_wait
131
+
132
+ # --- KB ---
133
+ kb_dir = default_kb_dir(self.source_path)
134
+ kb = KnowledgeBase(kb_dir)
135
+ if kb.count() == 0:
136
+ log.write(
137
+ "[red]Knowledge base is empty. Run 'receipts ingest' first.[/red]"
138
+ )
139
+ return
140
+
141
+ log.write(f"Knowledge base: {kb.count()} artifact(s)")
142
+
143
+ # --- Mine provable facts (zero LLM calls) ---
144
+ status.status_text = "Mining code facts..."
145
+ code_facts = mine_code_facts(self.source_path)
146
+ git_ev = mine_git_evidence(self.source_path)
147
+ log.write("")
148
+ log.write("[bold]Provable facts mined from the codebase:[/bold]")
149
+ for line in code_facts.summary_lines():
150
+ log.write(f" {line}")
151
+ for line in git_ev.summary_lines():
152
+ log.write(f" {line}")
153
+
154
+ grounded_facts = code_facts.prompt_facts()
155
+ if git_ev.available:
156
+ grounded_facts += "\n" + git_ev.prompt_facts()
157
+
158
+ # --- Parse ---
159
+ status.status_text = "Parsing resume..."
160
+ resume = parse_file(self.resume_path)
161
+ claims = extract_claims(resume, sections=self.sections, entries=self.entries)
162
+ if self.entries:
163
+ log.write(f"Scoped to entries: {', '.join(self.entries)} (+ skills)")
164
+ log.write(
165
+ f"Parsed [bold]{self.resume_path.name}[/bold]: {len(claims)} claim(s)"
166
+ )
167
+
168
+ # --- Verify, batching per bullet on API providers ---
169
+ status.status_text = "Verifying claims..."
170
+ use_batch = provider.name != "ollama"
171
+ groups = group_claims_by_bullet(claims) if use_batch else [[c] for c in claims]
172
+ log.write("")
173
+ log.write(f"[bold]Verifying {len(claims)} claim(s)...[/bold]")
174
+ if use_batch:
175
+ log.write("[dim]Claims from the same bullet share one LLM call.[/dim]")
176
+
177
+ results = []
178
+ errors = 0
179
+ done = 0
180
+ for group in groups:
181
+ done += len(group)
182
+ status.status_text = f"Verifying {done}/{len(claims)}..."
183
+ try:
184
+ if use_batch:
185
+ group_results = verify_bullet_claims(
186
+ group, kb, provider, distance_threshold=self.threshold
187
+ )
188
+ else:
189
+ group_results = [
190
+ verify_claim(
191
+ group[0],
192
+ kb,
193
+ provider,
194
+ distance_threshold=self.threshold,
195
+ )
196
+ ]
197
+ except Exception as exc:
198
+ from receipts.errors import ProviderConfigError
199
+
200
+ if isinstance(exc, ProviderConfigError):
201
+ log.write(f"[red]Provider configuration error: {exc}[/red]")
202
+ log.write(
203
+ "[red]Aborting run — fix your .env / environment"
204
+ " (run 'receipts check') and retry.[/red]"
205
+ )
206
+ status.status_text = "Error — provider config"
207
+ return
208
+ errors += len(group)
209
+ log.write(
210
+ f" [red]ERROR verifying '{group[0].bullet_text[:50]}':"
211
+ f" {exc}[/red]"
212
+ )
213
+ continue
214
+
215
+ for result in group_results:
216
+ results.append(result)
217
+ self.call_from_thread(table.add_result, result)
218
+
219
+ if result.completion:
220
+ status.add_tokens(
221
+ result.completion.input_tokens,
222
+ result.completion.output_tokens,
223
+ )
224
+
225
+ if errors:
226
+ log.write(f"[red]{errors} claim(s) failed due to API errors.[/red]")
227
+
228
+ verified = sum(1 for r in results if r.verdict == "verified")
229
+ plausible = sum(1 for r in results if r.verdict == "plausible")
230
+ unsupported = sum(1 for r in results if r.verdict == "unsupported")
231
+
232
+ log.write(
233
+ f"[green]{verified} verified[/green], "
234
+ f"[yellow]{plausible} plausible[/yellow], "
235
+ f"[red]{unsupported} unsupported[/red]"
236
+ )
237
+
238
+ # --- JD keywords the code can justify adding ---
239
+ addable_keywords: list[str] = []
240
+ if self.jd_path and self.jd_path.is_file():
241
+ from receipts.verify.keyword_gap import analyze_gap
242
+
243
+ status.status_text = "Analyzing JD keyword gap..."
244
+ try:
245
+ gap = analyze_gap(
246
+ self.jd_path.read_text(encoding="utf-8"),
247
+ resume,
248
+ kb,
249
+ provider,
250
+ distance_threshold=self.threshold,
251
+ )
252
+ if gap.completion:
253
+ status.add_tokens(
254
+ gap.completion.input_tokens, gap.completion.output_tokens
255
+ )
256
+ addable_keywords = [i.keyword for i in gap.addable]
257
+ if addable_keywords:
258
+ log.write(
259
+ f"Code-supported JD keywords: {', '.join(addable_keywords)}"
260
+ )
261
+ except Exception as exc:
262
+ log.write(f"[yellow]Keyword gap analysis failed: {exc}[/yellow]")
263
+
264
+ # --- Rewrite (grounded in mined facts) ---
265
+ status.status_text = "Generating rewrites..."
266
+ log.write("")
267
+ log.write("[bold]Generating rewrites for weak bullets...[/bold]")
268
+
269
+ try:
270
+ rewrites = rewrite_weak_bullets(
271
+ results,
272
+ provider,
273
+ grounded_facts=grounded_facts or None,
274
+ addable_keywords=addable_keywords or None,
275
+ )
276
+ except Exception as exc:
277
+ log.write(f"[red]Rewrite failed: {exc}[/red]")
278
+ rewrites = []
279
+
280
+ # --- Score-aware pass: retry rewrites that regressed ATS dimensions ---
281
+ if rewrites:
282
+ status.status_text = "Score-aware check..."
283
+ try:
284
+ optimized = optimize_rewrites(
285
+ rewrites,
286
+ group_results_by_bullet(results),
287
+ provider,
288
+ grounded_facts=grounded_facts or None,
289
+ addable_keywords=addable_keywords or None,
290
+ )
291
+ retried = sum(1 for o in optimized if o.retried)
292
+ fixed = sum(1 for o in optimized if o.retry_fixed)
293
+ if retried:
294
+ log.write(
295
+ f"Score-aware pass: {retried} rewrite(s) regressed,"
296
+ f" {fixed} fixed on retry."
297
+ )
298
+ rewrites = [o.result for o in optimized]
299
+ except Exception as exc:
300
+ log.write(f"[yellow]Score-aware pass failed: {exc}[/yellow]")
301
+
302
+ if not rewrites:
303
+ self.call_from_thread(diff.show_no_rewrites)
304
+ log.write("[green]All bullets verified — no rewrites needed.[/green]")
305
+ status.status_text = "Done (no rewrites needed)"
306
+ return
307
+
308
+ actual_changes = 0
309
+ for rw in rewrites:
310
+ self.call_from_thread(diff.add_rewrite, rw)
311
+ if rw.completion:
312
+ status.add_tokens(
313
+ rw.completion.input_tokens, rw.completion.output_tokens
314
+ )
315
+ if rw.original != rw.rewritten:
316
+ actual_changes += 1
317
+
318
+ log.write(
319
+ f"Generated {len(rewrites)} rewrite(s), {actual_changes} with changes."
320
+ )
321
+
322
+ if actual_changes == 0:
323
+ log.write("[green]No actual changes to apply.[/green]")
324
+ status.status_text = "Done"
325
+ return
326
+
327
+ # --- Surgical TeX replacement ---
328
+ status.status_text = "Generating .tex file..."
329
+ log.write("")
330
+ log.write("[bold]Producing optimized .tex file...[/bold]")
331
+
332
+ section_filter = self.sections[0] if self.sections else None
333
+ try:
334
+ report = rewrite_tex(
335
+ self.resume_path,
336
+ rewrites,
337
+ output_path=self.output_path,
338
+ section_filter=section_filter,
339
+ )
340
+ except Exception as exc:
341
+ log.write(f"[red]TeX generation failed: {exc}[/red]")
342
+ status.status_text = "Done (TeX generation failed)"
343
+ return
344
+
345
+ output_log.write("")
346
+ output_log.write("[bold]Optimized TeX File[/bold]")
347
+ output_log.write("")
348
+ output_log.write(f" Replacements: [green]{report.replacements}[/green]")
349
+ if report.skipped:
350
+ output_log.write(
351
+ f" Skipped: [yellow]{report.skipped}[/yellow] "
352
+ f"(could not match to TeX source)"
353
+ )
354
+ output_log.write(f" Output: [bold]{report.output_path}[/bold]")
355
+
356
+ log.write(f" Replacements: {report.replacements}")
357
+ log.write(f" Output: {report.output_path}")
358
+
359
+ # --- Optional PDF compilation ---
360
+ if self.compile_pdf:
361
+ status.status_text = "Compiling PDF..."
362
+ log.write("")
363
+ log.write("[bold]Compiling to PDF...[/bold]")
364
+
365
+ from receipts.rewrite.compiler import compile_tex
366
+
367
+ comp = compile_tex(report.output_path)
368
+
369
+ if comp.success:
370
+ output_log.write("")
371
+ output_log.write(
372
+ f" PDF: [bold green]{comp.pdf_path}[/bold green]"
373
+ )
374
+ output_log.write(f" Compiler: {comp.tool_used}")
375
+ log.write(
376
+ f" [green]PDF: {comp.pdf_path} (via {comp.tool_used})[/green]"
377
+ )
378
+ else:
379
+ output_log.write("")
380
+ output_log.write(
381
+ f" [yellow]PDF compilation failed: {comp.error}[/yellow]"
382
+ )
383
+ output_log.write(" You can compile manually or use Overleaf.")
384
+ log.write(f" [yellow]Compilation failed: {comp.error}[/yellow]")
385
+
386
+ # --- ATS score comparison if JD provided ---
387
+ if self.jd_path and self.jd_path.is_file():
388
+ status.status_text = "Scoring..."
389
+ log.write("")
390
+ log.write("[bold]Scoring original vs optimized...[/bold]")
391
+
392
+ from receipts.ats.comparator import compare_tex_files
393
+
394
+ jd_text = self.jd_path.read_text(encoding="utf-8")
395
+
396
+ try:
397
+ comparison = compare_tex_files(
398
+ self.resume_path, report.output_path, jd_text
399
+ )
400
+
401
+ output_log.write("")
402
+ output_log.write("[bold]━━━ ATS Score Comparison ━━━[/bold]")
403
+ output_log.write("")
404
+ output_log.write(
405
+ f" Composite: {comparison.before.composite:.1f} → "
406
+ f"{comparison.after.composite:.1f}"
407
+ )
408
+
409
+ if comparison.improved:
410
+ output_log.write(
411
+ f" [bold green]Improved by "
412
+ f"{comparison.composite_delta:+.1f} points[/bold green]"
413
+ )
414
+ else:
415
+ output_log.write(
416
+ f" [bold yellow]Changed by "
417
+ f"{comparison.composite_delta:+.1f} points[/bold yellow]"
418
+ )
419
+
420
+ output_log.write("")
421
+ for d in comparison.deltas:
422
+ if d.delta > 0:
423
+ color = "green"
424
+ elif d.delta < 0:
425
+ color = "red"
426
+ else:
427
+ color = "dim"
428
+ output_log.write(
429
+ f" [{color}]{d.name:<22} "
430
+ f"{d.before:>3} → {d.after:>3} ({d.delta:+d})[/{color}]"
431
+ )
432
+
433
+ log.write(
434
+ f" Composite: {comparison.before.composite:.1f} → {comparison.after.composite:.1f}"
435
+ )
436
+ except Exception as exc:
437
+ log.write(f" [red]Scoring failed: {exc}[/red]")
438
+
439
+ status.status_text = "Done"
440
+ log.write("")
441
+ log.write("[bold green]Rewrite pipeline complete.[/bold green]")
442
+
443
+ def action_show_tab(self, tab_id: str) -> None:
444
+ tc = self.query_one("#main-area", TabbedContent)
445
+ tc.active = tab_id
446
+
447
+ def on_data_table_row_selected(self, event) -> None:
448
+ """Selecting a claims row shows its FULL details in the Log tab."""
449
+ table = self.query_one("#claims-table", ClaimsTable)
450
+ result = table.result_at(event.cursor_row)
451
+ if result is None:
452
+ return
453
+ log = self.query_one("#activity-log", RichLog)
454
+ log.write("")
455
+ log.write(f"[bold]━━━ Claim details ({result.verdict}) ━━━[/bold]")
456
+ log.write(
457
+ f"[bold]Claim:[/bold] {result.claim.claim_type} — {result.claim.value}"
458
+ )
459
+ log.write(f"[bold]Bullet:[/bold] {result.claim.bullet_text}")
460
+ log.write(f"[bold]Why:[/bold] {result.explanation}")
461
+ for e in result.evidence[:3]:
462
+ log.write(f" evidence: {e.rel_path} :: {e.name}")
463
+ self.action_show_tab("log-pane")
@@ -0,0 +1,237 @@
1
+ """Textual TUI for the ATS scoring & comparison flow.
2
+
3
+ Full-screen dashboard: dimension scores with visual bars, bullet analysis,
4
+ optional before/after comparison, and statistical confidence intervals.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from textual.app import App, ComposeResult
12
+ from textual.widgets import DataTable, Footer, Header, RichLog, TabbedContent, TabPane
13
+
14
+ from receipts.tui.widgets.status_bar import StatusBar
15
+
16
+
17
+ class ScoreApp(App):
18
+ """Full-screen TUI for ATS resume scoring."""
19
+
20
+ TITLE = "Receipts"
21
+ SUB_TITLE = "ATS score"
22
+
23
+ CSS = """
24
+ #score-log {
25
+ height: 1fr;
26
+ }
27
+ #bullet-table {
28
+ height: 1fr;
29
+ }
30
+ """
31
+
32
+ BINDINGS = [
33
+ ("q", "quit", "Quit"),
34
+ ("s", "show_tab('score-pane')", "Scores"),
35
+ ("b", "show_tab('bullets-pane')", "Bullets"),
36
+ ("l", "show_tab('log-pane')", "Log"),
37
+ ]
38
+
39
+ def __init__(
40
+ self,
41
+ resume_path: Path,
42
+ jd_path: Path,
43
+ *,
44
+ compare_path: Path | None = None,
45
+ show_stats: bool = False,
46
+ **kwargs,
47
+ ) -> None:
48
+ super().__init__(**kwargs)
49
+ self.resume_path = resume_path
50
+ self.jd_path = jd_path
51
+ self.compare_path = compare_path
52
+ self.show_stats = show_stats
53
+
54
+ def compose(self) -> ComposeResult:
55
+ yield Header()
56
+ with TabbedContent(id="main-area"):
57
+ with TabPane("Scores", id="score-pane"):
58
+ yield RichLog(id="score-log", markup=True, wrap=True)
59
+ with TabPane("Bullets", id="bullets-pane"):
60
+ yield DataTable(id="bullet-table")
61
+ with TabPane("Log", id="log-pane"):
62
+ yield RichLog(id="activity-log", markup=True, wrap=True)
63
+ yield StatusBar(id="status-bar")
64
+ yield Footer()
65
+
66
+ def on_mount(self) -> None:
67
+ bt = self.query_one("#bullet-table", DataTable)
68
+ bt.add_columns("Bullet", "Section", "Action Verb", "Has Number", "Words")
69
+ bt.cursor_type = "row"
70
+ bt.zebra_stripes = True
71
+
72
+ self.run_worker(self._run_score, thread=True)
73
+
74
+ async def _run_score(self) -> None:
75
+ score_log = self.query_one("#score-log", RichLog)
76
+ activity = self.query_one("#activity-log", RichLog)
77
+ bt = self.query_one("#bullet-table", DataTable)
78
+ status = self.query_one("#status-bar", StatusBar)
79
+
80
+ from receipts.ats.scorer import score_tex_file
81
+
82
+ activity.write("[bold]Starting ATS scoring...[/bold]")
83
+
84
+ if not self.resume_path.is_file():
85
+ activity.write(f"[red]Resume not found: {self.resume_path}[/red]")
86
+ return
87
+ if not self.jd_path.is_file():
88
+ activity.write(f"[red]JD file not found: {self.jd_path}[/red]")
89
+ return
90
+
91
+ jd_text = self.jd_path.read_text(encoding="utf-8")
92
+
93
+ status.status_text = "Scoring resume..."
94
+ activity.write(
95
+ f"Scoring [bold]{self.resume_path.name}[/bold] against [bold]{self.jd_path.name}[/bold]..."
96
+ )
97
+
98
+ result = score_tex_file(self.resume_path, jd_text)
99
+
100
+ activity.write(f" Composite: {result.composite:.1f}/100")
101
+ activity.write(f" Bullets analyzed: {len(result.bullet_scores)}")
102
+
103
+ # --- Render dimension scores ---
104
+ score_log.write("")
105
+ score_log.write(f"[bold]ATS Score: {result.composite:.1f}/100[/bold]")
106
+ score_log.write("")
107
+
108
+ for dim in result.dimensions:
109
+ filled = dim.score // 5
110
+ empty = 20 - filled
111
+
112
+ if dim.score >= 70:
113
+ color = "green"
114
+ elif dim.score >= 40:
115
+ color = "yellow"
116
+ else:
117
+ color = "red"
118
+
119
+ bar = f"[{color}]{'█' * filled}[/{color}]{'░' * empty}"
120
+ score_log.write(f" {dim.name:<22} {dim.score:>3}/100 {bar}")
121
+ score_log.write(f" [dim]{_escape(dim.detail)}[/dim]")
122
+ score_log.write("")
123
+
124
+ score_log.write(f" [dim]Bullets analyzed: {len(result.bullet_scores)}[/dim]")
125
+
126
+ # --- Populate bullet table ---
127
+ for bs in result.bullet_scores:
128
+ verb_str = "[green]Yes[/green]" if bs.has_action_verb else "[red]No[/red]"
129
+ num_str = "[green]Yes[/green]" if bs.has_number else "[red]No[/red]"
130
+ text = bs.text[:80] + "..." if len(bs.text) > 80 else bs.text
131
+ self.call_from_thread(
132
+ bt.add_row, text, bs.section, verb_str, num_str, str(bs.word_count)
133
+ )
134
+
135
+ # --- Comparison ---
136
+ if self.compare_path:
137
+ status.status_text = "Comparing..."
138
+ activity.write("")
139
+ activity.write(
140
+ f"Comparing against [bold]{self.compare_path.name}[/bold]..."
141
+ )
142
+
143
+ if not self.compare_path.is_file():
144
+ activity.write(
145
+ f"[red]Comparison file not found: {self.compare_path}[/red]"
146
+ )
147
+ else:
148
+ from receipts.ats.comparator import compare_tex_files
149
+
150
+ report = compare_tex_files(self.resume_path, self.compare_path, jd_text)
151
+
152
+ score_log.write("")
153
+ score_log.write("[bold]━━━ Before / After Comparison ━━━[/bold]")
154
+ score_log.write("")
155
+ score_log.write(
156
+ f" Composite: {report.before.composite:.1f} → "
157
+ f"{report.after.composite:.1f} "
158
+ )
159
+
160
+ if report.improved:
161
+ score_log.write(
162
+ f" [bold green]Improved by {report.composite_delta:+.1f} points[/bold green]"
163
+ )
164
+ else:
165
+ score_log.write(
166
+ f" [bold yellow]Changed by {report.composite_delta:+.1f} points[/bold yellow]"
167
+ )
168
+
169
+ score_log.write("")
170
+
171
+ for d in report.deltas:
172
+ if d.delta > 0:
173
+ color = "green"
174
+ arrow = "▲"
175
+ elif d.delta < 0:
176
+ color = "red"
177
+ arrow = "▼"
178
+ else:
179
+ color = "dim"
180
+ arrow = "─"
181
+
182
+ score_log.write(
183
+ f" [{color}]{arrow} {d.name:<22} "
184
+ f"{d.before:>3} → {d.after:>3} ({d.delta:+d})[/{color}]"
185
+ )
186
+
187
+ activity.write(f" Composite delta: {report.composite_delta:+.1f}")
188
+
189
+ # --- Statistical analysis ---
190
+ if self.show_stats:
191
+ status.status_text = "Computing statistics..."
192
+ from receipts.ats.stats import compute_improvement
193
+
194
+ improvement = compute_improvement(report.before, report.after)
195
+
196
+ score_log.write("")
197
+ score_log.write("[bold]━━━ Statistical Analysis ━━━[/bold]")
198
+ score_log.write("")
199
+
200
+ sig_color = "green" if improvement.significant else "yellow"
201
+ score_log.write(
202
+ f" Significant: [{sig_color}]"
203
+ f"{'Yes' if improvement.significant else 'No'}[/{sig_color}]"
204
+ )
205
+ score_log.write(
206
+ f" Bullets: {improvement.n_bullets_before} → "
207
+ f"{improvement.n_bullets_after}"
208
+ )
209
+ score_log.write("")
210
+
211
+ for ci, name in [
212
+ (improvement.keyword_match_ci, "Keyword match"),
213
+ (improvement.action_verbs_ci, "Action verbs"),
214
+ (improvement.quantification_ci, "Quantification"),
215
+ ]:
216
+ if ci:
217
+ score_log.write(
218
+ f" {name:<18} {ci.mean:+.2f} "
219
+ f"\\[{ci.lower:+.2f}, {ci.upper:+.2f}] "
220
+ f"({int(ci.confidence * 100)}% CI, n={ci.n_samples})"
221
+ )
222
+
223
+ activity.write(
224
+ f" Significant: {'Yes' if improvement.significant else 'No'}"
225
+ )
226
+
227
+ status.status_text = "Done"
228
+ activity.write("")
229
+ activity.write("[bold green]Scoring complete.[/bold green]")
230
+
231
+ def action_show_tab(self, tab_id: str) -> None:
232
+ tc = self.query_one("#main-area", TabbedContent)
233
+ tc.active = tab_id
234
+
235
+
236
+ def _escape(text: str) -> str:
237
+ return text.replace("[", "\\[")