codebase-receipts-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
- codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
- codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
- codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
- codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- receipts/__init__.py +0 -0
- receipts/ama/__init__.py +0 -0
- receipts/ama/interviewer.py +415 -0
- receipts/ats/__init__.py +1 -0
- receipts/ats/comparator.py +98 -0
- receipts/ats/scorer.py +550 -0
- receipts/ats/stats.py +164 -0
- receipts/cli.py +2062 -0
- receipts/config.py +106 -0
- receipts/errors.py +18 -0
- receipts/export.py +120 -0
- receipts/ingest/__init__.py +0 -0
- receipts/ingest/artifact_extractor.py +679 -0
- receipts/ingest/git_source.py +159 -0
- receipts/ingest/manifest.py +114 -0
- receipts/ingest/scanner.py +141 -0
- receipts/ingest/secrets_scanner.py +225 -0
- receipts/interactive/__init__.py +1 -0
- receipts/interactive/repl.py +755 -0
- receipts/ledger/__init__.py +0 -0
- receipts/ledger/pricing_table.py +54 -0
- receipts/ledger/token_ledger.py +226 -0
- receipts/llm/__init__.py +0 -0
- receipts/llm/anthropic_provider.py +95 -0
- receipts/llm/factory.py +124 -0
- receipts/llm/fake_provider.py +79 -0
- receipts/llm/gemini_provider.py +205 -0
- receipts/llm/json_utils.py +46 -0
- receipts/llm/metered.py +62 -0
- receipts/llm/ollama_provider.py +117 -0
- receipts/llm/openai_provider.py +118 -0
- receipts/llm/provider.py +42 -0
- receipts/llm/split.py +78 -0
- receipts/llm/token_estimate.py +35 -0
- receipts/mine/__init__.py +1 -0
- receipts/mine/code_metrics.py +246 -0
- receipts/mine/git_evidence.py +109 -0
- receipts/prep/__init__.py +1 -0
- receipts/prep/dossier.py +313 -0
- receipts/prep/readiness.py +227 -0
- receipts/resume/__init__.py +0 -0
- receipts/resume/claim_extractor.py +338 -0
- receipts/resume/loader.py +35 -0
- receipts/resume/pdf_parser.py +259 -0
- receipts/resume/tex_parser.py +394 -0
- receipts/rewrite/__init__.py +0 -0
- receipts/rewrite/compiler.py +180 -0
- receipts/rewrite/optimizer.py +140 -0
- receipts/rewrite/tex_rewriter.py +227 -0
- receipts/tui/__init__.py +0 -0
- receipts/tui/ama_app.py +454 -0
- receipts/tui/app.py +316 -0
- receipts/tui/dossier_app.py +170 -0
- receipts/tui/hub.py +367 -0
- receipts/tui/ingest_app.py +183 -0
- receipts/tui/rewrite_app.py +463 -0
- receipts/tui/score_app.py +237 -0
- receipts/tui/widgets/__init__.py +0 -0
- receipts/tui/widgets/claims_table.py +50 -0
- receipts/tui/widgets/diff_view.py +38 -0
- receipts/tui/widgets/status_bar.py +38 -0
- receipts/verify/__init__.py +0 -0
- receipts/verify/bm25.py +112 -0
- receipts/verify/enrich.py +128 -0
- receipts/verify/jd_fetch.py +101 -0
- receipts/verify/kb.py +379 -0
- receipts/verify/keyword_gap.py +127 -0
- receipts/verify/query_expand.py +88 -0
- receipts/verify/rerank.py +74 -0
- receipts/verify/rewriter.py +172 -0
- receipts/verify/router.py +211 -0
- receipts/verify/summaries.py +258 -0
- receipts/verify/verifier.py +552 -0
|
@@ -0,0 +1,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("[", "\\[")
|