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/hub.py ADDED
@@ -0,0 +1,367 @@
1
+ """Unified TUI shell — dashboard, readiness, and ledger in one app.
2
+
3
+ One Textual application, one screen per zero-argument workflow, switchable
4
+ without exiting: `d` dashboard, `r` readiness, `l` ledger, `q` quits, `?`
5
+ shows help, `g` refreshes the current screen. Each command's ``--tui`` flag
6
+ deep-links straight to its screen (``receipts readiness --tui`` opens the
7
+ hub on the readiness screen).
8
+
9
+ Workflows that take arguments (verify, ama, rewrite, score, dossier,
10
+ ingest) keep their dedicated full-screen apps behind their own ``--tui``
11
+ flags — this shell unifies the always-available status/data screens.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import datetime
17
+
18
+ from textual.app import App, ComposeResult
19
+ from textual.containers import Horizontal, Vertical
20
+ from textual.screen import Screen
21
+ from textual.widgets import DataTable, Footer, Header, RichLog
22
+
23
+ from receipts.tui.widgets.status_bar import StatusBar
24
+
25
+ _SCREEN_KEYS = [
26
+ ("d", "switch('dashboard')", "Dashboard"),
27
+ ("r", "switch('readiness')", "Readiness"),
28
+ ("l", "switch('ledger')", "Ledger"),
29
+ ("g", "refresh_screen", "Refresh"),
30
+ ("question_mark", "help", "Help"),
31
+ ("q", "quit", "Quit"),
32
+ ]
33
+
34
+ _HELP_TEXT = (
35
+ "d dashboard · r readiness · l ledger · g refresh · q quit"
36
+ " — every screen shows the same footer, so you always know the keys."
37
+ )
38
+
39
+
40
+ class _HubScreen(Screen):
41
+ """Shared plumbing: status bar wiring + refresh hook."""
42
+
43
+ def on_screen_resume(self) -> None:
44
+ self.refresh_data()
45
+
46
+ def refresh_data(self) -> None: # overridden per screen
47
+ raise NotImplementedError
48
+
49
+ def _set_provider_status(self) -> None:
50
+ status = self.query_one(StatusBar)
51
+ try:
52
+ from receipts.config import load_settings
53
+
54
+ settings = load_settings()
55
+ status.provider_name = settings.provider
56
+ except Exception:
57
+ status.provider_name = "—"
58
+ status.status_text = self.SUB_TITLE or "Ready"
59
+
60
+
61
+ class DashboardScreen(_HubScreen):
62
+ SUB_TITLE = "dashboard"
63
+
64
+ CSS = """
65
+ #provider-panel, #ledger-panel, #readiness-panel {
66
+ height: 1fr;
67
+ border: solid $primary;
68
+ margin: 0 1;
69
+ }
70
+ .panel-row { height: 1fr; }
71
+ """
72
+
73
+ def compose(self) -> ComposeResult:
74
+ yield Header()
75
+ with Vertical():
76
+ with Horizontal(classes="panel-row"):
77
+ yield RichLog(id="provider-panel", markup=True, wrap=True)
78
+ yield RichLog(id="ledger-panel", markup=True, wrap=True)
79
+ yield RichLog(id="readiness-panel", markup=True, wrap=True)
80
+ yield StatusBar()
81
+ yield Footer()
82
+
83
+ def on_mount(self) -> None:
84
+ self.refresh_data()
85
+
86
+ def refresh_data(self) -> None:
87
+ for pid in ("provider-panel", "ledger-panel", "readiness-panel"):
88
+ self.query_one(f"#{pid}", RichLog).clear()
89
+ self._set_provider_status()
90
+ self.run_worker(self._load, thread=True)
91
+
92
+ async def _load(self) -> None:
93
+ self._load_provider()
94
+ self._load_ledger()
95
+ self._load_readiness()
96
+
97
+ def _load_provider(self) -> None:
98
+ panel = self.query_one("#provider-panel", RichLog)
99
+ panel.write("[bold]Provider[/bold]\n")
100
+
101
+ from receipts.config import load_settings
102
+ from receipts.llm.factory import get_provider
103
+
104
+ settings = load_settings()
105
+ panel.write(f"Configured: {settings.provider}")
106
+ try:
107
+ provider = get_provider(settings, verify=True)
108
+ except Exception as exc:
109
+ panel.write(f"[red]{exc}[/red]")
110
+ return
111
+
112
+ chat_model = getattr(provider, "chat_model", None)
113
+ embed_model = getattr(provider, "embed_model", None)
114
+ embed_via = getattr(provider, "embed_provider_name", None)
115
+ if chat_model:
116
+ panel.write(f"Chat model: {chat_model}")
117
+ if embed_model:
118
+ suffix = f" (via {embed_via})" if embed_via else ""
119
+ panel.write(f"Embed model: {embed_model}{suffix}")
120
+ panel.write("[green]Connected ✓[/green]")
121
+
122
+ def _load_ledger(self) -> None:
123
+ panel = self.query_one("#ledger-panel", RichLog)
124
+ panel.write("[bold]Token ledger (lifetime)[/bold]\n")
125
+
126
+ from receipts.ledger.token_ledger import TokenLedger
127
+
128
+ tl = TokenLedger()
129
+ try:
130
+ lt = tl.lifetime_summary()
131
+ finally:
132
+ tl.close()
133
+
134
+ if lt.call_count == 0:
135
+ panel.write("No LLM calls recorded yet.")
136
+ return
137
+ panel.write(f"Sessions: {lt.session_count}")
138
+ panel.write(f"LLM calls: {lt.call_count}")
139
+ panel.write(f"Input tokens: {lt.total_input:,}")
140
+ panel.write(f"Output tokens: {lt.total_output:,}")
141
+ panel.write(f"Est. cost: ${lt.total_cost:.4f}")
142
+ panel.write("\n[dim]Press l for the full ledger screen.[/dim]")
143
+
144
+ def _load_readiness(self) -> None:
145
+ panel = self.query_one("#readiness-panel", RichLog)
146
+ panel.write("[bold]Interview readiness[/bold]\n")
147
+
148
+ from receipts.prep.readiness import ReadinessStore, readiness_delta
149
+
150
+ store = ReadinessStore()
151
+ try:
152
+ sessions = store.sessions()
153
+ weakest = store.weakest_section()
154
+ finally:
155
+ store.close()
156
+
157
+ if not sessions:
158
+ panel.write("No saved interview sessions yet — run 'receipts ama'.")
159
+ return
160
+
161
+ for s in sessions[-5:]:
162
+ when = datetime.fromtimestamp(s.timestamp).strftime("%Y-%m-%d %H:%M")
163
+ panel.write(
164
+ f"{when} {s.resume_name[:20]:<20}"
165
+ f" {s.held_rounds}/{s.total_rounds} held up ({s.ratio:.0%})"
166
+ )
167
+
168
+ delta = readiness_delta(sessions)
169
+ if delta:
170
+ latest, previous = delta
171
+ change = (latest - previous) * 100
172
+ color = "green" if change >= 0 else "red"
173
+ panel.write(
174
+ f"\n[{color}]Trend: {previous:.0%} → {latest:.0%}"
175
+ f" ({change:+.0f} pts)[/{color}]"
176
+ )
177
+
178
+ if weakest and weakest.ratio < 1.0:
179
+ panel.write(
180
+ f"\n[yellow]Drill next: {weakest.section}"
181
+ f" ({weakest.held}/{weakest.total} held up)[/yellow]"
182
+ )
183
+ panel.write("\n[dim]Press r for the full readiness screen.[/dim]")
184
+
185
+
186
+ class ReadinessScreen(_HubScreen):
187
+ SUB_TITLE = "readiness"
188
+
189
+ CSS = """
190
+ #sessions-table { height: 1fr; margin: 0 1; }
191
+ #detail-panel { height: 1fr; border: solid $primary; margin: 0 1; }
192
+ """
193
+
194
+ def compose(self) -> ComposeResult:
195
+ yield Header()
196
+ with Vertical():
197
+ yield DataTable(id="sessions-table", cursor_type="row")
198
+ yield RichLog(id="detail-panel", markup=True, wrap=True)
199
+ yield StatusBar()
200
+ yield Footer()
201
+
202
+ def on_mount(self) -> None:
203
+ table = self.query_one("#sessions-table", DataTable)
204
+ table.add_columns("When", "Resume", "Held up", "Rate", "Sections")
205
+ self.refresh_data()
206
+
207
+ def refresh_data(self) -> None:
208
+ self._set_provider_status()
209
+ table = self.query_one("#sessions-table", DataTable)
210
+ detail = self.query_one("#detail-panel", RichLog)
211
+ table.clear()
212
+ detail.clear()
213
+
214
+ from receipts.prep.readiness import ReadinessStore, readiness_delta
215
+
216
+ store = ReadinessStore()
217
+ try:
218
+ sessions = store.sessions()
219
+ weakest = store.weakest_section()
220
+ stumbles = store.weak_spots(limit=5)
221
+ finally:
222
+ store.close()
223
+
224
+ if not sessions:
225
+ detail.write("No saved interview sessions yet — run 'receipts ama'.")
226
+ return
227
+
228
+ for s in reversed(sessions): # newest first in the table
229
+ when = datetime.fromtimestamp(s.timestamp).strftime("%Y-%m-%d %H:%M")
230
+ secs = ", ".join(f"{st.section} {st.held}/{st.total}" for st in s.sections)
231
+ table.add_row(
232
+ when,
233
+ s.resume_name[:24],
234
+ f"{s.held_rounds}/{s.total_rounds}",
235
+ f"{s.ratio:.0%}",
236
+ secs[:60],
237
+ )
238
+
239
+ latest = sessions[-1]
240
+ detail.write("[bold]Latest session by section[/bold]\n")
241
+ for stat in sorted(latest.sections, key=lambda s: s.ratio):
242
+ bar_len = int(stat.ratio * 20)
243
+ bar = "█" * bar_len + "░" * (20 - bar_len)
244
+ detail.write(f" {stat.section:<24} {stat.held}/{stat.total} {bar}")
245
+
246
+ delta = readiness_delta(sessions)
247
+ if delta:
248
+ new, old = delta
249
+ change = (new - old) * 100
250
+ color = "green" if change >= 0 else "red"
251
+ detail.write(
252
+ f"\n[{color}]Trend: {old:.0%} → {new:.0%} ({change:+.0f} pts)[/{color}]"
253
+ )
254
+
255
+ if weakest and weakest.ratio < 1.0:
256
+ detail.write(
257
+ f"\n[yellow]Drill next: {weakest.section} —"
258
+ f' receipts ama <resume> <source> --section "{weakest.section}"'
259
+ "[/yellow]"
260
+ )
261
+
262
+ if stumbles:
263
+ detail.write("\n[bold]Recent stumbles[/bold]")
264
+ for spot in stumbles:
265
+ detail.write(f" • {spot.bullet_text[:70]}")
266
+ detail.write(f" [dim]{spot.assessment[:90]}[/dim]")
267
+ if spot.model_answer:
268
+ detail.write(
269
+ f" [blue]→ Better answer: {spot.model_answer[:110]}[/blue]"
270
+ )
271
+
272
+
273
+ class LedgerScreen(_HubScreen):
274
+ SUB_TITLE = "ledger"
275
+
276
+ CSS = """
277
+ #totals-panel { height: auto; max-height: 10; border: solid $primary; margin: 0 1; }
278
+ #models-table { height: 1fr; margin: 0 1; }
279
+ """
280
+
281
+ def compose(self) -> ComposeResult:
282
+ yield Header()
283
+ with Vertical():
284
+ yield RichLog(id="totals-panel", markup=True, wrap=True)
285
+ yield DataTable(id="models-table", cursor_type="row")
286
+ yield StatusBar()
287
+ yield Footer()
288
+
289
+ def on_mount(self) -> None:
290
+ table = self.query_one("#models-table", DataTable)
291
+ table.add_columns("Provider", "Model", "Calls", "Input", "Output", "Cost")
292
+ self.refresh_data()
293
+
294
+ def refresh_data(self) -> None:
295
+ self._set_provider_status()
296
+ totals = self.query_one("#totals-panel", RichLog)
297
+ table = self.query_one("#models-table", DataTable)
298
+ totals.clear()
299
+ table.clear()
300
+
301
+ from receipts.ledger.token_ledger import TokenLedger
302
+
303
+ tl = TokenLedger()
304
+ try:
305
+ lt = tl.lifetime_summary()
306
+ last = tl.last_session_summary()
307
+ models = tl.model_breakdown()
308
+ finally:
309
+ tl.close()
310
+
311
+ if lt.call_count == 0:
312
+ totals.write("No LLM calls recorded yet. Run a command first.")
313
+ return
314
+
315
+ totals.write(
316
+ f"[bold]Lifetime[/bold] {lt.session_count} sessions ·"
317
+ f" {lt.call_count} calls · {lt.total_input:,} in /"
318
+ f" {lt.total_output:,} out · ${lt.total_cost:.4f}"
319
+ )
320
+ if last is not None:
321
+ totals.write(
322
+ f"[bold]Last session[/bold] {last.call_count} calls ·"
323
+ f" {last.total_input:,} in / {last.total_output:,} out ·"
324
+ f" ${last.total_cost:.4f}"
325
+ )
326
+
327
+ for m in models:
328
+ table.add_row(
329
+ m.provider,
330
+ m.model,
331
+ str(m.call_count),
332
+ f"{m.total_input:,}",
333
+ f"{m.total_output:,}",
334
+ f"${m.total_cost:.4f}",
335
+ )
336
+
337
+
338
+ class HubApp(App):
339
+ """The unified shell. ``start`` picks the screen a --tui flag deep-links to."""
340
+
341
+ TITLE = "Receipts"
342
+ SUB_TITLE = "hub"
343
+ BINDINGS = _SCREEN_KEYS
344
+ SCREENS = {
345
+ "dashboard": DashboardScreen,
346
+ "readiness": ReadinessScreen,
347
+ "ledger": LedgerScreen,
348
+ }
349
+
350
+ def __init__(self, *, start: str = "dashboard", **kwargs) -> None:
351
+ super().__init__(**kwargs)
352
+ self._start = start if start in self.SCREENS else "dashboard"
353
+
354
+ def on_mount(self) -> None:
355
+ self.push_screen(self._start)
356
+
357
+ def action_switch(self, name: str) -> None:
358
+ if name in self.SCREENS and self.screen.SUB_TITLE != name:
359
+ self.switch_screen(name)
360
+
361
+ def action_refresh_screen(self) -> None:
362
+ screen = self.screen
363
+ if isinstance(screen, _HubScreen):
364
+ screen.refresh_data()
365
+
366
+ def action_help(self) -> None:
367
+ self.notify(_HELP_TEXT, title="Keys", timeout=6)
@@ -0,0 +1,183 @@
1
+ """Textual TUI for the ingest flow — scan, secrets, extract, embed."""
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, ProgressBar, RichLog
9
+
10
+ from receipts.tui.widgets.status_bar import StatusBar
11
+
12
+
13
+ class IngestApp(App):
14
+ """Full-screen ingest: live log + embedding progress bar."""
15
+
16
+ TITLE = "Receipts"
17
+ SUB_TITLE = "codebase ingest"
18
+
19
+ CSS = """
20
+ #ingest-log {
21
+ height: 1fr;
22
+ }
23
+ #embed-progress {
24
+ dock: bottom;
25
+ height: 1;
26
+ margin: 0 2;
27
+ }
28
+ """
29
+
30
+ BINDINGS = [("q", "quit", "Quit")]
31
+
32
+ def __init__(
33
+ self,
34
+ source_path: Path,
35
+ *,
36
+ token_budget: int | None = None,
37
+ **kwargs,
38
+ ) -> None:
39
+ super().__init__(**kwargs)
40
+ self.source_path = source_path
41
+ self.token_budget = token_budget
42
+
43
+ def compose(self) -> ComposeResult:
44
+ yield Header()
45
+ yield RichLog(id="ingest-log", markup=True, wrap=True)
46
+ yield ProgressBar(id="embed-progress", show_eta=False)
47
+ yield StatusBar(id="status-bar")
48
+ yield Footer()
49
+
50
+ def on_mount(self) -> None:
51
+ self.run_worker(self._run_ingest, thread=True)
52
+
53
+ async def _run_ingest(self) -> None:
54
+ log = self.query_one("#ingest-log", RichLog)
55
+ status = self.query_one("#status-bar", StatusBar)
56
+ progress = self.query_one("#embed-progress", ProgressBar)
57
+
58
+ from receipts.config import load_settings
59
+ from receipts.ingest import artifact_extractor, scanner, secrets_scanner
60
+ from receipts.llm.factory import get_provider
61
+ from receipts.verify.kb import KnowledgeBase, default_kb_dir
62
+
63
+ log.write(f"[bold]Ingesting {self.source_path}[/bold]")
64
+
65
+ # --- Scan ---
66
+ status.status_text = "Scanning..."
67
+ result = scanner.scan(self.source_path)
68
+ log.write(
69
+ f"Scanned {len(result.files)} file(s)" f" ({len(result.text_files)} text)."
70
+ )
71
+ if result.skipped_dirs:
72
+ log.write(f"Skipped dependency dirs: {', '.join(result.skipped_dirs)}")
73
+ if result.ignored_count:
74
+ log.write(f"{result.ignored_count} entries excluded by .gitignore.")
75
+
76
+ # --- Secrets scan (always before anything leaves the machine) ---
77
+ status.status_text = "Scanning for secrets..."
78
+ report = secrets_scanner.SecretsReport(
79
+ findings=secrets_scanner.flag_special_paths(
80
+ [f.rel_path for f in result.files], result.venv_dirs
81
+ )
82
+ )
83
+ for file in result.text_files:
84
+ report.findings.extend(
85
+ secrets_scanner.scan_text(
86
+ file.rel_path,
87
+ scanner.read_text(self.source_path, file.rel_path),
88
+ )
89
+ )
90
+ if report.findings:
91
+ color = "red" if report.has_high_severity else "yellow"
92
+ log.write(
93
+ f"[{color}]Secrets scan: {len(report.findings)} finding(s)[/{color}]"
94
+ )
95
+ for finding in report.findings[:10]:
96
+ log.write(f" [dim]{finding.rule}: {finding.rel_path}[/dim]")
97
+ else:
98
+ log.write("[green]Secrets scan: clean.[/green]")
99
+
100
+ # --- Extract ---
101
+ status.status_text = "Extracting artifacts..."
102
+ extraction = artifact_extractor.extract_artifacts(
103
+ self.source_path, result.files, token_budget=self.token_budget
104
+ )
105
+ budget_label = (
106
+ f"auto {extraction.budget:,}"
107
+ if extraction.auto_budget
108
+ else f"{extraction.budget:,}"
109
+ )
110
+ log.write(
111
+ f"Extracted {len(extraction.artifacts)} artifact(s),"
112
+ f" ~{extraction.used_tokens:,} tokens (budget {budget_label})."
113
+ )
114
+ if extraction.dropped_over_budget:
115
+ log.write(
116
+ f"[yellow]{extraction.dropped_over_budget} artifact(s)"
117
+ f" (~{extraction.dropped_tokens:,} tokens) dropped over budget"
118
+ " — raise --budget to include them.[/yellow]"
119
+ )
120
+ if extraction.redaction_count:
121
+ log.write(
122
+ f"{extraction.redaction_count} likely secret(s) redacted"
123
+ " from artifact content."
124
+ )
125
+
126
+ if not extraction.artifacts:
127
+ log.write("[yellow]No artifacts to store.[/yellow]")
128
+ status.status_text = "Done (nothing to store)"
129
+ return
130
+
131
+ # --- Provider + embed ---
132
+ settings = load_settings()
133
+ try:
134
+ provider = get_provider(settings, verify=True)
135
+ except Exception as exc:
136
+ log.write(f"[red]Provider error: {exc}[/red]")
137
+ status.status_text = "Error — provider"
138
+ return
139
+
140
+ status.provider_name = provider.name
141
+ if hasattr(provider, "wait_callback"):
142
+
143
+ def _on_wait(reason: str, wait: int) -> None:
144
+ status.status_text = f"Rate-limited — waiting {wait}s..."
145
+ log.write(f"[yellow]{reason} — waiting {wait}s...[/yellow]")
146
+
147
+ provider.wait_callback = _on_wait
148
+
149
+ kb = KnowledgeBase(default_kb_dir(self.source_path))
150
+ total = len(extraction.artifacts)
151
+ log.write("")
152
+ log.write(f"[bold]Syncing {total} artifact(s) via '{provider.name}'...[/bold]")
153
+ self.call_from_thread(progress.update, total=total, progress=0)
154
+
155
+ def _on_progress(done: int, _total: int) -> None:
156
+ # _total is the number being embedded (changed artifacts only),
157
+ # which can be smaller than the corpus on a re-ingest.
158
+ self.call_from_thread(progress.update, total=_total, progress=done)
159
+ status.status_text = f"Embedding {done}/{_total}..."
160
+
161
+ try:
162
+ stats = kb.sync_artifacts(
163
+ extraction.artifacts,
164
+ provider,
165
+ source=self.source_path.resolve().name,
166
+ on_progress=_on_progress,
167
+ )
168
+ except Exception as exc:
169
+ log.write(f"[red]Embedding failed: {exc}[/red]")
170
+ status.status_text = "Error — embedding"
171
+ return
172
+
173
+ if stats.embedded == 0:
174
+ self.call_from_thread(progress.update, total=total, progress=total)
175
+
176
+ log.write("")
177
+ log.write(
178
+ f"[bold green]Knowledge base: {stats.added} new,"
179
+ f" {stats.changed} changed, {stats.unchanged} unchanged,"
180
+ f" {stats.removed} stale removed — {kb.count()} total.[/bold green]"
181
+ )
182
+ log.write("[dim]Next: receipts tui <resume.tex> <this folder>[/dim]")
183
+ status.status_text = "Done"