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/cli.py
ADDED
|
@@ -0,0 +1,2062 @@
|
|
|
1
|
+
"""Typer entrypoint for the receipts CLI.
|
|
2
|
+
|
|
3
|
+
Subcommands arrive phase by phase; ``check`` works today and verifies your
|
|
4
|
+
provider configuration end to end without sending any content anywhere.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from receipts.errors import ReceiptsError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
help="Receipts — resume claim verifier & interview prep, grounded in your code.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def check() -> None:
|
|
24
|
+
"""Verify provider configuration and connectivity (sends no content)."""
|
|
25
|
+
from receipts.config import load_settings
|
|
26
|
+
from receipts.llm.factory import get_provider
|
|
27
|
+
|
|
28
|
+
settings = load_settings()
|
|
29
|
+
typer.echo(f"Configured provider: {settings.provider}")
|
|
30
|
+
try:
|
|
31
|
+
provider = get_provider(settings)
|
|
32
|
+
except ReceiptsError as exc:
|
|
33
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
34
|
+
raise typer.Exit(1) from exc
|
|
35
|
+
|
|
36
|
+
# Show the RESOLVED models — real environment variables silently beat
|
|
37
|
+
# .env, so this line is how a stale shell override gets spotted.
|
|
38
|
+
chat_model = getattr(provider, "chat_model", None)
|
|
39
|
+
embed_model = getattr(provider, "embed_model", None)
|
|
40
|
+
if chat_model:
|
|
41
|
+
typer.echo(f"Chat model: {chat_model}")
|
|
42
|
+
if embed_model:
|
|
43
|
+
embed_via = getattr(provider, "embed_provider_name", None)
|
|
44
|
+
suffix = f" (via {embed_via})" if embed_via else ""
|
|
45
|
+
typer.echo(f"Embed model: {embed_model}{suffix}")
|
|
46
|
+
typer.secho(f"OK - provider '{provider.name}' is ready.", fg=typer.colors.GREEN)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def setup() -> None:
|
|
51
|
+
"""First-time setup wizard — pick a provider, store your key, validate.
|
|
52
|
+
|
|
53
|
+
Writes user-level config to ~/.receipts/.env so it works from any
|
|
54
|
+
folder. A project-local .env still overrides it when present.
|
|
55
|
+
"""
|
|
56
|
+
from receipts.config import GLOBAL_CONFIG_PATH
|
|
57
|
+
|
|
58
|
+
typer.secho("Receipts setup", bold=True)
|
|
59
|
+
typer.echo("Config will be saved to: " + str(GLOBAL_CONFIG_PATH))
|
|
60
|
+
typer.echo("")
|
|
61
|
+
typer.echo("Pick your LLM provider:")
|
|
62
|
+
typer.echo(
|
|
63
|
+
" [1] ollama — fully private: nothing leaves your machine."
|
|
64
|
+
" Best with a GPU or a small model; slow on plain laptop CPUs"
|
|
65
|
+
)
|
|
66
|
+
typer.echo(
|
|
67
|
+
" [2] gemini — fastest on ordinary laptops, free API key from"
|
|
68
|
+
" https://aistudio.google.com/apikey (recommended for public code)"
|
|
69
|
+
)
|
|
70
|
+
typer.echo(" [3] anthropic — Claude, paid key (embeddings via a second provider)")
|
|
71
|
+
typer.echo(" [4] openai — OpenAI or any compatible host (Groq/OpenRouter)")
|
|
72
|
+
|
|
73
|
+
choice = typer.prompt("Provider [1-4]", default="1").strip()
|
|
74
|
+
provider = {"1": "ollama", "2": "gemini", "3": "anthropic", "4": "openai"}.get(
|
|
75
|
+
choice, choice.lower()
|
|
76
|
+
)
|
|
77
|
+
if provider not in ("ollama", "gemini", "anthropic", "openai"):
|
|
78
|
+
typer.secho(f"Unknown provider '{provider}'.", fg=typer.colors.RED, err=True)
|
|
79
|
+
raise typer.Exit(1)
|
|
80
|
+
|
|
81
|
+
values: dict[str, str] = {"RECEIPTS_PROVIDER": provider}
|
|
82
|
+
|
|
83
|
+
if provider == "ollama":
|
|
84
|
+
typer.echo("")
|
|
85
|
+
typer.echo("Ollama runs locally — no key needed. Make sure it's installed")
|
|
86
|
+
typer.echo("(https://ollama.com) and you've pulled models, e.g.:")
|
|
87
|
+
typer.echo(" ollama pull gemma3:4b && ollama pull nomic-embed-text")
|
|
88
|
+
values["OLLAMA_CHAT_MODEL"] = typer.prompt(
|
|
89
|
+
"Chat model", default="gemma3:4b"
|
|
90
|
+
).strip()
|
|
91
|
+
host = typer.prompt(
|
|
92
|
+
"Ollama endpoint (Enter for the standard local install)",
|
|
93
|
+
default="http://localhost:11434",
|
|
94
|
+
).strip()
|
|
95
|
+
if host and host != "http://localhost:11434":
|
|
96
|
+
values["OLLAMA_HOST"] = host
|
|
97
|
+
elif provider == "gemini":
|
|
98
|
+
typer.echo("")
|
|
99
|
+
typer.echo("Get a FREE key at https://aistudio.google.com/apikey")
|
|
100
|
+
values["GEMINI_API_KEY"] = typer.prompt(
|
|
101
|
+
"GEMINI_API_KEY", hide_input=True
|
|
102
|
+
).strip()
|
|
103
|
+
elif provider == "anthropic":
|
|
104
|
+
typer.echo("")
|
|
105
|
+
typer.echo("Get a key at https://console.anthropic.com/ (paid).")
|
|
106
|
+
values["ANTHROPIC_API_KEY"] = typer.prompt(
|
|
107
|
+
"ANTHROPIC_API_KEY", hide_input=True
|
|
108
|
+
).strip()
|
|
109
|
+
typer.echo("")
|
|
110
|
+
typer.echo("Claude has no embeddings API — pick an embed provider:")
|
|
111
|
+
embed = (
|
|
112
|
+
typer.prompt("Embed provider (ollama/gemini)", default="ollama")
|
|
113
|
+
.strip()
|
|
114
|
+
.lower()
|
|
115
|
+
)
|
|
116
|
+
values["RECEIPTS_EMBED_PROVIDER"] = embed
|
|
117
|
+
if embed == "gemini":
|
|
118
|
+
values["GEMINI_API_KEY"] = typer.prompt(
|
|
119
|
+
"GEMINI_API_KEY (free, https://aistudio.google.com/apikey)",
|
|
120
|
+
hide_input=True,
|
|
121
|
+
).strip()
|
|
122
|
+
elif provider == "openai":
|
|
123
|
+
typer.echo("")
|
|
124
|
+
values["OPENAI_API_KEY"] = typer.prompt(
|
|
125
|
+
"OPENAI_API_KEY", hide_input=True
|
|
126
|
+
).strip()
|
|
127
|
+
base = typer.prompt(
|
|
128
|
+
"OPENAI_BASE_URL (Enter for official OpenAI;"
|
|
129
|
+
" e.g. https://api.groq.com/openai/v1)",
|
|
130
|
+
default="",
|
|
131
|
+
).strip()
|
|
132
|
+
if base:
|
|
133
|
+
values["OPENAI_BASE_URL"] = base
|
|
134
|
+
values["OPENAI_CHAT_MODEL"] = typer.prompt(
|
|
135
|
+
"Chat model id on that host", default="gpt-4o-mini"
|
|
136
|
+
).strip()
|
|
137
|
+
|
|
138
|
+
_write_global_config(values)
|
|
139
|
+
typer.echo("")
|
|
140
|
+
typer.secho(f"Saved {GLOBAL_CONFIG_PATH}", fg=typer.colors.GREEN)
|
|
141
|
+
|
|
142
|
+
typer.echo("Validating...")
|
|
143
|
+
from receipts.config import load_settings
|
|
144
|
+
from receipts.llm.factory import get_provider
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
provider_obj = get_provider(load_settings())
|
|
148
|
+
except ReceiptsError as exc:
|
|
149
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
150
|
+
typer.echo("Fix the issue and re-run 'receipts setup' or edit the file above.")
|
|
151
|
+
raise typer.Exit(1) from exc
|
|
152
|
+
|
|
153
|
+
typer.secho(
|
|
154
|
+
f"OK — provider '{provider_obj.name}' is ready."
|
|
155
|
+
" Next: receipts ingest <your-project-folder>",
|
|
156
|
+
fg=typer.colors.GREEN,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _write_global_config(values: dict[str, str]) -> None:
|
|
161
|
+
"""Create or update ~/.receipts/.env, preserving unrelated lines."""
|
|
162
|
+
from receipts.config import GLOBAL_CONFIG_PATH
|
|
163
|
+
|
|
164
|
+
GLOBAL_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
lines: list[str] = []
|
|
166
|
+
seen: set[str] = set()
|
|
167
|
+
|
|
168
|
+
if GLOBAL_CONFIG_PATH.is_file():
|
|
169
|
+
for line in GLOBAL_CONFIG_PATH.read_text(encoding="utf-8").splitlines():
|
|
170
|
+
key = line.split("=", 1)[0].strip() if "=" in line else ""
|
|
171
|
+
if key in values:
|
|
172
|
+
lines.append(f"{key}={values[key]}")
|
|
173
|
+
seen.add(key)
|
|
174
|
+
else:
|
|
175
|
+
lines.append(line)
|
|
176
|
+
|
|
177
|
+
for key, value in values.items():
|
|
178
|
+
if key not in seen:
|
|
179
|
+
lines.append(f"{key}={value}")
|
|
180
|
+
|
|
181
|
+
GLOBAL_CONFIG_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _resolve_kb_source(spec: str) -> tuple[Path, str]:
|
|
185
|
+
"""Resolve a codebase spec to ``(kb_dir, display_label)``.
|
|
186
|
+
|
|
187
|
+
Accepts, in priority order: a git URL (durable URL-keyed KB), an existing
|
|
188
|
+
local folder (classic behavior), or a manifest alias recorded by a
|
|
189
|
+
previous ingest. Raises ReceiptsError with the known aliases otherwise.
|
|
190
|
+
"""
|
|
191
|
+
from receipts.ingest.git_source import (
|
|
192
|
+
kb_dir_for_url,
|
|
193
|
+
looks_like_git_url,
|
|
194
|
+
repo_slug,
|
|
195
|
+
)
|
|
196
|
+
from receipts.ingest.manifest import known_aliases, lookup_source
|
|
197
|
+
from receipts.verify.kb import default_kb_dir
|
|
198
|
+
|
|
199
|
+
if looks_like_git_url(spec):
|
|
200
|
+
return kb_dir_for_url(spec), repo_slug(spec)
|
|
201
|
+
|
|
202
|
+
path = Path(spec)
|
|
203
|
+
if path.is_dir():
|
|
204
|
+
return default_kb_dir(path), path.resolve().name
|
|
205
|
+
|
|
206
|
+
entry = lookup_source(spec)
|
|
207
|
+
if entry is not None:
|
|
208
|
+
return Path(entry.kb_dir), entry.alias or spec
|
|
209
|
+
|
|
210
|
+
aliases = known_aliases()
|
|
211
|
+
hint = f"\n Known sources: {', '.join(aliases)}" if aliases else ""
|
|
212
|
+
raise ReceiptsError(
|
|
213
|
+
f"'{spec}' is not a folder, a git URL, or a known alias.{hint}\n"
|
|
214
|
+
" Ingest it first: receipts ingest <folder-or-url>"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _resolve_jd(jd: str | None, jd_url: str | None) -> str | None:
|
|
219
|
+
"""Turn --jd / --jd-url into a JD file path (fetching when needed)."""
|
|
220
|
+
if jd and jd_url:
|
|
221
|
+
raise ReceiptsError("Pass either --jd or --jd-url, not both.")
|
|
222
|
+
if not jd_url:
|
|
223
|
+
return jd
|
|
224
|
+
|
|
225
|
+
import hashlib
|
|
226
|
+
|
|
227
|
+
from receipts.verify.jd_fetch import fetch_jd_text
|
|
228
|
+
|
|
229
|
+
typer.echo(f"Fetching JD from {jd_url} ...")
|
|
230
|
+
text = fetch_jd_text(jd_url)
|
|
231
|
+
cache_dir = Path.home() / ".receipts" / "jd"
|
|
232
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
name = hashlib.sha256(jd_url.encode()).hexdigest()[:8]
|
|
234
|
+
jd_path = cache_dir / f"jd_{name}.txt"
|
|
235
|
+
jd_path.write_text(text, encoding="utf-8")
|
|
236
|
+
typer.echo(f" {len(text):,} chars of text extracted → {jd_path}")
|
|
237
|
+
return str(jd_path)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command()
|
|
241
|
+
def ingest(
|
|
242
|
+
source: str = typer.Argument(
|
|
243
|
+
..., help="Local folder or git URL of the codebase to ingest."
|
|
244
|
+
),
|
|
245
|
+
token_budget: int | None = typer.Option(
|
|
246
|
+
None,
|
|
247
|
+
"--budget",
|
|
248
|
+
"--token-budget",
|
|
249
|
+
help="Token cap for extracted artifacts."
|
|
250
|
+
" Default: auto-scaled to the corpus size (up to 1M tokens).",
|
|
251
|
+
),
|
|
252
|
+
branch: str | None = typer.Option(
|
|
253
|
+
None,
|
|
254
|
+
"--branch",
|
|
255
|
+
help="Branch or tag to clone (git URLs only).",
|
|
256
|
+
),
|
|
257
|
+
full_history: bool = typer.Option(
|
|
258
|
+
False,
|
|
259
|
+
"--full-history",
|
|
260
|
+
help="Clone the full git history instead of a shallow clone"
|
|
261
|
+
" (git URLs only; needed for commit-timespan/contributor evidence).",
|
|
262
|
+
),
|
|
263
|
+
use_tui: bool = typer.Option(
|
|
264
|
+
False, "--tui", help="Use the full-screen TUI with a progress bar."
|
|
265
|
+
),
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Scan a codebase, report likely secrets, and extract key code artifacts."""
|
|
268
|
+
from receipts.ingest.git_source import (
|
|
269
|
+
canonical_git_url,
|
|
270
|
+
cloned_repo,
|
|
271
|
+
kb_dir_for_url,
|
|
272
|
+
looks_like_git_url,
|
|
273
|
+
repo_slug,
|
|
274
|
+
)
|
|
275
|
+
from receipts.ingest.manifest import record_source
|
|
276
|
+
from receipts.verify.kb import default_kb_dir
|
|
277
|
+
|
|
278
|
+
if use_tui:
|
|
279
|
+
root = Path(source)
|
|
280
|
+
if not root.is_dir():
|
|
281
|
+
typer.secho(f"Not a folder: {source}", fg=typer.colors.RED, err=True)
|
|
282
|
+
raise typer.Exit(1)
|
|
283
|
+
from receipts.tui.ingest_app import IngestApp
|
|
284
|
+
|
|
285
|
+
IngestApp(root, token_budget=token_budget).run()
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
if looks_like_git_url(source):
|
|
290
|
+
kb_dir = kb_dir_for_url(source)
|
|
291
|
+
alias = repo_slug(source)
|
|
292
|
+
typer.echo(f"Cloning {source} ...")
|
|
293
|
+
with cloned_repo(source, branch=branch, full_history=full_history) as root:
|
|
294
|
+
_run_ingest(root, token_budget, kb_dir=kb_dir, source_label=alias)
|
|
295
|
+
record_source(canonical_git_url(source), kb_dir, alias=alias, kind="remote")
|
|
296
|
+
typer.secho(
|
|
297
|
+
f"Registered alias '{alias}' — use it (or the URL) anywhere a"
|
|
298
|
+
" codebase folder is accepted.",
|
|
299
|
+
fg=typer.colors.GREEN,
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
root = Path(source)
|
|
303
|
+
if not root.is_dir():
|
|
304
|
+
typer.secho(f"Not a folder: {source}", fg=typer.colors.RED, err=True)
|
|
305
|
+
raise typer.Exit(1)
|
|
306
|
+
_run_ingest(root, token_budget)
|
|
307
|
+
record_source(
|
|
308
|
+
str(root.resolve()),
|
|
309
|
+
default_kb_dir(root),
|
|
310
|
+
alias=root.resolve().name,
|
|
311
|
+
kind="local",
|
|
312
|
+
)
|
|
313
|
+
except ReceiptsError as exc:
|
|
314
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
315
|
+
raise typer.Exit(1) from exc
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _run_ingest(
|
|
319
|
+
root: Path,
|
|
320
|
+
token_budget: int | None,
|
|
321
|
+
*,
|
|
322
|
+
kb_dir: Path | None = None,
|
|
323
|
+
source_label: str | None = None,
|
|
324
|
+
) -> None:
|
|
325
|
+
from receipts.ingest import artifact_extractor, scanner, secrets_scanner
|
|
326
|
+
|
|
327
|
+
result = scanner.scan(root)
|
|
328
|
+
typer.echo(result.render_tree())
|
|
329
|
+
if result.skipped_dirs:
|
|
330
|
+
typer.echo(f"Skipped dependency dirs: {', '.join(result.skipped_dirs)}")
|
|
331
|
+
if result.ignored_count:
|
|
332
|
+
typer.echo(f"{result.ignored_count} entries excluded by .gitignore.")
|
|
333
|
+
|
|
334
|
+
report = secrets_scanner.SecretsReport(
|
|
335
|
+
findings=secrets_scanner.flag_special_paths(
|
|
336
|
+
[f.rel_path for f in result.files], result.venv_dirs
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
for file in result.text_files:
|
|
340
|
+
report.findings.extend(
|
|
341
|
+
secrets_scanner.scan_text(
|
|
342
|
+
file.rel_path, scanner.read_text(root, file.rel_path)
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
typer.echo("")
|
|
346
|
+
color = typer.colors.RED if report.has_high_severity else None
|
|
347
|
+
typer.secho(report.render(), fg=color)
|
|
348
|
+
|
|
349
|
+
extraction = artifact_extractor.extract_artifacts(
|
|
350
|
+
root, result.files, token_budget=token_budget
|
|
351
|
+
)
|
|
352
|
+
typer.echo("")
|
|
353
|
+
budget_label = (
|
|
354
|
+
f"auto {extraction.budget:,}"
|
|
355
|
+
if extraction.auto_budget
|
|
356
|
+
else f"{extraction.budget:,}"
|
|
357
|
+
)
|
|
358
|
+
typer.echo(
|
|
359
|
+
f"Extracted {len(extraction.artifacts)} artifact(s), "
|
|
360
|
+
f"~{extraction.used_tokens:,} tokens (budget {budget_label})."
|
|
361
|
+
)
|
|
362
|
+
if extraction.dropped_over_budget:
|
|
363
|
+
by_dir = ", ".join(
|
|
364
|
+
f"{d}/ {n}"
|
|
365
|
+
for d, n in sorted(extraction.dropped_by_dir.items(), key=lambda kv: -kv[1])
|
|
366
|
+
)
|
|
367
|
+
typer.secho(
|
|
368
|
+
f" {extraction.dropped_over_budget} artifact(s)"
|
|
369
|
+
f" (~{extraction.dropped_tokens:,} tokens) dropped over budget:"
|
|
370
|
+
f" {by_dir}.",
|
|
371
|
+
fg=typer.colors.YELLOW,
|
|
372
|
+
)
|
|
373
|
+
typer.secho(
|
|
374
|
+
f" Corpus is ~{extraction.corpus_tokens:,} tokens — raise --budget"
|
|
375
|
+
" to include everything.",
|
|
376
|
+
fg=typer.colors.YELLOW,
|
|
377
|
+
)
|
|
378
|
+
if extraction.redaction_count:
|
|
379
|
+
typer.echo(
|
|
380
|
+
f" {extraction.redaction_count} likely secret(s) redacted from"
|
|
381
|
+
" artifact content."
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
_store_in_kb(root, extraction.artifacts, kb_dir=kb_dir, source_label=source_label)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _store_in_kb(
|
|
388
|
+
root: Path,
|
|
389
|
+
artifacts: list,
|
|
390
|
+
*,
|
|
391
|
+
kb_dir: Path | None = None,
|
|
392
|
+
source_label: str | None = None,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Embed artifacts and store in the knowledge base.
|
|
395
|
+
|
|
396
|
+
``kb_dir``/``source_label`` override the path-derived defaults — used by
|
|
397
|
+
remote ingests so the KB is keyed by the URL, not the temp clone dir.
|
|
398
|
+
"""
|
|
399
|
+
if not artifacts:
|
|
400
|
+
typer.echo("No artifacts to store in the knowledge base.")
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
from receipts.config import load_settings
|
|
404
|
+
from receipts.llm.factory import get_provider
|
|
405
|
+
from receipts.verify.kb import KnowledgeBase, default_kb_dir
|
|
406
|
+
|
|
407
|
+
settings = load_settings()
|
|
408
|
+
try:
|
|
409
|
+
provider = get_provider(settings, verify=True)
|
|
410
|
+
except ReceiptsError as exc:
|
|
411
|
+
typer.secho(
|
|
412
|
+
f"Skipping knowledge-base storage: {exc}",
|
|
413
|
+
fg=typer.colors.YELLOW,
|
|
414
|
+
)
|
|
415
|
+
typer.echo(
|
|
416
|
+
"Run 'receipts check' to diagnose, then re-run ingest to populate the KB."
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
import time
|
|
421
|
+
|
|
422
|
+
kb_dir = kb_dir or default_kb_dir(root)
|
|
423
|
+
kb = KnowledgeBase(kb_dir)
|
|
424
|
+
source_label = source_label or root.resolve().name
|
|
425
|
+
|
|
426
|
+
# --- Phase 22C: corpus-level artifacts (zero LLM, always on) ---
|
|
427
|
+
from receipts.verify.summaries import (
|
|
428
|
+
build_facts_artifact,
|
|
429
|
+
build_hierarchical_summaries,
|
|
430
|
+
build_import_map_artifact,
|
|
431
|
+
summaries_enabled,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
artifacts = list(artifacts)
|
|
435
|
+
facts_art = build_facts_artifact(root)
|
|
436
|
+
if facts_art is not None:
|
|
437
|
+
artifacts.append(facts_art)
|
|
438
|
+
import_art = build_import_map_artifact(artifacts)
|
|
439
|
+
if import_art is not None:
|
|
440
|
+
artifacts.append(import_art)
|
|
441
|
+
|
|
442
|
+
# --- Phase 22C: hierarchical summaries (LLM, opt-in, cached) ---
|
|
443
|
+
if summaries_enabled():
|
|
444
|
+
typer.echo("")
|
|
445
|
+
typer.echo("Building hierarchical summaries (RECEIPTS_SUMMARIES=1) ...")
|
|
446
|
+
|
|
447
|
+
def _sum_progress(done: int, total: int) -> None:
|
|
448
|
+
if done % 25 == 0 or done == total:
|
|
449
|
+
typer.echo(f" summarized {done}/{total} file(s)")
|
|
450
|
+
|
|
451
|
+
summary_arts = build_hierarchical_summaries(
|
|
452
|
+
artifacts,
|
|
453
|
+
provider,
|
|
454
|
+
cache_path=kb_dir / "summary_cache.json",
|
|
455
|
+
on_progress=_sum_progress,
|
|
456
|
+
)
|
|
457
|
+
typer.echo(f" {len(summary_arts)} summary artifact(s) added")
|
|
458
|
+
artifacts.extend(summary_arts)
|
|
459
|
+
|
|
460
|
+
# --- Phase 22B: contextual chunk enrichment (LLM, opt-in, cached) ---
|
|
461
|
+
from receipts.verify.enrich import contextualize_artifacts, enrich_enabled
|
|
462
|
+
|
|
463
|
+
if enrich_enabled():
|
|
464
|
+
typer.echo("")
|
|
465
|
+
typer.echo("Enriching chunks with context lines (RECEIPTS_ENRICH=1) ...")
|
|
466
|
+
|
|
467
|
+
def _enrich_progress(done: int, total: int) -> None:
|
|
468
|
+
if done % 25 == 0 or done == total:
|
|
469
|
+
typer.echo(f" contextualized {done}/{total}")
|
|
470
|
+
|
|
471
|
+
artifacts, new_lines = contextualize_artifacts(
|
|
472
|
+
artifacts,
|
|
473
|
+
provider,
|
|
474
|
+
cache_path=kb_dir / "enrich_cache.json",
|
|
475
|
+
on_progress=_enrich_progress,
|
|
476
|
+
)
|
|
477
|
+
typer.echo(
|
|
478
|
+
f" {new_lines} context line(s) generated,"
|
|
479
|
+
f" {len(artifacts) - new_lines} from cache/skipped"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
typer.echo("")
|
|
483
|
+
typer.echo(
|
|
484
|
+
f"Syncing {len(artifacts)} artifact(s) into the knowledge base"
|
|
485
|
+
f" via '{provider.name}' ..."
|
|
486
|
+
)
|
|
487
|
+
if provider.name == "gemini":
|
|
488
|
+
typer.echo(
|
|
489
|
+
" (free-tier quota pacing may pause between batches — this can"
|
|
490
|
+
" take a few minutes for large codebases)"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
start_time = time.monotonic()
|
|
494
|
+
|
|
495
|
+
def _progress(done: int, total: int) -> None:
|
|
496
|
+
elapsed = time.monotonic() - start_time
|
|
497
|
+
if 0 < done < total:
|
|
498
|
+
eta = elapsed / done * (total - done)
|
|
499
|
+
typer.echo(
|
|
500
|
+
f" embedded {done}/{total} — ETA {int(eta // 60)}:{int(eta % 60):02d}"
|
|
501
|
+
)
|
|
502
|
+
else:
|
|
503
|
+
typer.echo(f" embedded {done}/{total}")
|
|
504
|
+
|
|
505
|
+
stats = kb.sync_artifacts(
|
|
506
|
+
artifacts, provider, source=source_label, on_progress=_progress
|
|
507
|
+
)
|
|
508
|
+
typer.secho(
|
|
509
|
+
f"Knowledge base: {stats.added} new, {stats.changed} changed,"
|
|
510
|
+
f" {stats.unchanged} unchanged (embedding skipped),"
|
|
511
|
+
f" {stats.removed} stale removed — {kb.count()} artifact(s) in {kb_dir}",
|
|
512
|
+
fg=typer.colors.GREEN,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _resolve_entries(
|
|
517
|
+
resume_path: Path,
|
|
518
|
+
sections: list[str] | None,
|
|
519
|
+
entries: list[str] | None,
|
|
520
|
+
scope_all: bool,
|
|
521
|
+
) -> list[str] | None:
|
|
522
|
+
"""Decide which resume entries to verify against the ingested codebase.
|
|
523
|
+
|
|
524
|
+
Explicit filters (--section / --entry / --all) win. Otherwise, in an
|
|
525
|
+
interactive terminal, list the project & experience entries and let the
|
|
526
|
+
user pick the one(s) that match the ingested codebase — verifying an
|
|
527
|
+
unrelated project's claims wastes tokens and produces misleading
|
|
528
|
+
"unsupported" noise. Skills sections are always kept for JD matching.
|
|
529
|
+
"""
|
|
530
|
+
if entries or sections or scope_all:
|
|
531
|
+
return entries
|
|
532
|
+
if not sys.stdin.isatty():
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
from receipts.resume.claim_extractor import classify_section
|
|
536
|
+
from receipts.resume.loader import load_resume
|
|
537
|
+
|
|
538
|
+
resume = load_resume(resume_path)
|
|
539
|
+
candidates: list[tuple[str, str]] = [] # (section, heading)
|
|
540
|
+
for sec in resume.sections:
|
|
541
|
+
if classify_section(sec.name) not in ("project", "experience"):
|
|
542
|
+
continue
|
|
543
|
+
for entry in sec.entries:
|
|
544
|
+
if entry.heading:
|
|
545
|
+
candidates.append((sec.name, entry.heading))
|
|
546
|
+
|
|
547
|
+
if not candidates:
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
typer.echo("")
|
|
551
|
+
typer.secho("Which entries match the codebase you ingested?", bold=True)
|
|
552
|
+
typer.echo(
|
|
553
|
+
" (skills sections are always included for JD matching;"
|
|
554
|
+
" education/certs are skipped)"
|
|
555
|
+
)
|
|
556
|
+
typer.echo("")
|
|
557
|
+
for i, (sec_name, heading) in enumerate(candidates, 1):
|
|
558
|
+
typer.echo(f" [{i}] {heading} ({sec_name})")
|
|
559
|
+
typer.echo(" [a] everything — the whole resume (uses the most tokens)")
|
|
560
|
+
typer.echo("")
|
|
561
|
+
|
|
562
|
+
raw = typer.prompt("Pick numbers (comma-separated) or 'a'", default="a")
|
|
563
|
+
raw = raw.strip().lower()
|
|
564
|
+
if raw in ("a", "all", ""):
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
chosen: list[str] = []
|
|
568
|
+
for part in raw.split(","):
|
|
569
|
+
part = part.strip()
|
|
570
|
+
if part.isdigit() and 1 <= int(part) <= len(candidates):
|
|
571
|
+
chosen.append(candidates[int(part) - 1][1])
|
|
572
|
+
if not chosen:
|
|
573
|
+
typer.secho(
|
|
574
|
+
" No valid selection — verifying everything.", fg=typer.colors.YELLOW
|
|
575
|
+
)
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
typer.echo("")
|
|
579
|
+
typer.echo(f" Scoped to: {', '.join(chosen)} (+ skills sections)")
|
|
580
|
+
return chosen
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@app.command()
|
|
584
|
+
def verify(
|
|
585
|
+
resume: str = typer.Argument(
|
|
586
|
+
..., help="Path to the resume: .tex, text-based .pdf, or .txt."
|
|
587
|
+
),
|
|
588
|
+
source: str | None = typer.Argument(
|
|
589
|
+
None,
|
|
590
|
+
help="Codebase to verify against: a local folder, a git URL, or a"
|
|
591
|
+
" manifest alias (must have been ingested). Optional when --map"
|
|
592
|
+
" is used.",
|
|
593
|
+
),
|
|
594
|
+
rewrite: bool = typer.Option(
|
|
595
|
+
False, "--rewrite", help="Propose rewrites for weak bullets."
|
|
596
|
+
),
|
|
597
|
+
jd: str | None = typer.Option(
|
|
598
|
+
None, "--jd", help="Path to a job description text file for gap analysis."
|
|
599
|
+
),
|
|
600
|
+
jd_url: str | None = typer.Option(
|
|
601
|
+
None,
|
|
602
|
+
"--jd-url",
|
|
603
|
+
help="Fetch the job description from a URL instead of a file.",
|
|
604
|
+
),
|
|
605
|
+
threshold: float = typer.Option(
|
|
606
|
+
0.5, "--threshold", help="Cosine distance threshold for evidence relevance."
|
|
607
|
+
),
|
|
608
|
+
section: list[str] | None = typer.Option(
|
|
609
|
+
None,
|
|
610
|
+
"--section",
|
|
611
|
+
help="Only verify claims from these resume sections (substring match, repeatable).",
|
|
612
|
+
),
|
|
613
|
+
entry: list[str] | None = typer.Option(
|
|
614
|
+
None,
|
|
615
|
+
"--entry",
|
|
616
|
+
help="Only verify claims from entries whose heading matches (substring, repeatable). Skills sections stay included.",
|
|
617
|
+
),
|
|
618
|
+
scope_all: bool = typer.Option(
|
|
619
|
+
False,
|
|
620
|
+
"--all",
|
|
621
|
+
help="Verify the whole resume without the entry picker.",
|
|
622
|
+
),
|
|
623
|
+
kb_map: list[str] | None = typer.Option(
|
|
624
|
+
None,
|
|
625
|
+
"--map",
|
|
626
|
+
help="Map a resume entry to its own ingested codebase (folder, git"
|
|
627
|
+
' URL, or alias), repeatable: --map "one more light=./oml"'
|
|
628
|
+
' --map "x-iq=owner/repo". Skills claims check across ALL mapped'
|
|
629
|
+
" codebases.",
|
|
630
|
+
),
|
|
631
|
+
) -> None:
|
|
632
|
+
"""Verify resume claims against the ingested knowledge base."""
|
|
633
|
+
try:
|
|
634
|
+
kb_maps = None
|
|
635
|
+
if kb_map:
|
|
636
|
+
from receipts.verify.router import parse_map_options
|
|
637
|
+
|
|
638
|
+
kb_maps = parse_map_options(kb_map, resolve=_resolve_kb_source)
|
|
639
|
+
|
|
640
|
+
if kb_maps and not entry and not scope_all:
|
|
641
|
+
# Scope to the mapped entries (+ skills) — the picker would be
|
|
642
|
+
# redundant when the user already said which entries matter.
|
|
643
|
+
entries = [m.entry_filter for m in kb_maps]
|
|
644
|
+
else:
|
|
645
|
+
entries = _resolve_entries(Path(resume), section, entry, scope_all)
|
|
646
|
+
|
|
647
|
+
if kb_maps is None and source is None:
|
|
648
|
+
typer.secho(
|
|
649
|
+
"Provide a codebase folder, or map entries to codebases"
|
|
650
|
+
' with --map "entry=./path".',
|
|
651
|
+
fg=typer.colors.RED,
|
|
652
|
+
err=True,
|
|
653
|
+
)
|
|
654
|
+
raise typer.Exit(1)
|
|
655
|
+
|
|
656
|
+
_run_verify(
|
|
657
|
+
Path(resume),
|
|
658
|
+
source,
|
|
659
|
+
rewrite,
|
|
660
|
+
_resolve_jd(jd, jd_url),
|
|
661
|
+
threshold,
|
|
662
|
+
section,
|
|
663
|
+
entries,
|
|
664
|
+
kb_maps=kb_maps,
|
|
665
|
+
)
|
|
666
|
+
except ReceiptsError as exc:
|
|
667
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
668
|
+
raise typer.Exit(1) from exc
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _run_verify(
|
|
672
|
+
resume_path: Path,
|
|
673
|
+
source_spec: str | Path | None,
|
|
674
|
+
do_rewrite: bool,
|
|
675
|
+
jd_path: str | None,
|
|
676
|
+
threshold: float,
|
|
677
|
+
sections: list[str] | None = None,
|
|
678
|
+
entries: list[str] | None = None,
|
|
679
|
+
*,
|
|
680
|
+
kb_maps: list | None = None,
|
|
681
|
+
) -> None:
|
|
682
|
+
from receipts.config import load_settings
|
|
683
|
+
from receipts.llm.factory import get_provider
|
|
684
|
+
from receipts.resume.claim_extractor import extract_claims
|
|
685
|
+
from receipts.resume.loader import load_resume
|
|
686
|
+
from receipts.verify.kb import KnowledgeBase
|
|
687
|
+
from receipts.verify.verifier import verify_claims
|
|
688
|
+
|
|
689
|
+
if not resume_path.is_file():
|
|
690
|
+
typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
|
|
691
|
+
raise typer.Exit(1)
|
|
692
|
+
|
|
693
|
+
router = None
|
|
694
|
+
kb = None
|
|
695
|
+
if kb_maps:
|
|
696
|
+
from receipts.verify.router import KBRouter
|
|
697
|
+
|
|
698
|
+
for m in kb_maps:
|
|
699
|
+
if m.kb_dir is None and not m.source_path.is_dir():
|
|
700
|
+
typer.secho(
|
|
701
|
+
f"Mapped folder not found: {m.source_path}",
|
|
702
|
+
fg=typer.colors.RED,
|
|
703
|
+
err=True,
|
|
704
|
+
)
|
|
705
|
+
raise typer.Exit(1)
|
|
706
|
+
|
|
707
|
+
router = KBRouter(kb_maps)
|
|
708
|
+
empty = router.empty_sources()
|
|
709
|
+
if empty:
|
|
710
|
+
for label in empty:
|
|
711
|
+
typer.secho(
|
|
712
|
+
f"No knowledge base for {label} — run"
|
|
713
|
+
f" 'receipts ingest {label}' first.",
|
|
714
|
+
fg=typer.colors.RED,
|
|
715
|
+
err=True,
|
|
716
|
+
)
|
|
717
|
+
raise typer.Exit(1)
|
|
718
|
+
|
|
719
|
+
typer.echo("Entry → codebase mapping:")
|
|
720
|
+
for m in kb_maps:
|
|
721
|
+
typer.echo(f' "{m.entry_filter}" → {m.display_label}')
|
|
722
|
+
typer.echo(" (skills claims check across all mapped codebases)")
|
|
723
|
+
else:
|
|
724
|
+
if source_spec is None:
|
|
725
|
+
typer.secho(
|
|
726
|
+
"Provide a codebase (folder, git URL, or alias) or use --map.",
|
|
727
|
+
fg=typer.colors.RED,
|
|
728
|
+
err=True,
|
|
729
|
+
)
|
|
730
|
+
raise typer.Exit(1)
|
|
731
|
+
|
|
732
|
+
kb_dir, source_label = _resolve_kb_source(str(source_spec))
|
|
733
|
+
kb = KnowledgeBase(kb_dir)
|
|
734
|
+
if kb.count() == 0:
|
|
735
|
+
typer.secho(
|
|
736
|
+
f"Knowledge base for '{source_label}' is empty."
|
|
737
|
+
" Run 'receipts ingest' first.",
|
|
738
|
+
fg=typer.colors.RED,
|
|
739
|
+
err=True,
|
|
740
|
+
)
|
|
741
|
+
raise typer.Exit(1)
|
|
742
|
+
|
|
743
|
+
settings = load_settings()
|
|
744
|
+
provider = get_provider(settings, verify=True)
|
|
745
|
+
|
|
746
|
+
typer.echo(f"Parsing {resume_path.name} ...")
|
|
747
|
+
resume = load_resume(resume_path)
|
|
748
|
+
typer.echo(f" Name: {resume.name}")
|
|
749
|
+
typer.echo(f" Sections: {len(resume.sections)}")
|
|
750
|
+
|
|
751
|
+
claims = extract_claims(resume, sections=sections, entries=entries)
|
|
752
|
+
if sections:
|
|
753
|
+
typer.echo(f" Filtering to sections matching: {', '.join(sections)}")
|
|
754
|
+
if entries:
|
|
755
|
+
typer.echo(f" Filtering to entries matching: {', '.join(entries)}")
|
|
756
|
+
typer.echo(f" Claims extracted: {len(claims)}")
|
|
757
|
+
typer.echo("")
|
|
758
|
+
|
|
759
|
+
use_batch = provider.name != "ollama"
|
|
760
|
+
typer.echo(f"Verifying {len(claims)} claim(s) against the knowledge base ...")
|
|
761
|
+
if use_batch:
|
|
762
|
+
typer.echo(" (claims from the same bullet share one LLM call)")
|
|
763
|
+
|
|
764
|
+
if router is not None:
|
|
765
|
+
from receipts.verify.router import verify_claims_routed
|
|
766
|
+
|
|
767
|
+
results = verify_claims_routed(
|
|
768
|
+
claims,
|
|
769
|
+
router,
|
|
770
|
+
provider,
|
|
771
|
+
distance_threshold=threshold,
|
|
772
|
+
batch=use_batch,
|
|
773
|
+
)
|
|
774
|
+
else:
|
|
775
|
+
results = verify_claims(
|
|
776
|
+
claims, kb, provider, distance_threshold=threshold, batch=use_batch
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
_print_verification_report(results)
|
|
780
|
+
|
|
781
|
+
if do_rewrite:
|
|
782
|
+
_run_rewrite(results, provider)
|
|
783
|
+
|
|
784
|
+
if jd_path:
|
|
785
|
+
gap_kb = router.multi_kb() if router is not None else kb
|
|
786
|
+
_run_keyword_gap(Path(jd_path), resume, gap_kb, provider, threshold)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _print_verification_report(results: list) -> None:
|
|
790
|
+
from receipts.verify.verifier import VerificationResult
|
|
791
|
+
|
|
792
|
+
by_verdict: dict[str, list[VerificationResult]] = {}
|
|
793
|
+
for r in results:
|
|
794
|
+
by_verdict.setdefault(r.verdict, []).append(r)
|
|
795
|
+
|
|
796
|
+
verified = by_verdict.get("verified", [])
|
|
797
|
+
plausible = by_verdict.get("plausible", [])
|
|
798
|
+
unsupported = by_verdict.get("unsupported", [])
|
|
799
|
+
|
|
800
|
+
typer.echo("")
|
|
801
|
+
typer.secho(
|
|
802
|
+
f"Results: {len(verified)} verified, {len(plausible)} plausible, "
|
|
803
|
+
f"{len(unsupported)} unsupported",
|
|
804
|
+
bold=True,
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
if unsupported:
|
|
808
|
+
typer.echo("")
|
|
809
|
+
typer.secho("UNSUPPORTED CLAIMS:", fg=typer.colors.RED, bold=True)
|
|
810
|
+
seen: set[str] = set()
|
|
811
|
+
for r in unsupported:
|
|
812
|
+
key = f"{r.claim.value}|{r.claim.bullet_text}"
|
|
813
|
+
if key in seen:
|
|
814
|
+
continue
|
|
815
|
+
seen.add(key)
|
|
816
|
+
typer.echo(f" [{r.claim.claim_type}] {r.claim.value}")
|
|
817
|
+
typer.echo(f" bullet: {r.claim.bullet_text[:80]}...")
|
|
818
|
+
typer.echo(f" reason: {r.explanation}")
|
|
819
|
+
|
|
820
|
+
if plausible:
|
|
821
|
+
typer.echo("")
|
|
822
|
+
typer.secho("PLAUSIBLE CLAIMS:", fg=typer.colors.YELLOW, bold=True)
|
|
823
|
+
seen_p: set[str] = set()
|
|
824
|
+
for r in plausible:
|
|
825
|
+
key = f"{r.claim.value}|{r.claim.bullet_text}"
|
|
826
|
+
if key in seen_p:
|
|
827
|
+
continue
|
|
828
|
+
seen_p.add(key)
|
|
829
|
+
typer.echo(f" [{r.claim.claim_type}] {r.claim.value}")
|
|
830
|
+
typer.echo(f" reason: {r.explanation}")
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _run_rewrite(results: list, provider) -> None:
|
|
834
|
+
from receipts.verify.rewriter import rewrite_weak_bullets
|
|
835
|
+
|
|
836
|
+
typer.echo("")
|
|
837
|
+
typer.secho("REWRITES:", bold=True)
|
|
838
|
+
rewrites = rewrite_weak_bullets(results, provider)
|
|
839
|
+
if not rewrites:
|
|
840
|
+
typer.echo(" No rewrites needed — all bullets are backed by code.")
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
for rw in rewrites:
|
|
844
|
+
typer.echo("")
|
|
845
|
+
typer.echo(f" ORIGINAL: {rw.original[:100]}")
|
|
846
|
+
typer.secho(f" REWRITTEN: {rw.rewritten[:100]}", fg=typer.colors.GREEN)
|
|
847
|
+
typer.echo(f" why: {rw.explanation}")
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _run_keyword_gap(jd_path: Path, resume, kb, provider, threshold: float) -> None:
|
|
851
|
+
from receipts.verify.keyword_gap import analyze_gap
|
|
852
|
+
|
|
853
|
+
if not jd_path.is_file():
|
|
854
|
+
typer.secho(f"JD file not found: {jd_path}", fg=typer.colors.RED, err=True)
|
|
855
|
+
return
|
|
856
|
+
|
|
857
|
+
jd_text = jd_path.read_text(encoding="utf-8")
|
|
858
|
+
typer.echo("")
|
|
859
|
+
typer.secho("KEYWORD GAP ANALYSIS:", bold=True)
|
|
860
|
+
|
|
861
|
+
report = analyze_gap(jd_text, resume, kb, provider, distance_threshold=threshold)
|
|
862
|
+
|
|
863
|
+
if report.addable:
|
|
864
|
+
typer.echo("")
|
|
865
|
+
typer.secho(" Can add (code supports it):", fg=typer.colors.GREEN)
|
|
866
|
+
for item in report.addable:
|
|
867
|
+
typer.echo(f" + {item.keyword} — {item.suggestion}")
|
|
868
|
+
|
|
869
|
+
if report.ungrounded:
|
|
870
|
+
typer.echo("")
|
|
871
|
+
typer.secho(" Ungrounded (resume claims, no code):", fg=typer.colors.YELLOW)
|
|
872
|
+
for item in report.ungrounded:
|
|
873
|
+
typer.echo(f" ? {item.keyword} — {item.suggestion}")
|
|
874
|
+
|
|
875
|
+
if report.gaps:
|
|
876
|
+
typer.echo("")
|
|
877
|
+
typer.echo(" Gaps (JD wants, you don't have):")
|
|
878
|
+
for item in report.gaps:
|
|
879
|
+
typer.echo(f" - {item.keyword}")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@app.command()
|
|
883
|
+
def tui(
|
|
884
|
+
resume: str = typer.Argument(
|
|
885
|
+
..., help="Path to the resume: .tex, text-based .pdf, or .txt."
|
|
886
|
+
),
|
|
887
|
+
source: str = typer.Argument(
|
|
888
|
+
...,
|
|
889
|
+
help="Codebase: local folder, git URL, or manifest alias"
|
|
890
|
+
" (must have been ingested).",
|
|
891
|
+
),
|
|
892
|
+
threshold: float = typer.Option(
|
|
893
|
+
0.5, "--threshold", help="Cosine distance threshold for evidence relevance."
|
|
894
|
+
),
|
|
895
|
+
section: list[str] | None = typer.Option(
|
|
896
|
+
None,
|
|
897
|
+
"--section",
|
|
898
|
+
help="Only verify claims from these resume sections (substring match, repeatable).",
|
|
899
|
+
),
|
|
900
|
+
entry: list[str] | None = typer.Option(
|
|
901
|
+
None,
|
|
902
|
+
"--entry",
|
|
903
|
+
help="Only verify claims from entries whose heading matches (substring, repeatable). Skills sections stay included.",
|
|
904
|
+
),
|
|
905
|
+
scope_all: bool = typer.Option(
|
|
906
|
+
False,
|
|
907
|
+
"--all",
|
|
908
|
+
help="Verify the whole resume without the entry picker.",
|
|
909
|
+
),
|
|
910
|
+
) -> None:
|
|
911
|
+
"""Launch the interactive TUI for resume verification."""
|
|
912
|
+
resume_path = Path(resume)
|
|
913
|
+
if not resume_path.is_file():
|
|
914
|
+
typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
|
|
915
|
+
raise typer.Exit(1)
|
|
916
|
+
try:
|
|
917
|
+
kb_dir, _ = _resolve_kb_source(source)
|
|
918
|
+
except ReceiptsError as exc:
|
|
919
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
920
|
+
raise typer.Exit(1) from exc
|
|
921
|
+
|
|
922
|
+
entries = _resolve_entries(resume_path, section, entry, scope_all)
|
|
923
|
+
|
|
924
|
+
from receipts.tui.app import ReceiptsApp
|
|
925
|
+
|
|
926
|
+
tui_app = ReceiptsApp(
|
|
927
|
+
resume_path,
|
|
928
|
+
Path(source),
|
|
929
|
+
threshold=threshold,
|
|
930
|
+
sections=section,
|
|
931
|
+
entries=entries,
|
|
932
|
+
kb_dir=kb_dir,
|
|
933
|
+
)
|
|
934
|
+
tui_app.run()
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@app.command()
|
|
938
|
+
def ama(
|
|
939
|
+
resume: str = typer.Argument(
|
|
940
|
+
..., help="Path to the resume: .tex, text-based .pdf, or .txt."
|
|
941
|
+
),
|
|
942
|
+
source: str = typer.Argument(
|
|
943
|
+
..., help="Local folder of the codebase (must have been ingested)."
|
|
944
|
+
),
|
|
945
|
+
rounds: int = typer.Option(5, "--rounds", help="Number of interview rounds."),
|
|
946
|
+
threshold: float = typer.Option(
|
|
947
|
+
0.5,
|
|
948
|
+
"--threshold",
|
|
949
|
+
help="Cosine distance threshold for evidence relevance.",
|
|
950
|
+
),
|
|
951
|
+
section: list[str] | None = typer.Option(
|
|
952
|
+
None,
|
|
953
|
+
"--section",
|
|
954
|
+
help="Only verify claims from these resume sections (substring match, repeatable).",
|
|
955
|
+
),
|
|
956
|
+
entry: list[str] | None = typer.Option(
|
|
957
|
+
None,
|
|
958
|
+
"--entry",
|
|
959
|
+
help="Only quiz on entries whose heading matches (substring, repeatable). Skills sections stay included.",
|
|
960
|
+
),
|
|
961
|
+
scope_all: bool = typer.Option(
|
|
962
|
+
False,
|
|
963
|
+
"--all",
|
|
964
|
+
help="Quiz on the whole resume without the entry picker.",
|
|
965
|
+
),
|
|
966
|
+
style: str | None = typer.Option(
|
|
967
|
+
None,
|
|
968
|
+
"--style",
|
|
969
|
+
help="Interview round preset: service | product | startup.",
|
|
970
|
+
),
|
|
971
|
+
save: bool = typer.Option(
|
|
972
|
+
True,
|
|
973
|
+
"--save/--no-save",
|
|
974
|
+
help="Persist this session for readiness tracking (default on).",
|
|
975
|
+
),
|
|
976
|
+
use_tui: bool = typer.Option(
|
|
977
|
+
False,
|
|
978
|
+
"--tui",
|
|
979
|
+
help="Use the interactive TUI chat interface instead of plain text.",
|
|
980
|
+
),
|
|
981
|
+
) -> None:
|
|
982
|
+
"""Mock-interview mode — quiz yourself on your resume claims."""
|
|
983
|
+
if style:
|
|
984
|
+
from receipts.ama.interviewer import INTERVIEW_STYLES
|
|
985
|
+
|
|
986
|
+
if style.strip().lower() not in INTERVIEW_STYLES:
|
|
987
|
+
typer.secho(
|
|
988
|
+
f"Unknown style '{style}'." f" Valid: {', '.join(INTERVIEW_STYLES)}.",
|
|
989
|
+
fg=typer.colors.RED,
|
|
990
|
+
err=True,
|
|
991
|
+
)
|
|
992
|
+
raise typer.Exit(1)
|
|
993
|
+
style = style.strip().lower()
|
|
994
|
+
|
|
995
|
+
entries = _resolve_entries(Path(resume), section, entry, scope_all)
|
|
996
|
+
if use_tui:
|
|
997
|
+
resume_path = Path(resume)
|
|
998
|
+
if not resume_path.is_file():
|
|
999
|
+
typer.secho(
|
|
1000
|
+
f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True
|
|
1001
|
+
)
|
|
1002
|
+
raise typer.Exit(1)
|
|
1003
|
+
try:
|
|
1004
|
+
kb_dir, _ = _resolve_kb_source(source)
|
|
1005
|
+
except ReceiptsError as exc:
|
|
1006
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
1007
|
+
raise typer.Exit(1) from exc
|
|
1008
|
+
from receipts.tui.ama_app import AmaApp
|
|
1009
|
+
|
|
1010
|
+
ama_tui = AmaApp(
|
|
1011
|
+
resume_path,
|
|
1012
|
+
Path(source),
|
|
1013
|
+
n_rounds=rounds,
|
|
1014
|
+
threshold=threshold,
|
|
1015
|
+
sections=section,
|
|
1016
|
+
entries=entries,
|
|
1017
|
+
style=style,
|
|
1018
|
+
save_session=save,
|
|
1019
|
+
kb_dir=kb_dir,
|
|
1020
|
+
)
|
|
1021
|
+
ama_tui.run()
|
|
1022
|
+
return
|
|
1023
|
+
try:
|
|
1024
|
+
_run_ama(
|
|
1025
|
+
Path(resume),
|
|
1026
|
+
source,
|
|
1027
|
+
rounds,
|
|
1028
|
+
threshold,
|
|
1029
|
+
section,
|
|
1030
|
+
entries,
|
|
1031
|
+
style=style,
|
|
1032
|
+
save=save,
|
|
1033
|
+
)
|
|
1034
|
+
except ReceiptsError as exc:
|
|
1035
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
1036
|
+
raise typer.Exit(1) from exc
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _save_ama_session(rounds: list, resume_name: str, source: str) -> None:
|
|
1040
|
+
"""Persist an AMA session and print the trend vs the previous one."""
|
|
1041
|
+
from receipts.prep.readiness import ReadinessStore, readiness_delta
|
|
1042
|
+
|
|
1043
|
+
store = ReadinessStore()
|
|
1044
|
+
try:
|
|
1045
|
+
store.save_session(rounds, resume_name=resume_name, source=source)
|
|
1046
|
+
sessions = store.sessions(resume_name=resume_name)
|
|
1047
|
+
finally:
|
|
1048
|
+
store.close()
|
|
1049
|
+
|
|
1050
|
+
typer.echo("")
|
|
1051
|
+
typer.echo("Session saved — run 'receipts readiness' to track progress.")
|
|
1052
|
+
delta = readiness_delta(sessions)
|
|
1053
|
+
if delta:
|
|
1054
|
+
latest, previous = delta
|
|
1055
|
+
change = (latest - previous) * 100
|
|
1056
|
+
color = typer.colors.GREEN if change >= 0 else typer.colors.RED
|
|
1057
|
+
typer.secho(
|
|
1058
|
+
f"Held-up rate: {latest:.0%} ({change:+.0f} pts vs previous session)",
|
|
1059
|
+
fg=color,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def _run_ama(
|
|
1064
|
+
resume_path: Path,
|
|
1065
|
+
source_spec: str | Path,
|
|
1066
|
+
n_rounds: int,
|
|
1067
|
+
threshold: float,
|
|
1068
|
+
sections: list[str] | None = None,
|
|
1069
|
+
entries: list[str] | None = None,
|
|
1070
|
+
*,
|
|
1071
|
+
style: str | None = None,
|
|
1072
|
+
save: bool = True,
|
|
1073
|
+
) -> None:
|
|
1074
|
+
from receipts.ama.interviewer import (
|
|
1075
|
+
InterviewRound,
|
|
1076
|
+
assess_answer,
|
|
1077
|
+
generate_follow_up,
|
|
1078
|
+
generate_model_answer,
|
|
1079
|
+
generate_question,
|
|
1080
|
+
generate_summary,
|
|
1081
|
+
select_bullets,
|
|
1082
|
+
)
|
|
1083
|
+
from receipts.config import load_settings
|
|
1084
|
+
from receipts.llm.factory import get_provider
|
|
1085
|
+
from receipts.resume.claim_extractor import extract_claims
|
|
1086
|
+
from receipts.resume.loader import load_resume
|
|
1087
|
+
from receipts.verify.kb import KnowledgeBase
|
|
1088
|
+
from receipts.verify.verifier import verify_claims
|
|
1089
|
+
|
|
1090
|
+
if not resume_path.is_file():
|
|
1091
|
+
typer.secho(
|
|
1092
|
+
f"Resume not found: {resume_path}",
|
|
1093
|
+
fg=typer.colors.RED,
|
|
1094
|
+
err=True,
|
|
1095
|
+
)
|
|
1096
|
+
raise typer.Exit(1)
|
|
1097
|
+
|
|
1098
|
+
kb_dir, source_label = _resolve_kb_source(str(source_spec))
|
|
1099
|
+
kb = KnowledgeBase(kb_dir)
|
|
1100
|
+
if kb.count() == 0:
|
|
1101
|
+
typer.secho(
|
|
1102
|
+
f"Knowledge base for '{source_label}' is empty."
|
|
1103
|
+
" Run 'receipts ingest' first.",
|
|
1104
|
+
fg=typer.colors.RED,
|
|
1105
|
+
err=True,
|
|
1106
|
+
)
|
|
1107
|
+
raise typer.Exit(1)
|
|
1108
|
+
|
|
1109
|
+
settings = load_settings()
|
|
1110
|
+
provider = get_provider(settings, verify=True)
|
|
1111
|
+
|
|
1112
|
+
typer.echo(f"Parsing {resume_path.name} ...")
|
|
1113
|
+
resume = load_resume(resume_path)
|
|
1114
|
+
claims = extract_claims(resume, sections=sections, entries=entries)
|
|
1115
|
+
|
|
1116
|
+
typer.echo(f"Verifying {len(claims)} claim(s) ...")
|
|
1117
|
+
results = verify_claims(
|
|
1118
|
+
claims,
|
|
1119
|
+
kb,
|
|
1120
|
+
provider,
|
|
1121
|
+
distance_threshold=threshold,
|
|
1122
|
+
batch=provider.name != "ollama",
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
selected = select_bullets(results, n_rounds)
|
|
1126
|
+
if not selected:
|
|
1127
|
+
typer.echo("No bullets to interview on.")
|
|
1128
|
+
raise typer.Exit(0)
|
|
1129
|
+
|
|
1130
|
+
typer.echo("")
|
|
1131
|
+
typer.secho(
|
|
1132
|
+
f"Starting mock interview — {len(selected)} round(s).",
|
|
1133
|
+
bold=True,
|
|
1134
|
+
)
|
|
1135
|
+
typer.echo("Answer each question as you would in a real interview.")
|
|
1136
|
+
typer.echo("Type 'quit' at any prompt to end early.")
|
|
1137
|
+
typer.echo("")
|
|
1138
|
+
|
|
1139
|
+
interview_rounds: list[InterviewRound] = []
|
|
1140
|
+
|
|
1141
|
+
for i, result in enumerate(selected, 1):
|
|
1142
|
+
typer.secho(f"--- Round {i}/{len(selected)} ---", bold=True)
|
|
1143
|
+
typer.echo(f" [{result.verdict}] {result.claim.bullet_text[:80]}")
|
|
1144
|
+
typer.echo("")
|
|
1145
|
+
|
|
1146
|
+
question, q_comp = generate_question(result, provider, style=style)
|
|
1147
|
+
typer.secho(f"Q: {question}", fg=typer.colors.CYAN)
|
|
1148
|
+
answer = typer.prompt("Your answer")
|
|
1149
|
+
if answer.strip().lower() == "quit":
|
|
1150
|
+
typer.echo("Ending interview early.")
|
|
1151
|
+
break
|
|
1152
|
+
|
|
1153
|
+
follow_up, fu_comp = generate_follow_up(question, answer, provider)
|
|
1154
|
+
typer.echo("")
|
|
1155
|
+
typer.secho(f"Follow-up: {follow_up}", fg=typer.colors.CYAN)
|
|
1156
|
+
fu_answer = typer.prompt("Your answer")
|
|
1157
|
+
if fu_answer.strip().lower() == "quit":
|
|
1158
|
+
typer.echo("Ending interview early.")
|
|
1159
|
+
break
|
|
1160
|
+
|
|
1161
|
+
combined = f"{answer}\n\nFollow-up: {fu_answer}"
|
|
1162
|
+
held_up, assessment, a_comp = assess_answer(
|
|
1163
|
+
result.claim.bullet_text, question, combined, provider
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
verdict_color = typer.colors.GREEN if held_up else typer.colors.RED
|
|
1167
|
+
typer.echo("")
|
|
1168
|
+
typer.secho(
|
|
1169
|
+
f" {'Held up' if held_up else 'Did not hold up'}: " f"{assessment}",
|
|
1170
|
+
fg=verdict_color,
|
|
1171
|
+
)
|
|
1172
|
+
typer.echo("")
|
|
1173
|
+
|
|
1174
|
+
model_answer, ma_comp = generate_model_answer(result, question, provider)
|
|
1175
|
+
typer.secho(" A strong, honest answer:", fg=typer.colors.BLUE, bold=True)
|
|
1176
|
+
typer.echo(f" {model_answer}")
|
|
1177
|
+
typer.echo("")
|
|
1178
|
+
|
|
1179
|
+
interview_rounds.append(
|
|
1180
|
+
InterviewRound(
|
|
1181
|
+
bullet_text=result.claim.bullet_text,
|
|
1182
|
+
section=result.claim.section,
|
|
1183
|
+
heading=result.claim.entry_heading,
|
|
1184
|
+
verdict=result.verdict,
|
|
1185
|
+
question=question,
|
|
1186
|
+
answer=answer,
|
|
1187
|
+
follow_up=follow_up,
|
|
1188
|
+
follow_up_answer=fu_answer,
|
|
1189
|
+
held_up=held_up,
|
|
1190
|
+
assessment=assessment,
|
|
1191
|
+
model_answer=model_answer,
|
|
1192
|
+
completions=[q_comp, fu_comp, a_comp, ma_comp],
|
|
1193
|
+
)
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
if not interview_rounds:
|
|
1197
|
+
return
|
|
1198
|
+
|
|
1199
|
+
typer.echo("")
|
|
1200
|
+
typer.secho("=== Interview Summary ===", bold=True)
|
|
1201
|
+
|
|
1202
|
+
summary = generate_summary(interview_rounds, provider)
|
|
1203
|
+
|
|
1204
|
+
if summary.strong:
|
|
1205
|
+
typer.secho("Strong points:", fg=typer.colors.GREEN)
|
|
1206
|
+
for pt in summary.strong:
|
|
1207
|
+
typer.echo(f" + {pt}")
|
|
1208
|
+
|
|
1209
|
+
if summary.weak:
|
|
1210
|
+
typer.secho("Weak points:", fg=typer.colors.RED)
|
|
1211
|
+
for pt in summary.weak:
|
|
1212
|
+
typer.echo(f" - {pt}")
|
|
1213
|
+
|
|
1214
|
+
typer.echo("")
|
|
1215
|
+
typer.echo(summary.overall)
|
|
1216
|
+
|
|
1217
|
+
if save:
|
|
1218
|
+
_save_ama_session(interview_rounds, resume.name, source_label)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
@app.command()
|
|
1222
|
+
def rewrite(
|
|
1223
|
+
resume: str = typer.Argument(
|
|
1224
|
+
..., help="Path to the resume: .tex, text-based .pdf, or .txt."
|
|
1225
|
+
),
|
|
1226
|
+
source: str = typer.Argument(
|
|
1227
|
+
..., help="Local folder of the codebase (must have been ingested)."
|
|
1228
|
+
),
|
|
1229
|
+
output: str | None = typer.Option(
|
|
1230
|
+
None,
|
|
1231
|
+
"--output",
|
|
1232
|
+
"-o",
|
|
1233
|
+
help="Output path for the rewritten .tex file. Defaults to <resume>_optimized.tex.",
|
|
1234
|
+
),
|
|
1235
|
+
threshold: float = typer.Option(
|
|
1236
|
+
0.5, "--threshold", help="Cosine distance threshold for evidence relevance."
|
|
1237
|
+
),
|
|
1238
|
+
section: list[str] | None = typer.Option(
|
|
1239
|
+
None,
|
|
1240
|
+
"--section",
|
|
1241
|
+
help="Only rewrite bullets from these resume sections (substring match).",
|
|
1242
|
+
),
|
|
1243
|
+
entry: list[str] | None = typer.Option(
|
|
1244
|
+
None,
|
|
1245
|
+
"--entry",
|
|
1246
|
+
help="Only rewrite entries whose heading matches (substring, repeatable). Skills sections stay included.",
|
|
1247
|
+
),
|
|
1248
|
+
scope_all: bool = typer.Option(
|
|
1249
|
+
False,
|
|
1250
|
+
"--all",
|
|
1251
|
+
help="Rewrite across the whole resume without the entry picker.",
|
|
1252
|
+
),
|
|
1253
|
+
compile_pdf: bool = typer.Option(
|
|
1254
|
+
False,
|
|
1255
|
+
"--pdf",
|
|
1256
|
+
help="Attempt to compile the rewritten .tex to PDF.",
|
|
1257
|
+
),
|
|
1258
|
+
jd: str | None = typer.Option(
|
|
1259
|
+
None,
|
|
1260
|
+
"--jd",
|
|
1261
|
+
help="Path to a job description text file for ATS-aware rewriting.",
|
|
1262
|
+
),
|
|
1263
|
+
jd_url: str | None = typer.Option(
|
|
1264
|
+
None,
|
|
1265
|
+
"--jd-url",
|
|
1266
|
+
help="Fetch the job description from a URL instead of a file.",
|
|
1267
|
+
),
|
|
1268
|
+
use_tui: bool = typer.Option(
|
|
1269
|
+
False,
|
|
1270
|
+
"--tui",
|
|
1271
|
+
help="Use the full-screen TUI dashboard.",
|
|
1272
|
+
),
|
|
1273
|
+
) -> None:
|
|
1274
|
+
"""Verify claims and generate an optimized .tex resume with rewritten bullets."""
|
|
1275
|
+
if Path(resume).suffix.lower() != ".tex":
|
|
1276
|
+
typer.secho(
|
|
1277
|
+
"rewrite outputs an optimized .tex, so it needs your .tex source."
|
|
1278
|
+
" (verify, ama, dossier, and score all accept .pdf resumes.)",
|
|
1279
|
+
fg=typer.colors.RED,
|
|
1280
|
+
err=True,
|
|
1281
|
+
)
|
|
1282
|
+
raise typer.Exit(1)
|
|
1283
|
+
entries = _resolve_entries(Path(resume), section, entry, scope_all)
|
|
1284
|
+
try:
|
|
1285
|
+
jd = _resolve_jd(jd, jd_url)
|
|
1286
|
+
except ReceiptsError as exc:
|
|
1287
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
1288
|
+
raise typer.Exit(1) from exc
|
|
1289
|
+
|
|
1290
|
+
if use_tui:
|
|
1291
|
+
if not Path(source).is_dir():
|
|
1292
|
+
typer.secho(
|
|
1293
|
+
"rewrite --tui needs a local folder (it mines code metrics"
|
|
1294
|
+
" on screen). Clone the repo first, or drop --tui.",
|
|
1295
|
+
fg=typer.colors.RED,
|
|
1296
|
+
err=True,
|
|
1297
|
+
)
|
|
1298
|
+
raise typer.Exit(1)
|
|
1299
|
+
from receipts.tui.rewrite_app import RewriteApp
|
|
1300
|
+
|
|
1301
|
+
rw_app = RewriteApp(
|
|
1302
|
+
Path(resume),
|
|
1303
|
+
Path(source),
|
|
1304
|
+
threshold=threshold,
|
|
1305
|
+
sections=section,
|
|
1306
|
+
entries=entries,
|
|
1307
|
+
output_path=Path(output) if output else None,
|
|
1308
|
+
compile_pdf=compile_pdf,
|
|
1309
|
+
jd_path=Path(jd) if jd else None,
|
|
1310
|
+
)
|
|
1311
|
+
rw_app.run()
|
|
1312
|
+
return
|
|
1313
|
+
try:
|
|
1314
|
+
from receipts.ingest.git_source import (
|
|
1315
|
+
cloned_repo,
|
|
1316
|
+
kb_dir_for_url,
|
|
1317
|
+
looks_like_git_url,
|
|
1318
|
+
)
|
|
1319
|
+
from receipts.ingest.manifest import lookup_source
|
|
1320
|
+
|
|
1321
|
+
spec = source
|
|
1322
|
+
if not looks_like_git_url(spec) and not Path(spec).is_dir():
|
|
1323
|
+
# Alias: local aliases resolve to their folder; remote aliases
|
|
1324
|
+
# resolve to their canonical URL for a fresh clone.
|
|
1325
|
+
record = lookup_source(spec)
|
|
1326
|
+
if record is not None and record.kind == "local":
|
|
1327
|
+
spec = record.source
|
|
1328
|
+
elif record is not None and record.kind == "remote":
|
|
1329
|
+
spec = f"https://{record.source}"
|
|
1330
|
+
else:
|
|
1331
|
+
_resolve_kb_source(spec) # raises the friendly unknown-source error
|
|
1332
|
+
|
|
1333
|
+
if looks_like_git_url(spec):
|
|
1334
|
+
# Rewriting mines the working tree and git history, so remote
|
|
1335
|
+
# sources are cloned in full — the KB stays keyed by the URL.
|
|
1336
|
+
kb_dir = kb_dir_for_url(spec)
|
|
1337
|
+
typer.echo(f"Cloning {spec} (full history, for git evidence) ...")
|
|
1338
|
+
with cloned_repo(spec, full_history=True) as root:
|
|
1339
|
+
_run_rewrite_pipeline(
|
|
1340
|
+
Path(resume),
|
|
1341
|
+
root,
|
|
1342
|
+
output,
|
|
1343
|
+
threshold,
|
|
1344
|
+
section,
|
|
1345
|
+
compile_pdf,
|
|
1346
|
+
jd,
|
|
1347
|
+
entries,
|
|
1348
|
+
kb_dir=kb_dir,
|
|
1349
|
+
)
|
|
1350
|
+
else:
|
|
1351
|
+
_run_rewrite_pipeline(
|
|
1352
|
+
Path(resume),
|
|
1353
|
+
Path(spec),
|
|
1354
|
+
output,
|
|
1355
|
+
threshold,
|
|
1356
|
+
section,
|
|
1357
|
+
compile_pdf,
|
|
1358
|
+
jd,
|
|
1359
|
+
entries,
|
|
1360
|
+
)
|
|
1361
|
+
except ReceiptsError as exc:
|
|
1362
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
1363
|
+
raise typer.Exit(1) from exc
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _run_rewrite_pipeline(
|
|
1367
|
+
resume_path: Path,
|
|
1368
|
+
source_path: Path,
|
|
1369
|
+
output_path_str: str | None,
|
|
1370
|
+
threshold: float,
|
|
1371
|
+
sections: list[str] | None,
|
|
1372
|
+
compile_pdf: bool,
|
|
1373
|
+
jd_path_str: str | None,
|
|
1374
|
+
entries: list[str] | None = None,
|
|
1375
|
+
*,
|
|
1376
|
+
kb_dir: Path | None = None,
|
|
1377
|
+
) -> None:
|
|
1378
|
+
from receipts.config import load_settings
|
|
1379
|
+
from receipts.llm.factory import get_provider
|
|
1380
|
+
from receipts.mine.code_metrics import mine_code_facts
|
|
1381
|
+
from receipts.mine.git_evidence import mine_git_evidence
|
|
1382
|
+
from receipts.resume.claim_extractor import extract_claims
|
|
1383
|
+
from receipts.resume.loader import load_resume
|
|
1384
|
+
from receipts.rewrite.optimizer import (
|
|
1385
|
+
group_results_by_bullet,
|
|
1386
|
+
optimize_rewrites,
|
|
1387
|
+
)
|
|
1388
|
+
from receipts.rewrite.tex_rewriter import rewrite_tex
|
|
1389
|
+
from receipts.verify.kb import KnowledgeBase, default_kb_dir
|
|
1390
|
+
from receipts.verify.rewriter import rewrite_weak_bullets
|
|
1391
|
+
from receipts.verify.verifier import verify_claims
|
|
1392
|
+
|
|
1393
|
+
if not resume_path.is_file():
|
|
1394
|
+
typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
|
|
1395
|
+
raise typer.Exit(1)
|
|
1396
|
+
if not source_path.is_dir():
|
|
1397
|
+
typer.secho(
|
|
1398
|
+
f"Source folder not found: {source_path}", fg=typer.colors.RED, err=True
|
|
1399
|
+
)
|
|
1400
|
+
raise typer.Exit(1)
|
|
1401
|
+
|
|
1402
|
+
kb_dir = kb_dir or default_kb_dir(source_path)
|
|
1403
|
+
kb = KnowledgeBase(kb_dir)
|
|
1404
|
+
if kb.count() == 0:
|
|
1405
|
+
typer.secho(
|
|
1406
|
+
"Knowledge base is empty. Run 'receipts ingest' first.",
|
|
1407
|
+
fg=typer.colors.RED,
|
|
1408
|
+
err=True,
|
|
1409
|
+
)
|
|
1410
|
+
raise typer.Exit(1)
|
|
1411
|
+
|
|
1412
|
+
settings = load_settings()
|
|
1413
|
+
provider = get_provider(settings, verify=True)
|
|
1414
|
+
|
|
1415
|
+
# --- Mine provable facts (zero LLM calls) ---
|
|
1416
|
+
typer.echo("Mining provable facts from the codebase ...")
|
|
1417
|
+
code_facts = mine_code_facts(source_path)
|
|
1418
|
+
git_ev = mine_git_evidence(source_path)
|
|
1419
|
+
for line in code_facts.summary_lines():
|
|
1420
|
+
typer.echo(f" {line}")
|
|
1421
|
+
for line in git_ev.summary_lines():
|
|
1422
|
+
typer.echo(f" {line}")
|
|
1423
|
+
|
|
1424
|
+
grounded_facts = code_facts.prompt_facts()
|
|
1425
|
+
if git_ev.available:
|
|
1426
|
+
grounded_facts += "\n" + git_ev.prompt_facts()
|
|
1427
|
+
|
|
1428
|
+
typer.echo("")
|
|
1429
|
+
typer.echo(f"Parsing {resume_path.name} ...")
|
|
1430
|
+
resume = load_resume(resume_path)
|
|
1431
|
+
claims = extract_claims(resume, sections=sections, entries=entries)
|
|
1432
|
+
|
|
1433
|
+
if sections:
|
|
1434
|
+
typer.echo(f" Filtering to sections matching: {', '.join(sections)}")
|
|
1435
|
+
if entries:
|
|
1436
|
+
typer.echo(f" Filtering to entries matching: {', '.join(entries)}")
|
|
1437
|
+
typer.echo(f" Claims extracted: {len(claims)}")
|
|
1438
|
+
|
|
1439
|
+
typer.echo(f"Verifying {len(claims)} claim(s) ...")
|
|
1440
|
+
results = verify_claims(
|
|
1441
|
+
claims,
|
|
1442
|
+
kb,
|
|
1443
|
+
provider,
|
|
1444
|
+
distance_threshold=threshold,
|
|
1445
|
+
batch=provider.name != "ollama",
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
_print_verification_report(results)
|
|
1449
|
+
|
|
1450
|
+
# --- JD keywords the code can justify adding ---
|
|
1451
|
+
addable_keywords: list[str] = []
|
|
1452
|
+
if jd_path_str and Path(jd_path_str).is_file():
|
|
1453
|
+
from receipts.verify.keyword_gap import analyze_gap
|
|
1454
|
+
|
|
1455
|
+
typer.echo("")
|
|
1456
|
+
typer.echo("Analyzing JD keyword gap ...")
|
|
1457
|
+
gap = analyze_gap(
|
|
1458
|
+
Path(jd_path_str).read_text(encoding="utf-8"),
|
|
1459
|
+
resume,
|
|
1460
|
+
kb,
|
|
1461
|
+
provider,
|
|
1462
|
+
distance_threshold=threshold,
|
|
1463
|
+
)
|
|
1464
|
+
addable_keywords = [i.keyword for i in gap.addable]
|
|
1465
|
+
if addable_keywords:
|
|
1466
|
+
typer.echo(
|
|
1467
|
+
f" Code-supported keywords to weave in: {', '.join(addable_keywords)}"
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
typer.echo("")
|
|
1471
|
+
typer.secho("Generating rewrites (grounded in mined facts)...", bold=True)
|
|
1472
|
+
raw_rewrites = rewrite_weak_bullets(
|
|
1473
|
+
results,
|
|
1474
|
+
provider,
|
|
1475
|
+
grounded_facts=grounded_facts or None,
|
|
1476
|
+
addable_keywords=addable_keywords or None,
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
if not raw_rewrites:
|
|
1480
|
+
typer.secho("All bullets verified — no rewrites needed.", fg=typer.colors.GREEN)
|
|
1481
|
+
return
|
|
1482
|
+
|
|
1483
|
+
# --- Score-aware pass: retry rewrites that regressed ATS dimensions ---
|
|
1484
|
+
optimized = optimize_rewrites(
|
|
1485
|
+
raw_rewrites,
|
|
1486
|
+
group_results_by_bullet(results),
|
|
1487
|
+
provider,
|
|
1488
|
+
grounded_facts=grounded_facts or None,
|
|
1489
|
+
addable_keywords=addable_keywords or None,
|
|
1490
|
+
)
|
|
1491
|
+
retried = sum(1 for o in optimized if o.retried)
|
|
1492
|
+
fixed = sum(1 for o in optimized if o.retry_fixed)
|
|
1493
|
+
if retried:
|
|
1494
|
+
typer.echo(
|
|
1495
|
+
f" Score-aware pass: {retried} rewrite(s) had ATS regressions,"
|
|
1496
|
+
f" {fixed} fixed on retry."
|
|
1497
|
+
)
|
|
1498
|
+
rewrites = [o.result for o in optimized]
|
|
1499
|
+
|
|
1500
|
+
for rw in rewrites:
|
|
1501
|
+
if rw.original != rw.rewritten:
|
|
1502
|
+
typer.echo("")
|
|
1503
|
+
typer.echo(f" ORIGINAL: {rw.original[:120]}")
|
|
1504
|
+
typer.secho(f" REWRITTEN: {rw.rewritten[:120]}", fg=typer.colors.GREEN)
|
|
1505
|
+
typer.echo(f" why: {rw.explanation}")
|
|
1506
|
+
|
|
1507
|
+
output_path = Path(output_path_str) if output_path_str else None
|
|
1508
|
+
section_filter = sections[0] if sections else None
|
|
1509
|
+
|
|
1510
|
+
typer.echo("")
|
|
1511
|
+
typer.secho("Generating optimized .tex file...", bold=True)
|
|
1512
|
+
report = rewrite_tex(
|
|
1513
|
+
resume_path,
|
|
1514
|
+
rewrites,
|
|
1515
|
+
output_path=output_path,
|
|
1516
|
+
section_filter=section_filter,
|
|
1517
|
+
)
|
|
1518
|
+
|
|
1519
|
+
typer.echo(f" Replacements: {report.replacements}")
|
|
1520
|
+
if report.skipped:
|
|
1521
|
+
typer.secho(
|
|
1522
|
+
f" Skipped: {report.skipped} (could not match to TeX source)",
|
|
1523
|
+
fg=typer.colors.YELLOW,
|
|
1524
|
+
)
|
|
1525
|
+
typer.secho(f" Output: {report.output_path}", fg=typer.colors.GREEN)
|
|
1526
|
+
|
|
1527
|
+
if compile_pdf:
|
|
1528
|
+
from receipts.rewrite.compiler import compile_tex
|
|
1529
|
+
|
|
1530
|
+
typer.echo("")
|
|
1531
|
+
typer.echo("Compiling to PDF...")
|
|
1532
|
+
comp = compile_tex(report.output_path)
|
|
1533
|
+
if comp.success:
|
|
1534
|
+
typer.secho(
|
|
1535
|
+
f" PDF: {comp.pdf_path} (via {comp.tool_used})", fg=typer.colors.GREEN
|
|
1536
|
+
)
|
|
1537
|
+
else:
|
|
1538
|
+
typer.secho(f" Compilation failed: {comp.error}", fg=typer.colors.YELLOW)
|
|
1539
|
+
typer.echo(" You can compile manually or use Overleaf.")
|
|
1540
|
+
|
|
1541
|
+
if jd_path_str and Path(jd_path_str).is_file():
|
|
1542
|
+
from receipts.ats.comparator import compare_tex_files
|
|
1543
|
+
|
|
1544
|
+
typer.echo("")
|
|
1545
|
+
typer.secho("ATS Score Comparison:", bold=True)
|
|
1546
|
+
comparison = compare_tex_files(
|
|
1547
|
+
resume_path,
|
|
1548
|
+
report.output_path,
|
|
1549
|
+
Path(jd_path_str).read_text(encoding="utf-8"),
|
|
1550
|
+
)
|
|
1551
|
+
typer.echo(comparison.summary)
|
|
1552
|
+
if comparison.improved:
|
|
1553
|
+
typer.secho(
|
|
1554
|
+
f"\n Resume improved by {comparison.composite_delta:+.1f} points.",
|
|
1555
|
+
fg=typer.colors.GREEN,
|
|
1556
|
+
)
|
|
1557
|
+
else:
|
|
1558
|
+
typer.secho(
|
|
1559
|
+
f"\n Resume score changed by {comparison.composite_delta:+.1f} points.",
|
|
1560
|
+
fg=typer.colors.YELLOW,
|
|
1561
|
+
)
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
@app.command()
|
|
1565
|
+
def score(
|
|
1566
|
+
resume: str = typer.Argument(
|
|
1567
|
+
..., help="Path to the resume: .tex, text-based .pdf, or .txt."
|
|
1568
|
+
),
|
|
1569
|
+
jd: str | None = typer.Argument(
|
|
1570
|
+
None,
|
|
1571
|
+
help="Path to a job description text file (or use --jd-url).",
|
|
1572
|
+
),
|
|
1573
|
+
jd_url: str | None = typer.Option(
|
|
1574
|
+
None,
|
|
1575
|
+
"--jd-url",
|
|
1576
|
+
help="Fetch the job description from a URL instead of a file.",
|
|
1577
|
+
),
|
|
1578
|
+
compare: str | None = typer.Option(
|
|
1579
|
+
None,
|
|
1580
|
+
"--compare",
|
|
1581
|
+
"-c",
|
|
1582
|
+
help="Path to a second .tex file (rewritten) to compare against.",
|
|
1583
|
+
),
|
|
1584
|
+
stats: bool = typer.Option(
|
|
1585
|
+
False,
|
|
1586
|
+
"--stats",
|
|
1587
|
+
help="Show statistical confidence intervals on improvement.",
|
|
1588
|
+
),
|
|
1589
|
+
use_tui: bool = typer.Option(
|
|
1590
|
+
False,
|
|
1591
|
+
"--tui",
|
|
1592
|
+
help="Use the full-screen TUI dashboard.",
|
|
1593
|
+
),
|
|
1594
|
+
) -> None:
|
|
1595
|
+
"""Score a resume against a job description (ATS scoring engine)."""
|
|
1596
|
+
try:
|
|
1597
|
+
if jd is None and jd_url is None:
|
|
1598
|
+
raise ReceiptsError(
|
|
1599
|
+
"Provide a JD: a text-file path, or --jd-url <posting url>."
|
|
1600
|
+
)
|
|
1601
|
+
jd = _resolve_jd(jd, jd_url)
|
|
1602
|
+
except ReceiptsError as exc:
|
|
1603
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
1604
|
+
raise typer.Exit(1) from exc
|
|
1605
|
+
|
|
1606
|
+
if use_tui:
|
|
1607
|
+
from receipts.tui.score_app import ScoreApp
|
|
1608
|
+
|
|
1609
|
+
score_app = ScoreApp(
|
|
1610
|
+
Path(resume),
|
|
1611
|
+
Path(jd),
|
|
1612
|
+
compare_path=Path(compare) if compare else None,
|
|
1613
|
+
show_stats=stats,
|
|
1614
|
+
)
|
|
1615
|
+
score_app.run()
|
|
1616
|
+
return
|
|
1617
|
+
try:
|
|
1618
|
+
_run_score(Path(resume), Path(jd), compare, stats)
|
|
1619
|
+
except ReceiptsError as exc:
|
|
1620
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
1621
|
+
raise typer.Exit(1) from exc
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def _run_score(
|
|
1625
|
+
resume_path: Path,
|
|
1626
|
+
jd_path: Path,
|
|
1627
|
+
compare_path_str: str | None,
|
|
1628
|
+
show_stats: bool,
|
|
1629
|
+
) -> None:
|
|
1630
|
+
from receipts.ats.scorer import score_tex_file
|
|
1631
|
+
|
|
1632
|
+
if not resume_path.is_file():
|
|
1633
|
+
typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
|
|
1634
|
+
raise typer.Exit(1)
|
|
1635
|
+
if not jd_path.is_file():
|
|
1636
|
+
typer.secho(f"JD file not found: {jd_path}", fg=typer.colors.RED, err=True)
|
|
1637
|
+
raise typer.Exit(1)
|
|
1638
|
+
|
|
1639
|
+
jd_text = jd_path.read_text(encoding="utf-8")
|
|
1640
|
+
|
|
1641
|
+
typer.echo(f"Scoring {resume_path.name} against {jd_path.name} ...")
|
|
1642
|
+
result = score_tex_file(resume_path, jd_text)
|
|
1643
|
+
|
|
1644
|
+
typer.echo("")
|
|
1645
|
+
typer.secho(f"ATS Score: {result.composite:.1f}/100", bold=True)
|
|
1646
|
+
typer.echo("")
|
|
1647
|
+
for dim in result.dimensions:
|
|
1648
|
+
bar = "█" * (dim.score // 5) + "░" * (20 - dim.score // 5)
|
|
1649
|
+
typer.echo(f" {dim.name:<22} {dim.score:>3}/100 {bar}")
|
|
1650
|
+
typer.echo(f" {dim.detail}")
|
|
1651
|
+
|
|
1652
|
+
typer.echo("")
|
|
1653
|
+
typer.echo(f" Bullets analyzed: {len(result.bullet_scores)}")
|
|
1654
|
+
|
|
1655
|
+
if compare_path_str:
|
|
1656
|
+
from receipts.ats.comparator import compare_tex_files
|
|
1657
|
+
|
|
1658
|
+
compare_path = Path(compare_path_str)
|
|
1659
|
+
if not compare_path.is_file():
|
|
1660
|
+
typer.secho(
|
|
1661
|
+
f"Comparison file not found: {compare_path}",
|
|
1662
|
+
fg=typer.colors.RED,
|
|
1663
|
+
err=True,
|
|
1664
|
+
)
|
|
1665
|
+
raise typer.Exit(1)
|
|
1666
|
+
|
|
1667
|
+
typer.echo("")
|
|
1668
|
+
typer.secho("Before/After Comparison:", bold=True)
|
|
1669
|
+
report = compare_tex_files(resume_path, compare_path, jd_text)
|
|
1670
|
+
typer.echo(report.summary)
|
|
1671
|
+
|
|
1672
|
+
if report.improved:
|
|
1673
|
+
typer.secho(
|
|
1674
|
+
f"\n Resume improved by {report.composite_delta:+.1f} points.",
|
|
1675
|
+
fg=typer.colors.GREEN,
|
|
1676
|
+
)
|
|
1677
|
+
else:
|
|
1678
|
+
typer.secho(
|
|
1679
|
+
f"\n Resume score changed by {report.composite_delta:+.1f} points.",
|
|
1680
|
+
fg=typer.colors.YELLOW,
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
if show_stats:
|
|
1684
|
+
from receipts.ats.stats import compute_improvement
|
|
1685
|
+
|
|
1686
|
+
typer.echo("")
|
|
1687
|
+
typer.secho("Statistical Analysis:", bold=True)
|
|
1688
|
+
improvement = compute_improvement(report.before, report.after)
|
|
1689
|
+
typer.echo(improvement.summary)
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
@app.command()
|
|
1693
|
+
def dossier(
|
|
1694
|
+
resume: str = typer.Argument(
|
|
1695
|
+
..., help="Path to the resume: .tex, text-based .pdf, or .txt."
|
|
1696
|
+
),
|
|
1697
|
+
source: str = typer.Argument(
|
|
1698
|
+
..., help="Local folder of the codebase (must have been ingested)."
|
|
1699
|
+
),
|
|
1700
|
+
output: str | None = typer.Option(
|
|
1701
|
+
None,
|
|
1702
|
+
"--output",
|
|
1703
|
+
"-o",
|
|
1704
|
+
help="Output path for the dossier. Defaults to dossier.md.",
|
|
1705
|
+
),
|
|
1706
|
+
threshold: float = typer.Option(
|
|
1707
|
+
0.5, "--threshold", help="Cosine distance threshold for evidence relevance."
|
|
1708
|
+
),
|
|
1709
|
+
section: list[str] | None = typer.Option(
|
|
1710
|
+
None,
|
|
1711
|
+
"--section",
|
|
1712
|
+
help="Only cover these resume sections (substring match, repeatable).",
|
|
1713
|
+
),
|
|
1714
|
+
entry: list[str] | None = typer.Option(
|
|
1715
|
+
None,
|
|
1716
|
+
"--entry",
|
|
1717
|
+
help="Only cover entries whose heading matches (substring, repeatable).",
|
|
1718
|
+
),
|
|
1719
|
+
scope_all: bool = typer.Option(
|
|
1720
|
+
False,
|
|
1721
|
+
"--all",
|
|
1722
|
+
help="Cover the whole resume without the entry picker.",
|
|
1723
|
+
),
|
|
1724
|
+
pdf: bool = typer.Option(
|
|
1725
|
+
False,
|
|
1726
|
+
"--pdf",
|
|
1727
|
+
help="Also export the dossier to PDF (needs the [pdf] extra).",
|
|
1728
|
+
),
|
|
1729
|
+
use_tui: bool = typer.Option(
|
|
1730
|
+
False, "--tui", help="Use the full-screen TUI with a markdown preview."
|
|
1731
|
+
),
|
|
1732
|
+
) -> None:
|
|
1733
|
+
"""Generate the interview defense dossier — your night-before study doc."""
|
|
1734
|
+
try:
|
|
1735
|
+
entries = _resolve_entries(Path(resume), section, entry, scope_all)
|
|
1736
|
+
if use_tui:
|
|
1737
|
+
from receipts.tui.dossier_app import DossierApp
|
|
1738
|
+
|
|
1739
|
+
DossierApp(
|
|
1740
|
+
Path(resume),
|
|
1741
|
+
Path(source),
|
|
1742
|
+
threshold=threshold,
|
|
1743
|
+
sections=section,
|
|
1744
|
+
entries=entries,
|
|
1745
|
+
output_path=Path(output) if output else None,
|
|
1746
|
+
).run()
|
|
1747
|
+
return
|
|
1748
|
+
written = _run_dossier(
|
|
1749
|
+
Path(resume), source, output, threshold, section, entries
|
|
1750
|
+
)
|
|
1751
|
+
if pdf and written is not None:
|
|
1752
|
+
from receipts.export import export_markdown_pdf
|
|
1753
|
+
|
|
1754
|
+
result = export_markdown_pdf(written)
|
|
1755
|
+
if result.success:
|
|
1756
|
+
typer.secho(f" PDF: {result.pdf_path}", fg=typer.colors.GREEN)
|
|
1757
|
+
else:
|
|
1758
|
+
typer.secho(
|
|
1759
|
+
f" PDF export failed: {result.error}", fg=typer.colors.YELLOW
|
|
1760
|
+
)
|
|
1761
|
+
except ReceiptsError as exc:
|
|
1762
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
1763
|
+
raise typer.Exit(1) from exc
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def _run_dossier(
|
|
1767
|
+
resume_path: Path,
|
|
1768
|
+
source_spec: str | Path,
|
|
1769
|
+
output_str: str | None,
|
|
1770
|
+
threshold: float,
|
|
1771
|
+
sections: list[str] | None,
|
|
1772
|
+
entries: list[str] | None,
|
|
1773
|
+
) -> Path | None:
|
|
1774
|
+
from datetime import date
|
|
1775
|
+
|
|
1776
|
+
from receipts.config import load_settings
|
|
1777
|
+
from receipts.llm.factory import get_provider
|
|
1778
|
+
from receipts.prep.dossier import build_dossier, render_markdown
|
|
1779
|
+
from receipts.prep.readiness import ReadinessStore
|
|
1780
|
+
from receipts.resume.claim_extractor import extract_claims
|
|
1781
|
+
from receipts.resume.loader import load_resume
|
|
1782
|
+
from receipts.verify.kb import KnowledgeBase
|
|
1783
|
+
from receipts.verify.verifier import verify_claims
|
|
1784
|
+
|
|
1785
|
+
if not resume_path.is_file():
|
|
1786
|
+
typer.secho(f"Resume not found: {resume_path}", fg=typer.colors.RED, err=True)
|
|
1787
|
+
raise typer.Exit(1)
|
|
1788
|
+
|
|
1789
|
+
kb_dir, source_label = _resolve_kb_source(str(source_spec))
|
|
1790
|
+
kb = KnowledgeBase(kb_dir)
|
|
1791
|
+
if kb.count() == 0:
|
|
1792
|
+
typer.secho(
|
|
1793
|
+
f"Knowledge base for '{source_label}' is empty."
|
|
1794
|
+
" Run 'receipts ingest' first.",
|
|
1795
|
+
fg=typer.colors.RED,
|
|
1796
|
+
err=True,
|
|
1797
|
+
)
|
|
1798
|
+
raise typer.Exit(1)
|
|
1799
|
+
|
|
1800
|
+
settings = load_settings()
|
|
1801
|
+
provider = get_provider(settings, verify=True)
|
|
1802
|
+
|
|
1803
|
+
typer.echo(f"Parsing {resume_path.name} ...")
|
|
1804
|
+
resume = load_resume(resume_path)
|
|
1805
|
+
claims = extract_claims(resume, sections=sections, entries=entries)
|
|
1806
|
+
typer.echo(f" Claims extracted: {len(claims)}")
|
|
1807
|
+
|
|
1808
|
+
typer.echo(f"Verifying {len(claims)} claim(s) ...")
|
|
1809
|
+
results = verify_claims(
|
|
1810
|
+
claims,
|
|
1811
|
+
kb,
|
|
1812
|
+
provider,
|
|
1813
|
+
distance_threshold=threshold,
|
|
1814
|
+
batch=provider.name != "ollama",
|
|
1815
|
+
)
|
|
1816
|
+
|
|
1817
|
+
# Weak spots from past mock-interview sessions feed the drill notes.
|
|
1818
|
+
store = ReadinessStore()
|
|
1819
|
+
try:
|
|
1820
|
+
weak_spots = store.weak_spots()
|
|
1821
|
+
finally:
|
|
1822
|
+
store.close()
|
|
1823
|
+
if weak_spots:
|
|
1824
|
+
typer.echo(f" Including {len(weak_spots)} weak spot(s) from past sessions.")
|
|
1825
|
+
|
|
1826
|
+
typer.echo("")
|
|
1827
|
+
typer.secho("Building dossier (one LLM call per bullet)...", bold=True)
|
|
1828
|
+
|
|
1829
|
+
def _progress(done: int, total: int) -> None:
|
|
1830
|
+
typer.echo(f" bullet {done}/{total}")
|
|
1831
|
+
|
|
1832
|
+
entries_built = build_dossier(
|
|
1833
|
+
results, provider, weak_spots=weak_spots, on_progress=_progress
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
doc = render_markdown(
|
|
1837
|
+
entries_built,
|
|
1838
|
+
resume_name=resume.name,
|
|
1839
|
+
source_name=source_label,
|
|
1840
|
+
generated=date.today().isoformat(),
|
|
1841
|
+
)
|
|
1842
|
+
|
|
1843
|
+
output_path = Path(output_str) if output_str else Path("dossier.md")
|
|
1844
|
+
output_path.write_text(doc, encoding="utf-8")
|
|
1845
|
+
|
|
1846
|
+
typer.echo("")
|
|
1847
|
+
typer.secho(f"Dossier written: {output_path}", fg=typer.colors.GREEN, bold=True)
|
|
1848
|
+
typer.echo(f" Bullets covered: {len(entries_built)}")
|
|
1849
|
+
unsupported = sum(1 for e in entries_built if e.verdict == "unsupported")
|
|
1850
|
+
if unsupported:
|
|
1851
|
+
typer.secho(
|
|
1852
|
+
f" Study the {unsupported} UNSUPPORTED bullet(s) first —"
|
|
1853
|
+
" they're listed at the top of each section.",
|
|
1854
|
+
fg=typer.colors.YELLOW,
|
|
1855
|
+
)
|
|
1856
|
+
return output_path
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
@app.command()
|
|
1860
|
+
def readiness(
|
|
1861
|
+
use_tui: bool = typer.Option(
|
|
1862
|
+
False, "--tui", help="Open the readiness screen in the TUI hub."
|
|
1863
|
+
),
|
|
1864
|
+
) -> None:
|
|
1865
|
+
"""Show mock-interview readiness — trends across saved AMA sessions."""
|
|
1866
|
+
if use_tui:
|
|
1867
|
+
from receipts.tui.hub import HubApp
|
|
1868
|
+
|
|
1869
|
+
HubApp(start="readiness").run()
|
|
1870
|
+
return
|
|
1871
|
+
|
|
1872
|
+
from datetime import datetime
|
|
1873
|
+
|
|
1874
|
+
from receipts.prep.readiness import ReadinessStore, readiness_delta
|
|
1875
|
+
|
|
1876
|
+
store = ReadinessStore()
|
|
1877
|
+
try:
|
|
1878
|
+
sessions = store.sessions()
|
|
1879
|
+
weakest = store.weakest_section()
|
|
1880
|
+
weak_spots = store.weak_spots(limit=5)
|
|
1881
|
+
finally:
|
|
1882
|
+
store.close()
|
|
1883
|
+
|
|
1884
|
+
if not sessions:
|
|
1885
|
+
typer.echo(
|
|
1886
|
+
"No saved interview sessions yet."
|
|
1887
|
+
" Run 'receipts ama <resume> <source>' first."
|
|
1888
|
+
)
|
|
1889
|
+
return
|
|
1890
|
+
|
|
1891
|
+
typer.secho("Mock-interview readiness", bold=True)
|
|
1892
|
+
typer.echo("")
|
|
1893
|
+
|
|
1894
|
+
for s in sessions:
|
|
1895
|
+
when = datetime.fromtimestamp(s.timestamp).strftime("%Y-%m-%d %H:%M")
|
|
1896
|
+
typer.echo(
|
|
1897
|
+
f" {when} {s.resume_name:<24} "
|
|
1898
|
+
f"{s.held_rounds}/{s.total_rounds} held up ({s.ratio:.0%})"
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1901
|
+
delta = readiness_delta(sessions)
|
|
1902
|
+
if delta:
|
|
1903
|
+
latest, previous = delta
|
|
1904
|
+
change = (latest - previous) * 100
|
|
1905
|
+
color = typer.colors.GREEN if change >= 0 else typer.colors.RED
|
|
1906
|
+
typer.echo("")
|
|
1907
|
+
typer.secho(
|
|
1908
|
+
f" Trend: {previous:.0%} → {latest:.0%} ({change:+.0f} pts)",
|
|
1909
|
+
fg=color,
|
|
1910
|
+
bold=True,
|
|
1911
|
+
)
|
|
1912
|
+
|
|
1913
|
+
latest_session = sessions[-1]
|
|
1914
|
+
if latest_session.sections:
|
|
1915
|
+
typer.echo("")
|
|
1916
|
+
typer.secho(" Latest session by section:", bold=True)
|
|
1917
|
+
for stat in sorted(latest_session.sections, key=lambda s: s.ratio):
|
|
1918
|
+
bar_len = int(stat.ratio * 10)
|
|
1919
|
+
bar = "#" * bar_len + "-" * (10 - bar_len)
|
|
1920
|
+
typer.echo(f" {stat.section:<24} {stat.held}/{stat.total} [{bar}]")
|
|
1921
|
+
|
|
1922
|
+
if weakest and weakest.ratio < 1.0:
|
|
1923
|
+
typer.echo("")
|
|
1924
|
+
typer.secho(
|
|
1925
|
+
f" Drill next: {weakest.section} "
|
|
1926
|
+
f"({weakest.held}/{weakest.total} held up) — try"
|
|
1927
|
+
f' receipts ama <resume> <source> --section "{weakest.section}"',
|
|
1928
|
+
fg=typer.colors.YELLOW,
|
|
1929
|
+
)
|
|
1930
|
+
|
|
1931
|
+
if weak_spots:
|
|
1932
|
+
typer.echo("")
|
|
1933
|
+
typer.secho(" Recent stumbles:", bold=True)
|
|
1934
|
+
for spot in weak_spots:
|
|
1935
|
+
typer.echo(f" - {spot.bullet_text[:70]}")
|
|
1936
|
+
typer.echo(f" {spot.assessment[:90]}")
|
|
1937
|
+
if spot.model_answer:
|
|
1938
|
+
typer.secho(
|
|
1939
|
+
f" → Better answer: {spot.model_answer[:110]}",
|
|
1940
|
+
fg=typer.colors.BLUE,
|
|
1941
|
+
)
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
@app.command()
|
|
1945
|
+
def export(
|
|
1946
|
+
source: str = typer.Argument(
|
|
1947
|
+
..., help="File to export: a .md (dossier/report) or .tex (resume)."
|
|
1948
|
+
),
|
|
1949
|
+
output: str | None = typer.Option(
|
|
1950
|
+
None, "--output", "-o", help="Output PDF path. Defaults to <source>.pdf."
|
|
1951
|
+
),
|
|
1952
|
+
) -> None:
|
|
1953
|
+
"""Export a markdown or TeX file to PDF.
|
|
1954
|
+
|
|
1955
|
+
Markdown needs no external programs (pip install codebase-receipts-cli[pdf]).
|
|
1956
|
+
TeX uses latexmk/tectonic/pdflatex when installed.
|
|
1957
|
+
"""
|
|
1958
|
+
from receipts.export import export_to_pdf
|
|
1959
|
+
|
|
1960
|
+
source_path = Path(source)
|
|
1961
|
+
if not source_path.is_file():
|
|
1962
|
+
typer.secho(f"File not found: {source_path}", fg=typer.colors.RED, err=True)
|
|
1963
|
+
raise typer.Exit(1)
|
|
1964
|
+
|
|
1965
|
+
try:
|
|
1966
|
+
result = export_to_pdf(source_path, Path(output) if output else None)
|
|
1967
|
+
except ReceiptsError as exc:
|
|
1968
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
1969
|
+
raise typer.Exit(1) from exc
|
|
1970
|
+
|
|
1971
|
+
if result.success:
|
|
1972
|
+
typer.secho(
|
|
1973
|
+
f"PDF written: {result.pdf_path} (via {result.tool_used})",
|
|
1974
|
+
fg=typer.colors.GREEN,
|
|
1975
|
+
)
|
|
1976
|
+
else:
|
|
1977
|
+
typer.secho(f"Export failed: {result.error}", fg=typer.colors.RED, err=True)
|
|
1978
|
+
raise typer.Exit(1)
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
@app.command()
|
|
1982
|
+
def ledger(
|
|
1983
|
+
session: bool = typer.Option(
|
|
1984
|
+
False,
|
|
1985
|
+
"--session",
|
|
1986
|
+
help="Show only the most recent session (not lifetime totals).",
|
|
1987
|
+
),
|
|
1988
|
+
use_tui: bool = typer.Option(
|
|
1989
|
+
False, "--tui", help="Open the ledger screen in the TUI hub."
|
|
1990
|
+
),
|
|
1991
|
+
) -> None:
|
|
1992
|
+
"""Show the token/cost ledger — lifetime totals by default."""
|
|
1993
|
+
if use_tui:
|
|
1994
|
+
from receipts.tui.hub import HubApp
|
|
1995
|
+
|
|
1996
|
+
HubApp(start="ledger").run()
|
|
1997
|
+
return
|
|
1998
|
+
|
|
1999
|
+
from receipts.ledger.token_ledger import TokenLedger
|
|
2000
|
+
|
|
2001
|
+
tl = TokenLedger()
|
|
2002
|
+
try:
|
|
2003
|
+
latest = tl.last_session_summary() if session else None
|
|
2004
|
+
lt = tl.lifetime_summary()
|
|
2005
|
+
finally:
|
|
2006
|
+
tl.close()
|
|
2007
|
+
|
|
2008
|
+
if lt.call_count == 0:
|
|
2009
|
+
typer.echo("No LLM calls recorded yet. Run a command first.")
|
|
2010
|
+
return
|
|
2011
|
+
|
|
2012
|
+
if session:
|
|
2013
|
+
# latest can't be None here: lifetime has calls, so a session exists.
|
|
2014
|
+
typer.secho("Most recent session", bold=True)
|
|
2015
|
+
typer.echo(f" Session id: {latest.session_id}")
|
|
2016
|
+
typer.echo(f" LLM calls: {latest.call_count}")
|
|
2017
|
+
typer.echo(f" Input tokens: {latest.total_input:,}")
|
|
2018
|
+
typer.echo(f" Output tokens: {latest.total_output:,}")
|
|
2019
|
+
typer.echo(f" Est. cost: ${latest.total_cost:.4f}")
|
|
2020
|
+
return
|
|
2021
|
+
|
|
2022
|
+
typer.secho("Lifetime token ledger", bold=True)
|
|
2023
|
+
typer.echo(f" Sessions: {lt.session_count}")
|
|
2024
|
+
typer.echo(f" LLM calls: {lt.call_count}")
|
|
2025
|
+
typer.echo(f" Input tokens: {lt.total_input:,}")
|
|
2026
|
+
typer.echo(f" Output tokens: {lt.total_output:,}")
|
|
2027
|
+
typer.echo(f" Est. cost: ${lt.total_cost:.4f}")
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
@app.command()
|
|
2031
|
+
def dashboard() -> None:
|
|
2032
|
+
"""One-screen TUI: provider status, token spend, interview readiness."""
|
|
2033
|
+
from receipts.tui.hub import HubApp
|
|
2034
|
+
|
|
2035
|
+
HubApp(start="dashboard").run()
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
@app.command(name="interactive")
|
|
2039
|
+
def interactive() -> None:
|
|
2040
|
+
"""Drop into the interactive slash-command REPL."""
|
|
2041
|
+
from receipts.interactive.repl import run_repl
|
|
2042
|
+
|
|
2043
|
+
run_repl()
|
|
2044
|
+
|
|
2045
|
+
|
|
2046
|
+
def main() -> None:
|
|
2047
|
+
# Windows consoles/pipes can default to cp1252, which can't encode the
|
|
2048
|
+
# score bars (█░) or arrows (→). Force UTF-8 with graceful degradation.
|
|
2049
|
+
for stream in (sys.stdout, sys.stderr):
|
|
2050
|
+
if hasattr(stream, "reconfigure") and (stream.encoding or "").lower() not in (
|
|
2051
|
+
"utf-8",
|
|
2052
|
+
"utf8",
|
|
2053
|
+
):
|
|
2054
|
+
try:
|
|
2055
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
2056
|
+
except (OSError, ValueError):
|
|
2057
|
+
pass
|
|
2058
|
+
app()
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
if __name__ == "__main__":
|
|
2062
|
+
main()
|