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/tui/app.py ADDED
@@ -0,0 +1,316 @@
1
+ """Textual TUI for the receipts verify flow.
2
+
3
+ Layout: left tree pane (ingested files), right tabbed content
4
+ (claims table, rewrites diff, activity log), persistent status bar.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from textual.app import App, ComposeResult
12
+ from textual.containers import Horizontal
13
+ from textual.widgets import (
14
+ Footer,
15
+ Header,
16
+ RichLog,
17
+ TabbedContent,
18
+ TabPane,
19
+ Tree,
20
+ )
21
+
22
+ from receipts.tui.widgets.claims_table import ClaimsTable
23
+ from receipts.tui.widgets.diff_view import DiffView
24
+ from receipts.tui.widgets.status_bar import StatusBar
25
+
26
+
27
+ class ReceiptsApp(App):
28
+ """Panelled TUI for resume claim verification."""
29
+
30
+ TITLE = "Receipts"
31
+ SUB_TITLE = "resume claim verifier"
32
+
33
+ CSS = """
34
+ #tree-pane {
35
+ width: 30;
36
+ dock: left;
37
+ border-right: solid $primary;
38
+ overflow-y: auto;
39
+ }
40
+ #main-area {
41
+ width: 1fr;
42
+ }
43
+ #activity-log {
44
+ height: 1fr;
45
+ }
46
+ """
47
+
48
+ BINDINGS = [
49
+ ("q", "quit", "Quit"),
50
+ ("c", "show_tab('claims-pane')", "Claims"),
51
+ ("r", "show_tab('rewrites-pane')", "Rewrites"),
52
+ ("l", "show_tab('log-pane')", "Log"),
53
+ ]
54
+
55
+ def __init__(
56
+ self,
57
+ resume_path: Path,
58
+ source_path: Path,
59
+ *,
60
+ threshold: float = 0.5,
61
+ sections: list[str] | None = None,
62
+ entries: list[str] | None = None,
63
+ kb_dir: Path | None = None,
64
+ **kwargs,
65
+ ) -> None:
66
+ super().__init__(**kwargs)
67
+ self.resume_path = resume_path
68
+ self.source_path = source_path
69
+ self.threshold = threshold
70
+ self.sections = sections
71
+ self.entries = entries
72
+ # Pre-resolved KB dir (git URLs / manifest aliases); None → derive
73
+ # from source_path like always.
74
+ self.kb_dir = kb_dir
75
+
76
+ def compose(self) -> ComposeResult:
77
+ yield Header()
78
+ with Horizontal():
79
+ yield Tree("Files", id="tree-pane")
80
+ with TabbedContent(id="main-area"):
81
+ with TabPane("Claims", id="claims-pane"):
82
+ yield ClaimsTable(id="claims-table")
83
+ with TabPane("Rewrites", id="rewrites-pane"):
84
+ yield DiffView(id="diff-view")
85
+ with TabPane("Log", id="log-pane"):
86
+ yield RichLog(id="activity-log", markup=True, wrap=True)
87
+ yield StatusBar(id="status-bar")
88
+ yield Footer()
89
+
90
+ def on_mount(self) -> None:
91
+ self.run_worker(self._run_verify, thread=True)
92
+
93
+ async def _run_verify(self) -> None:
94
+ log = self.query_one("#activity-log", RichLog)
95
+ status = self.query_one("#status-bar", StatusBar)
96
+ table = self.query_one("#claims-table", ClaimsTable)
97
+ diff = self.query_one("#diff-view", DiffView)
98
+ tree = self.query_one("#tree-pane", Tree)
99
+
100
+ log.write("[bold]Starting verification...[/bold]")
101
+
102
+ from receipts.config import load_settings
103
+ from receipts.llm.factory import get_provider
104
+ from receipts.resume.claim_extractor import extract_claims
105
+ from receipts.resume.loader import load_resume
106
+ from receipts.verify.kb import KnowledgeBase, default_kb_dir
107
+ from receipts.verify.rewriter import rewrite_weak_bullets
108
+ from receipts.verify.verifier import (
109
+ group_claims_by_bullet,
110
+ verify_bullet_claims,
111
+ verify_claim,
112
+ )
113
+
114
+ # --- Load provider ---
115
+ settings = load_settings()
116
+ try:
117
+ provider = get_provider(settings, verify=True)
118
+ except Exception as exc:
119
+ log.write(f"[red]Provider error: {exc}[/red]")
120
+ status.status_text = "Error"
121
+ return
122
+
123
+ status.provider_name = provider.name
124
+ log.write(f"Provider: [green]{provider.name}[/green]")
125
+
126
+ # Surface rate-limit waits in the status bar instead of hanging silently.
127
+ if hasattr(provider, "wait_callback"):
128
+
129
+ def _on_wait(reason: str, wait: int) -> None:
130
+ status.status_text = f"Rate-limited — waiting {wait}s..."
131
+ log.write(
132
+ f"[yellow]{reason} — waiting {wait}s before retrying...[/yellow]"
133
+ )
134
+
135
+ provider.wait_callback = _on_wait
136
+
137
+ # --- Open KB ---
138
+ kb_dir = self.kb_dir or default_kb_dir(self.source_path)
139
+ kb = KnowledgeBase(kb_dir)
140
+ if kb.count() == 0:
141
+ msg = "Knowledge base is empty. Run 'receipts ingest' first."
142
+ log.write(f"[red]{msg}[/red]")
143
+ status.status_text = "Error — empty KB"
144
+ return
145
+
146
+ log.write(f"Knowledge base: {kb.count()} artifact(s) in {kb_dir}")
147
+
148
+ # --- Populate file tree from KB ---
149
+ _populate_tree(tree, kb)
150
+
151
+ # --- Parse resume ---
152
+ status.status_text = "Parsing resume..."
153
+ log.write(f"Parsing [bold]{self.resume_path.name}[/bold] ...")
154
+ resume = load_resume(self.resume_path)
155
+ log.write(f" Name: {resume.name}")
156
+ log.write(f" Sections: {len(resume.sections)}")
157
+
158
+ claims = extract_claims(resume, sections=self.sections, entries=self.entries)
159
+ if self.entries:
160
+ log.write(f" Scoped to entries: {', '.join(self.entries)} (+ skills)")
161
+ log.write(f" Claims: {len(claims)}")
162
+
163
+ # --- Verify claims, batching per bullet on API providers ---
164
+ status.status_text = "Verifying claims..."
165
+ use_batch = provider.name != "ollama"
166
+ groups = group_claims_by_bullet(claims) if use_batch else [[c] for c in claims]
167
+ log.write("")
168
+ log.write(f"[bold]Verifying {len(claims)} claim(s)...[/bold]")
169
+ if use_batch:
170
+ log.write("[dim]Claims from the same bullet share one LLM call.[/dim]")
171
+
172
+ results = []
173
+ errors = 0
174
+ done = 0
175
+ for group in groups:
176
+ done += len(group)
177
+ status.status_text = f"Verifying {done}/{len(claims)}..."
178
+ try:
179
+ if use_batch:
180
+ group_results = verify_bullet_claims(
181
+ group, kb, provider, distance_threshold=self.threshold
182
+ )
183
+ else:
184
+ group_results = [
185
+ verify_claim(
186
+ group[0],
187
+ kb,
188
+ provider,
189
+ distance_threshold=self.threshold,
190
+ )
191
+ ]
192
+ except Exception as exc:
193
+ from receipts.errors import ProviderConfigError
194
+
195
+ if isinstance(exc, ProviderConfigError):
196
+ log.write(f"[red]Provider configuration error: {exc}[/red]")
197
+ log.write(
198
+ "[red]Aborting run — fix your .env / environment"
199
+ " (run 'receipts check') and retry.[/red]"
200
+ )
201
+ status.status_text = "Error — provider config"
202
+ return
203
+ errors += len(group)
204
+ log.write(
205
+ f" [red]ERROR verifying '{group[0].bullet_text[:50]}':"
206
+ f" {exc}[/red]"
207
+ )
208
+ continue
209
+
210
+ for result in group_results:
211
+ results.append(result)
212
+ self.call_from_thread(table.add_result, result)
213
+
214
+ if result.completion:
215
+ status.add_tokens(
216
+ result.completion.input_tokens,
217
+ result.completion.output_tokens,
218
+ )
219
+
220
+ if result.verdict == "unsupported":
221
+ log.write(
222
+ f" [red]✗[/red] {result.claim.claim_type}:"
223
+ f" {result.claim.value}"
224
+ )
225
+ elif result.verdict == "plausible":
226
+ log.write(
227
+ f" [yellow]?[/yellow] {result.claim.claim_type}:"
228
+ f" {result.claim.value}"
229
+ )
230
+
231
+ if errors:
232
+ log.write(f"[red]{errors} claim(s) failed due to API errors.[/red]")
233
+
234
+ verified = sum(1 for r in results if r.verdict == "verified")
235
+ plausible = sum(1 for r in results if r.verdict == "plausible")
236
+ unsupported = sum(1 for r in results if r.verdict == "unsupported")
237
+
238
+ log.write("")
239
+ log.write(
240
+ f"[bold]Results:[/bold] "
241
+ f"[green]{verified} verified[/green], "
242
+ f"[yellow]{plausible} plausible[/yellow], "
243
+ f"[red]{unsupported} unsupported[/red]"
244
+ )
245
+
246
+ # --- Rewrite weak bullets ---
247
+ status.status_text = "Rewriting weak bullets..."
248
+ log.write("")
249
+ log.write("[bold]Generating rewrites for weak bullets...[/bold]")
250
+
251
+ try:
252
+ rewrites = rewrite_weak_bullets(results, provider)
253
+ except Exception as exc:
254
+ log.write(f"[red]Rewrite failed: {exc}[/red]")
255
+ rewrites = []
256
+
257
+ if not rewrites:
258
+ self.call_from_thread(diff.show_no_rewrites)
259
+ log.write("[green]No rewrites needed.[/green]")
260
+ else:
261
+ for rw in rewrites:
262
+ self.call_from_thread(diff.add_rewrite, rw)
263
+ if rw.completion:
264
+ status.add_tokens(
265
+ rw.completion.input_tokens,
266
+ rw.completion.output_tokens,
267
+ )
268
+ log.write(f"Generated {len(rewrites)} rewrite(s).")
269
+
270
+ status.status_text = "Done"
271
+ log.write("")
272
+ log.write("[bold green]Verification complete.[/bold green]")
273
+
274
+ def action_show_tab(self, tab_id: str) -> None:
275
+ tc = self.query_one("#main-area", TabbedContent)
276
+ tc.active = tab_id
277
+
278
+ def on_data_table_row_selected(self, event) -> None:
279
+ """Selecting a claims row shows its FULL details in the Log tab."""
280
+ table = self.query_one("#claims-table", ClaimsTable)
281
+ result = table.result_at(event.cursor_row)
282
+ if result is None:
283
+ return
284
+ log = self.query_one("#activity-log", RichLog)
285
+ log.write("")
286
+ log.write(f"[bold]━━━ Claim details ({result.verdict}) ━━━[/bold]")
287
+ log.write(
288
+ f"[bold]Claim:[/bold] {result.claim.claim_type} — {result.claim.value}"
289
+ )
290
+ log.write(f"[bold]Bullet:[/bold] {result.claim.bullet_text}")
291
+ log.write(f"[bold]Why:[/bold] {result.explanation}")
292
+ for e in result.evidence[:3]:
293
+ log.write(f" evidence: {e.rel_path} :: {e.name}")
294
+ self.action_show_tab("log-pane")
295
+
296
+
297
+ def _populate_tree(tree: Tree, kb: object) -> None:
298
+ """Build a tree of file paths from KB metadata."""
299
+ raw = kb._collection.get(include=["metadatas"])
300
+ paths: set[str] = set()
301
+ for meta in raw.get("metadatas", []) or []:
302
+ if meta and "rel_path" in meta:
303
+ paths.add(meta["rel_path"])
304
+
305
+ tree.root.expand()
306
+ dirs: dict[str, object] = {}
307
+
308
+ for path in sorted(paths):
309
+ parts = path.replace("\\", "/").split("/")
310
+ parent = tree.root
311
+ for i, part in enumerate(parts[:-1]):
312
+ key = "/".join(parts[: i + 1])
313
+ if key not in dirs:
314
+ dirs[key] = parent.add(part, expand=True)
315
+ parent = dirs[key]
316
+ parent.add_leaf(parts[-1])
@@ -0,0 +1,170 @@
1
+ """Textual TUI for the dossier flow — verify, build, preview the study doc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import App, ComposeResult
8
+ from textual.widgets import Footer, Header, Markdown, RichLog, TabbedContent, TabPane
9
+
10
+ from receipts.tui.widgets.status_bar import StatusBar
11
+
12
+
13
+ class DossierApp(App):
14
+ """Full-screen dossier builder with an in-app markdown preview."""
15
+
16
+ TITLE = "Receipts"
17
+ SUB_TITLE = "defense dossier"
18
+
19
+ CSS = """
20
+ #dossier-preview {
21
+ height: 1fr;
22
+ padding: 0 1;
23
+ }
24
+ #activity-log {
25
+ height: 1fr;
26
+ }
27
+ """
28
+
29
+ BINDINGS = [
30
+ ("q", "quit", "Quit"),
31
+ ("p", "show_tab('preview-pane')", "Preview"),
32
+ ("l", "show_tab('log-pane')", "Log"),
33
+ ]
34
+
35
+ def __init__(
36
+ self,
37
+ resume_path: Path,
38
+ source_path: Path,
39
+ *,
40
+ threshold: float = 0.5,
41
+ sections: list[str] | None = None,
42
+ entries: list[str] | None = None,
43
+ output_path: Path | None = None,
44
+ **kwargs,
45
+ ) -> None:
46
+ super().__init__(**kwargs)
47
+ self.resume_path = resume_path
48
+ self.source_path = source_path
49
+ self.threshold = threshold
50
+ self.sections = sections
51
+ self.entries = entries
52
+ self.output_path = output_path or Path("dossier.md")
53
+
54
+ def compose(self) -> ComposeResult:
55
+ yield Header()
56
+ with TabbedContent(id="main-area"):
57
+ with TabPane("Preview", id="preview-pane"):
58
+ yield Markdown("*Building dossier...*", id="dossier-preview")
59
+ with TabPane("Log", id="log-pane"):
60
+ yield RichLog(id="activity-log", markup=True, wrap=True)
61
+ yield StatusBar(id="status-bar")
62
+ yield Footer()
63
+
64
+ def on_mount(self) -> None:
65
+ self.run_worker(self._run_dossier, thread=True)
66
+
67
+ async def _run_dossier(self) -> None:
68
+ log = self.query_one("#activity-log", RichLog)
69
+ status = self.query_one("#status-bar", StatusBar)
70
+ preview = self.query_one("#dossier-preview", Markdown)
71
+
72
+ from datetime import date
73
+
74
+ from receipts.config import load_settings
75
+ from receipts.llm.factory import get_provider
76
+ from receipts.prep.dossier import build_dossier, render_markdown
77
+ from receipts.prep.readiness import ReadinessStore
78
+ from receipts.resume.claim_extractor import extract_claims
79
+ from receipts.resume.loader import load_resume
80
+ from receipts.verify.kb import KnowledgeBase, default_kb_dir
81
+ from receipts.verify.verifier import verify_claims
82
+
83
+ settings = load_settings()
84
+ try:
85
+ provider = get_provider(settings, verify=True)
86
+ except Exception as exc:
87
+ log.write(f"[red]Provider error: {exc}[/red]")
88
+ status.status_text = "Error — provider"
89
+ return
90
+
91
+ status.provider_name = provider.name
92
+ if hasattr(provider, "wait_callback"):
93
+
94
+ def _on_wait(reason: str, wait: int) -> None:
95
+ status.status_text = f"Rate-limited — waiting {wait}s..."
96
+ log.write(f"[yellow]{reason} — waiting {wait}s...[/yellow]")
97
+
98
+ provider.wait_callback = _on_wait
99
+
100
+ kb = KnowledgeBase(default_kb_dir(self.source_path))
101
+ if kb.count() == 0:
102
+ log.write(
103
+ "[red]Knowledge base is empty. Run 'receipts ingest' first.[/red]"
104
+ )
105
+ status.status_text = "Error — empty KB"
106
+ return
107
+
108
+ status.status_text = "Parsing resume..."
109
+ resume = load_resume(self.resume_path)
110
+ claims = extract_claims(resume, sections=self.sections, entries=self.entries)
111
+ log.write(f"Claims: {len(claims)}")
112
+
113
+ status.status_text = "Verifying claims..."
114
+ log.write(f"Verifying {len(claims)} claim(s)...")
115
+ try:
116
+ results = verify_claims(
117
+ claims,
118
+ kb,
119
+ provider,
120
+ distance_threshold=self.threshold,
121
+ batch=provider.name != "ollama",
122
+ )
123
+ except Exception as exc:
124
+ log.write(f"[red]Verification failed: {exc}[/red]")
125
+ status.status_text = "Error — verification"
126
+ return
127
+
128
+ store = ReadinessStore()
129
+ try:
130
+ weak_spots = store.weak_spots()
131
+ finally:
132
+ store.close()
133
+ if weak_spots:
134
+ log.write(f"Including {len(weak_spots)} weak spot(s) from past sessions.")
135
+
136
+ log.write("")
137
+ log.write("[bold]Building dossier (one LLM call per bullet)...[/bold]")
138
+
139
+ def _progress(done: int, total: int) -> None:
140
+ status.status_text = f"Dossier bullet {done}/{total}..."
141
+ log.write(f" bullet {done}/{total}")
142
+
143
+ try:
144
+ entries_built = build_dossier(
145
+ results, provider, weak_spots=weak_spots, on_progress=_progress
146
+ )
147
+ except Exception as exc:
148
+ log.write(f"[red]Dossier build failed: {exc}[/red]")
149
+ status.status_text = "Error — dossier"
150
+ return
151
+
152
+ doc = render_markdown(
153
+ entries_built,
154
+ resume_name=resume.name,
155
+ source_name=self.source_path.name,
156
+ generated=date.today().isoformat(),
157
+ )
158
+ self.output_path.write_text(doc, encoding="utf-8")
159
+
160
+ self.call_from_thread(preview.update, doc)
161
+
162
+ log.write("")
163
+ log.write(f"[bold green]Dossier written: {self.output_path}[/bold green]")
164
+ log.write(f" Bullets covered: {len(entries_built)}")
165
+ log.write("[dim]Press p to read it here, or open the .md file.[/dim]")
166
+ status.status_text = "Done"
167
+
168
+ def action_show_tab(self, tab_id: str) -> None:
169
+ tc = self.query_one("#main-area", TabbedContent)
170
+ tc.active = tab_id