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
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"
|