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/verify/kb.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Chroma-backed knowledge base over ingested code artifacts.
|
|
2
|
+
|
|
3
|
+
Artifacts from the extractor are already individual functions, classes, or
|
|
4
|
+
file excerpts — each one becomes a single Chroma document. Embeddings come
|
|
5
|
+
from whatever LLM provider the user configured (Ollama, Gemini, etc.),
|
|
6
|
+
keeping the KB module provider-agnostic.
|
|
7
|
+
|
|
8
|
+
Persistence lives under ``~/.receipts/kb/<project>/`` so scanned repos are
|
|
9
|
+
never polluted with index files.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import chromadb
|
|
23
|
+
except (ImportError, OSError) as _chroma_err:
|
|
24
|
+
import platform as _platform
|
|
25
|
+
import sys as _sys
|
|
26
|
+
|
|
27
|
+
_arch = _platform.machine()
|
|
28
|
+
_os = _platform.system()
|
|
29
|
+
_py = _sys.version.split()[0]
|
|
30
|
+
raise SystemExit(
|
|
31
|
+
f"\n[receipts] chromadb failed to load on {_os}/{_arch} (Python {_py}).\n"
|
|
32
|
+
"\n"
|
|
33
|
+
"chromadb bundles hnswlib, a C++ extension. If no prebuilt wheel\n"
|
|
34
|
+
"exists for your architecture, pip needs to compile it from source.\n"
|
|
35
|
+
"\n"
|
|
36
|
+
" Linux/ARM64:\n"
|
|
37
|
+
" sudo apt install build-essential python3-dev\n"
|
|
38
|
+
" pip install chromadb\n"
|
|
39
|
+
"\n"
|
|
40
|
+
" macOS:\n"
|
|
41
|
+
" xcode-select --install && pip install chromadb\n"
|
|
42
|
+
"\n"
|
|
43
|
+
" Windows:\n"
|
|
44
|
+
" Install Visual Studio Build Tools:\n"
|
|
45
|
+
" https://visualstudio.microsoft.com/visual-cpp-build-tools/\n"
|
|
46
|
+
" then: pip install chromadb\n"
|
|
47
|
+
"\n"
|
|
48
|
+
"If you are on an unsupported or unusual architecture (e.g. 32-bit x86,\n"
|
|
49
|
+
"RISC-V), chromadb may not be buildable. Open an issue and we can\n"
|
|
50
|
+
"evaluate a fallback vector store:\n"
|
|
51
|
+
"https://github.com/Ishaan-1606/receipts-cli/issues\n"
|
|
52
|
+
"\n"
|
|
53
|
+
f"Original error: {_chroma_err}\n"
|
|
54
|
+
) from None
|
|
55
|
+
|
|
56
|
+
from receipts.ingest.artifact_extractor import Artifact
|
|
57
|
+
from receipts.llm.provider import LLMProvider
|
|
58
|
+
from receipts.verify.bm25 import BM25Index, rrf_fuse
|
|
59
|
+
|
|
60
|
+
_EMBED_BATCH_SIZE = 32
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hybrid_enabled() -> bool:
|
|
64
|
+
"""Hybrid (dense + BM25) retrieval toggle — on unless RECEIPTS_HYBRID=0."""
|
|
65
|
+
return os.environ.get("RECEIPTS_HYBRID", "1").strip() != "0"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cosine_distance(a: list[float], b: list[float]) -> float:
|
|
69
|
+
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
|
70
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
71
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
72
|
+
if not norm_a or not norm_b:
|
|
73
|
+
return 1.0
|
|
74
|
+
return 1.0 - dot / (norm_a * norm_b)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class SearchResult:
|
|
79
|
+
artifact_id: str
|
|
80
|
+
rel_path: str
|
|
81
|
+
kind: str
|
|
82
|
+
name: str
|
|
83
|
+
language: str
|
|
84
|
+
start_line: int
|
|
85
|
+
end_line: int
|
|
86
|
+
code: str
|
|
87
|
+
distance: float
|
|
88
|
+
source: str = "" # ingest source label, "" for pre-18C chunks
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class SyncStats:
|
|
93
|
+
"""Outcome of an incremental sync — every artifact is accounted for."""
|
|
94
|
+
|
|
95
|
+
added: int
|
|
96
|
+
changed: int
|
|
97
|
+
unchanged: int
|
|
98
|
+
removed: int
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def embedded(self) -> int:
|
|
102
|
+
return self.added + self.changed
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class KnowledgeBase:
|
|
106
|
+
"""Vector store over code artifacts backed by ChromaDB."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, persist_dir: Path, collection_name: str = "artifacts"):
|
|
109
|
+
self._persist_dir = Path(persist_dir)
|
|
110
|
+
self._collection_name = collection_name
|
|
111
|
+
self._client = chromadb.PersistentClient(path=str(self._persist_dir))
|
|
112
|
+
self._collection = self._client.get_or_create_collection(
|
|
113
|
+
name=collection_name,
|
|
114
|
+
metadata={"hnsw:space": "cosine"},
|
|
115
|
+
)
|
|
116
|
+
# Lazy BM25 index over the collection's documents (Phase 22A).
|
|
117
|
+
# Derived from the same store, invalidated on every write — it can
|
|
118
|
+
# never drift out of sync with the vectors.
|
|
119
|
+
self._bm25: BM25Index | None = None
|
|
120
|
+
self._bm25_source: dict[str, str] = {}
|
|
121
|
+
|
|
122
|
+
def _bm25_index(self) -> BM25Index:
|
|
123
|
+
if self._bm25 is None:
|
|
124
|
+
got = self._collection.get(include=["documents", "metadatas"])
|
|
125
|
+
docs: dict[str, str] = {}
|
|
126
|
+
self._bm25_source = {}
|
|
127
|
+
for id_, doc, meta in zip(
|
|
128
|
+
got["ids"],
|
|
129
|
+
got["documents"] or [],
|
|
130
|
+
got["metadatas"] or [],
|
|
131
|
+
strict=False,
|
|
132
|
+
):
|
|
133
|
+
docs[id_] = doc or ""
|
|
134
|
+
self._bm25_source[id_] = str((meta or {}).get("source", ""))
|
|
135
|
+
self._bm25 = BM25Index(docs)
|
|
136
|
+
return self._bm25
|
|
137
|
+
|
|
138
|
+
def add_artifacts(
|
|
139
|
+
self,
|
|
140
|
+
artifacts: list[Artifact],
|
|
141
|
+
provider: LLMProvider,
|
|
142
|
+
*,
|
|
143
|
+
source: str = "",
|
|
144
|
+
batch_size: int = _EMBED_BATCH_SIZE,
|
|
145
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
146
|
+
) -> int:
|
|
147
|
+
"""Embed and store artifacts. Returns the number stored.
|
|
148
|
+
|
|
149
|
+
``on_progress(stored_so_far, total)`` fires after each batch so
|
|
150
|
+
callers can show progress during slow (rate-limited) embedding runs.
|
|
151
|
+
"""
|
|
152
|
+
if not artifacts:
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
stored = 0
|
|
156
|
+
for start in range(0, len(artifacts), batch_size):
|
|
157
|
+
batch = artifacts[start : start + batch_size]
|
|
158
|
+
texts = [a.code for a in batch]
|
|
159
|
+
embeddings = provider.embed(texts)
|
|
160
|
+
ids = [_artifact_id(a) for a in batch]
|
|
161
|
+
metadatas = [_artifact_meta(a, source) for a in batch]
|
|
162
|
+
self._collection.upsert(
|
|
163
|
+
ids=ids,
|
|
164
|
+
embeddings=embeddings,
|
|
165
|
+
documents=texts,
|
|
166
|
+
metadatas=metadatas,
|
|
167
|
+
)
|
|
168
|
+
stored += len(batch)
|
|
169
|
+
if on_progress is not None:
|
|
170
|
+
on_progress(stored, len(artifacts))
|
|
171
|
+
self._bm25 = None # collection changed — rebuild lexical index lazily
|
|
172
|
+
return stored
|
|
173
|
+
|
|
174
|
+
def sync_artifacts(
|
|
175
|
+
self,
|
|
176
|
+
artifacts: list[Artifact],
|
|
177
|
+
provider: LLMProvider,
|
|
178
|
+
*,
|
|
179
|
+
source: str = "",
|
|
180
|
+
batch_size: int = _EMBED_BATCH_SIZE,
|
|
181
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
182
|
+
) -> SyncStats:
|
|
183
|
+
"""Incremental upsert: embed only new/changed artifacts, drop stale ones.
|
|
184
|
+
|
|
185
|
+
Change detection compares the content hash stored in each chunk's
|
|
186
|
+
metadata, so re-ingesting after a small edit re-embeds only what
|
|
187
|
+
actually changed — the rest of the corpus is skipped entirely.
|
|
188
|
+
``on_progress`` receives (embedded_so_far, total_to_embed), i.e. only
|
|
189
|
+
the work being done, not the artifacts being skipped.
|
|
190
|
+
"""
|
|
191
|
+
# Stable identity — duplicates keep the first occurrence.
|
|
192
|
+
unique: dict[str, Artifact] = {}
|
|
193
|
+
for a in artifacts:
|
|
194
|
+
unique.setdefault(_artifact_id(a), a)
|
|
195
|
+
|
|
196
|
+
existing: dict[str, str] = {}
|
|
197
|
+
got = self._collection.get(include=["metadatas"])
|
|
198
|
+
for id_, meta in zip(got["ids"], got["metadatas"] or [], strict=False):
|
|
199
|
+
existing[id_] = (meta or {}).get("content_hash", "")
|
|
200
|
+
|
|
201
|
+
added = changed = unchanged = 0
|
|
202
|
+
to_embed: list[Artifact] = []
|
|
203
|
+
for id_, a in unique.items():
|
|
204
|
+
if id_ not in existing:
|
|
205
|
+
added += 1
|
|
206
|
+
to_embed.append(a)
|
|
207
|
+
elif existing[id_] != _content_hash(a):
|
|
208
|
+
changed += 1
|
|
209
|
+
to_embed.append(a)
|
|
210
|
+
else:
|
|
211
|
+
unchanged += 1
|
|
212
|
+
|
|
213
|
+
stale = [id_ for id_ in existing if id_ not in unique]
|
|
214
|
+
if stale:
|
|
215
|
+
self._collection.delete(ids=stale)
|
|
216
|
+
self._bm25 = None
|
|
217
|
+
|
|
218
|
+
if to_embed:
|
|
219
|
+
self.add_artifacts(
|
|
220
|
+
to_embed,
|
|
221
|
+
provider,
|
|
222
|
+
source=source,
|
|
223
|
+
batch_size=batch_size,
|
|
224
|
+
on_progress=on_progress,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return SyncStats(
|
|
228
|
+
added=added,
|
|
229
|
+
changed=changed,
|
|
230
|
+
unchanged=unchanged,
|
|
231
|
+
removed=len(stale),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def search(
|
|
235
|
+
self,
|
|
236
|
+
query: str,
|
|
237
|
+
provider: LLMProvider,
|
|
238
|
+
n_results: int = 5,
|
|
239
|
+
*,
|
|
240
|
+
source: str | None = None,
|
|
241
|
+
hybrid: bool | None = None,
|
|
242
|
+
) -> list[SearchResult]:
|
|
243
|
+
"""Return the closest artifacts for a query.
|
|
244
|
+
|
|
245
|
+
Hybrid mode (default, ``RECEIPTS_HYBRID=0`` to disable) runs BOTH
|
|
246
|
+
retrievers — dense cosine similarity and BM25 lexical matching —
|
|
247
|
+
and merges their rankings with reciprocal rank fusion. BM25 catches
|
|
248
|
+
the exact identifiers embeddings blur ("pgvector", "Alembic");
|
|
249
|
+
crucially, every fused candidate still carries its TRUE semantic
|
|
250
|
+
distance, so the verifier's evidence threshold gates hybrid results
|
|
251
|
+
exactly as strictly as dense-only ones. More recall, same honesty.
|
|
252
|
+
|
|
253
|
+
``source`` restricts results to chunks ingested from that source
|
|
254
|
+
label — the guard against cross-source evidence contamination in
|
|
255
|
+
mixed corpora (a claim about project A must not "verify" against
|
|
256
|
+
lookalike code from project B).
|
|
257
|
+
"""
|
|
258
|
+
count = self._collection.count()
|
|
259
|
+
if count == 0:
|
|
260
|
+
return []
|
|
261
|
+
if hybrid is None:
|
|
262
|
+
hybrid = _hybrid_enabled()
|
|
263
|
+
|
|
264
|
+
n_results = min(n_results, count)
|
|
265
|
+
embedding = provider.embed([query])[0]
|
|
266
|
+
|
|
267
|
+
# Dense leg: over-fetch in hybrid mode so fusion has candidates.
|
|
268
|
+
fetch_n = min(count, max(n_results * 3, 10)) if hybrid else n_results
|
|
269
|
+
raw = self._collection.query(
|
|
270
|
+
query_embeddings=[embedding],
|
|
271
|
+
n_results=fetch_n,
|
|
272
|
+
where={"source": source} if source else None,
|
|
273
|
+
include=["documents", "metadatas", "distances"],
|
|
274
|
+
)
|
|
275
|
+
info: dict[str, tuple[dict, str, float]] = {}
|
|
276
|
+
dense_ids: list[str] = []
|
|
277
|
+
for i in range(len(raw["ids"][0])):
|
|
278
|
+
id_ = raw["ids"][0][i]
|
|
279
|
+
dense_ids.append(id_)
|
|
280
|
+
info[id_] = (
|
|
281
|
+
raw["metadatas"][0][i] or {},
|
|
282
|
+
raw["documents"][0][i],
|
|
283
|
+
raw["distances"][0][i],
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if hybrid:
|
|
287
|
+
index = self._bm25_index()
|
|
288
|
+
sparse = [id_ for id_, _ in index.search(query, fetch_n)]
|
|
289
|
+
if source:
|
|
290
|
+
sparse = [id_ for id_ in sparse if self._bm25_source.get(id_) == source]
|
|
291
|
+
chosen = rrf_fuse([dense_ids, sparse])[:n_results]
|
|
292
|
+
|
|
293
|
+
# BM25-only candidates lack a dense distance — compute the real
|
|
294
|
+
# cosine distance so the threshold gate treats them identically.
|
|
295
|
+
missing = [id_ for id_ in chosen if id_ not in info]
|
|
296
|
+
if missing:
|
|
297
|
+
got = self._collection.get(
|
|
298
|
+
ids=missing,
|
|
299
|
+
include=["documents", "metadatas", "embeddings"],
|
|
300
|
+
)
|
|
301
|
+
for id_, doc, meta, emb in zip(
|
|
302
|
+
got["ids"],
|
|
303
|
+
got["documents"] or [],
|
|
304
|
+
got["metadatas"] or [],
|
|
305
|
+
got["embeddings"],
|
|
306
|
+
strict=False,
|
|
307
|
+
):
|
|
308
|
+
dist = _cosine_distance(embedding, list(emb))
|
|
309
|
+
info[id_] = (meta or {}, doc or "", dist)
|
|
310
|
+
else:
|
|
311
|
+
chosen = dense_ids[:n_results]
|
|
312
|
+
|
|
313
|
+
results: list[SearchResult] = []
|
|
314
|
+
for id_ in chosen:
|
|
315
|
+
meta, doc, dist = info[id_]
|
|
316
|
+
results.append(
|
|
317
|
+
SearchResult(
|
|
318
|
+
artifact_id=id_,
|
|
319
|
+
rel_path=meta.get("rel_path", ""),
|
|
320
|
+
kind=meta.get("kind", ""),
|
|
321
|
+
name=meta.get("name", ""),
|
|
322
|
+
language=meta.get("language", ""),
|
|
323
|
+
start_line=int(meta.get("start_line", 0)),
|
|
324
|
+
end_line=int(meta.get("end_line", 0)),
|
|
325
|
+
code=doc,
|
|
326
|
+
distance=dist,
|
|
327
|
+
source=str(meta.get("source", "")),
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
# Fusion decides WHICH results surface; distance orders them, so
|
|
331
|
+
# callers keep the closest-first contract they always had.
|
|
332
|
+
results.sort(key=lambda r: r.distance)
|
|
333
|
+
return results
|
|
334
|
+
|
|
335
|
+
def count(self) -> int:
|
|
336
|
+
return self._collection.count()
|
|
337
|
+
|
|
338
|
+
def clear(self) -> None:
|
|
339
|
+
"""Drop all stored artifacts and recreate the collection."""
|
|
340
|
+
self._client.delete_collection(self._collection_name)
|
|
341
|
+
self._collection = self._client.get_or_create_collection(
|
|
342
|
+
name=self._collection_name,
|
|
343
|
+
metadata={"hnsw:space": "cosine"},
|
|
344
|
+
)
|
|
345
|
+
self._bm25 = None
|
|
346
|
+
self._bm25_source = {}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def default_kb_dir(project_root: Path) -> Path:
|
|
350
|
+
"""Derive a per-project KB directory under ``~/.receipts/kb/``."""
|
|
351
|
+
resolved = str(project_root.resolve())
|
|
352
|
+
short_hash = hashlib.sha256(resolved.encode()).hexdigest()[:8]
|
|
353
|
+
name = project_root.resolve().name
|
|
354
|
+
return Path.home() / ".receipts" / "kb" / f"{name}_{short_hash}"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _artifact_id(artifact: Artifact) -> str:
|
|
358
|
+
key = f"{artifact.rel_path}:{artifact.kind}:{artifact.name}:{artifact.start_line}"
|
|
359
|
+
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _content_hash(artifact: Artifact) -> str:
|
|
363
|
+
"""Hash of the artifact's code — the change signal for incremental sync."""
|
|
364
|
+
return hashlib.sha256(artifact.code.encode("utf-8", errors="replace")).hexdigest()[
|
|
365
|
+
:16
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _artifact_meta(artifact: Artifact, source: str = "") -> dict[str, str | int]:
|
|
370
|
+
return {
|
|
371
|
+
"rel_path": artifact.rel_path,
|
|
372
|
+
"kind": artifact.kind,
|
|
373
|
+
"name": artifact.name,
|
|
374
|
+
"language": artifact.language,
|
|
375
|
+
"start_line": artifact.start_line,
|
|
376
|
+
"end_line": artifact.end_line,
|
|
377
|
+
"content_hash": _content_hash(artifact),
|
|
378
|
+
"source": source,
|
|
379
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""JD keyword gap analysis grounded in code evidence.
|
|
2
|
+
|
|
3
|
+
Given a job description, extract technical keywords and report which
|
|
4
|
+
ones the resume already covers, which the codebase can justify adding,
|
|
5
|
+
and which have no support in either place.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from receipts.llm.json_utils import parse_json_loose
|
|
14
|
+
from receipts.llm.provider import CompletionResult, LLMProvider
|
|
15
|
+
from receipts.resume.tex_parser import ParsedResume
|
|
16
|
+
from receipts.verify.kb import KnowledgeBase
|
|
17
|
+
|
|
18
|
+
_EXTRACT_SYSTEM = """\
|
|
19
|
+
Extract the key technical skills, tools, frameworks, and qualifications \
|
|
20
|
+
from this job description. Return ONLY concrete, specific skills \
|
|
21
|
+
(e.g. "React", "PostgreSQL", "CI/CD", "REST APIs") — no soft skills \
|
|
22
|
+
or generic phrases.
|
|
23
|
+
|
|
24
|
+
Respond with a JSON array: ["skill1", "skill2", ...]
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class KeywordGapItem:
|
|
30
|
+
keyword: str
|
|
31
|
+
in_resume: bool
|
|
32
|
+
in_codebase: bool
|
|
33
|
+
suggestion: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class KeywordGapReport:
|
|
38
|
+
items: list[KeywordGapItem]
|
|
39
|
+
completion: CompletionResult | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def addable(self) -> list[KeywordGapItem]:
|
|
43
|
+
"""Keywords the code supports but resume doesn't mention."""
|
|
44
|
+
return [i for i in self.items if not i.in_resume and i.in_codebase]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def ungrounded(self) -> list[KeywordGapItem]:
|
|
48
|
+
"""Keywords the resume claims but code doesn't back up."""
|
|
49
|
+
return [i for i in self.items if i.in_resume and not i.in_codebase]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def gaps(self) -> list[KeywordGapItem]:
|
|
53
|
+
"""Keywords in the JD absent from both resume and codebase."""
|
|
54
|
+
return [i for i in self.items if not i.in_resume and not i.in_codebase]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _extract_jd_keywords(
|
|
58
|
+
jd_text: str, provider: LLMProvider
|
|
59
|
+
) -> tuple[list[str], CompletionResult]:
|
|
60
|
+
completion = provider.complete(_EXTRACT_SYSTEM, jd_text, json_mode=True)
|
|
61
|
+
try:
|
|
62
|
+
keywords = parse_json_loose(completion.text)
|
|
63
|
+
if isinstance(keywords, list):
|
|
64
|
+
return [str(k) for k in keywords], completion
|
|
65
|
+
# Some models wrap the array: {"skills": [...]} or {"keywords": [...]}
|
|
66
|
+
if isinstance(keywords, dict):
|
|
67
|
+
for value in keywords.values():
|
|
68
|
+
if isinstance(value, list):
|
|
69
|
+
return [str(k) for k in value], completion
|
|
70
|
+
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
71
|
+
pass
|
|
72
|
+
return [], completion
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resume_text(resume: ParsedResume) -> str:
|
|
76
|
+
"""Flatten the parsed resume into a single searchable string."""
|
|
77
|
+
parts: list[str] = []
|
|
78
|
+
for sec in resume.sections:
|
|
79
|
+
parts.append(sec.name)
|
|
80
|
+
for entry in sec.entries:
|
|
81
|
+
parts.append(entry.heading)
|
|
82
|
+
for b in entry.bullets:
|
|
83
|
+
parts.append(b.text)
|
|
84
|
+
return " ".join(parts).lower()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def analyze_gap(
|
|
88
|
+
jd_text: str,
|
|
89
|
+
resume: ParsedResume,
|
|
90
|
+
kb: KnowledgeBase,
|
|
91
|
+
provider: LLMProvider,
|
|
92
|
+
*,
|
|
93
|
+
distance_threshold: float = 0.5,
|
|
94
|
+
) -> KeywordGapReport:
|
|
95
|
+
"""Analyse the gap between a JD, resume, and code evidence."""
|
|
96
|
+
keywords, completion = _extract_jd_keywords(jd_text, provider)
|
|
97
|
+
if not keywords:
|
|
98
|
+
return KeywordGapReport(items=[], completion=completion)
|
|
99
|
+
|
|
100
|
+
flat = _resume_text(resume)
|
|
101
|
+
|
|
102
|
+
items: list[KeywordGapItem] = []
|
|
103
|
+
for kw in keywords:
|
|
104
|
+
in_resume = kw.lower() in flat
|
|
105
|
+
|
|
106
|
+
results = kb.search(kw, provider, n_results=3)
|
|
107
|
+
in_codebase = any(r.distance <= distance_threshold for r in results)
|
|
108
|
+
|
|
109
|
+
if not in_resume and in_codebase:
|
|
110
|
+
suggestion = f"Your code uses {kw} — consider adding it to your resume."
|
|
111
|
+
elif in_resume and not in_codebase:
|
|
112
|
+
suggestion = f"Resume mentions {kw} but no code evidence found."
|
|
113
|
+
elif not in_resume and not in_codebase:
|
|
114
|
+
suggestion = f"JD requires {kw} — not in resume or codebase."
|
|
115
|
+
else:
|
|
116
|
+
suggestion = f"{kw} is in your resume and backed by code."
|
|
117
|
+
|
|
118
|
+
items.append(
|
|
119
|
+
KeywordGapItem(
|
|
120
|
+
keyword=kw,
|
|
121
|
+
in_resume=in_resume,
|
|
122
|
+
in_codebase=in_codebase,
|
|
123
|
+
suggestion=suggestion,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return KeywordGapReport(items=items, completion=completion)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Query-side intelligence (Phase 22D) — search the way code is written.
|
|
2
|
+
|
|
3
|
+
Resume claims are phrased for recruiters ("reduced query latency by 40%");
|
|
4
|
+
code is phrased for machines ("CREATE INDEX", "cache.get", "asyncio.gather").
|
|
5
|
+
Two techniques bridge the gap, both using the configured provider, both
|
|
6
|
+
metered, both OFF by default (``RECEIPTS_QUERY_EXPANSION=1`` to enable):
|
|
7
|
+
|
|
8
|
+
- **Multi-query expansion**: rewrite the claim into 2-3 short queries
|
|
9
|
+
phrased the way the proving code would look.
|
|
10
|
+
- **HyDE** (hypothetical document embeddings): generate a small snippet of
|
|
11
|
+
code that WOULD prove the claim, and search with that — a fake document
|
|
12
|
+
finds real documents better than an abstract claim does.
|
|
13
|
+
|
|
14
|
+
Plus the ONE bounded reformulate-and-retry hop (Phase 22E's retry half):
|
|
15
|
+
if the first retrieval finds nothing above threshold, reformulate once and
|
|
16
|
+
try again. Once. Never an unbounded agent loop.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
from receipts.llm.json_utils import parse_json_loose
|
|
25
|
+
from receipts.llm.provider import LLMProvider
|
|
26
|
+
|
|
27
|
+
_EXPAND_SYSTEM = """\
|
|
28
|
+
You turn resume claims into code-search queries. Given a claim, write 2-3
|
|
29
|
+
SHORT queries (3-8 words each) phrased the way the implementing code would
|
|
30
|
+
look — function names, library names, config keys, SQL keywords.
|
|
31
|
+
|
|
32
|
+
Respond in JSON: {"queries": ["q1", "q2", "q3"]}
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_HYDE_SYSTEM = """\
|
|
36
|
+
You write hypothetical evidence. Given a resume claim, write a SHORT code
|
|
37
|
+
snippet (max 12 lines, any language that fits) that WOULD exist in a
|
|
38
|
+
codebase where the claim is true. Realistic identifiers, no comments
|
|
39
|
+
explaining yourself.
|
|
40
|
+
|
|
41
|
+
Respond in JSON: {"code": "the snippet"}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_REFORMULATE_SYSTEM = """\
|
|
45
|
+
A code search for the claim below found nothing relevant. Write ONE
|
|
46
|
+
alternative search query (3-10 words) using different, more concrete
|
|
47
|
+
vocabulary — library names, patterns, or synonyms the code might use.
|
|
48
|
+
|
|
49
|
+
Respond in JSON: {"query": "the query"}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def expansion_enabled() -> bool:
|
|
54
|
+
"""22D toggle — off unless RECEIPTS_QUERY_EXPANSION=1."""
|
|
55
|
+
return os.environ.get("RECEIPTS_QUERY_EXPANSION", "0").strip() == "1"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def expand_queries(claim_text: str, provider: LLMProvider) -> list[str]:
|
|
59
|
+
"""2-3 code-phrased search queries for the claim (may be empty)."""
|
|
60
|
+
try:
|
|
61
|
+
completion = provider.complete(_EXPAND_SYSTEM, claim_text, json_mode=True)
|
|
62
|
+
parsed = parse_json_loose(completion.text)
|
|
63
|
+
raw = parsed.get("queries", [])
|
|
64
|
+
if isinstance(raw, list):
|
|
65
|
+
return [str(q).strip() for q in raw if str(q).strip()][:3]
|
|
66
|
+
except (ValueError, json.JSONDecodeError, AttributeError, Exception):
|
|
67
|
+
pass
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def hyde_snippet(claim_text: str, provider: LLMProvider) -> str:
|
|
72
|
+
"""A hypothetical code snippet that would prove the claim ('' on failure)."""
|
|
73
|
+
try:
|
|
74
|
+
completion = provider.complete(_HYDE_SYSTEM, claim_text, json_mode=True)
|
|
75
|
+
parsed = parse_json_loose(completion.text)
|
|
76
|
+
return str(parsed.get("code", "")).strip()[:1200]
|
|
77
|
+
except (ValueError, json.JSONDecodeError, AttributeError, Exception):
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def reformulate(claim_text: str, provider: LLMProvider) -> str:
|
|
82
|
+
"""One alternative query for the single bounded retry hop ('' on failure)."""
|
|
83
|
+
try:
|
|
84
|
+
completion = provider.complete(_REFORMULATE_SYSTEM, claim_text, json_mode=True)
|
|
85
|
+
parsed = parse_json_loose(completion.text)
|
|
86
|
+
return str(parsed.get("query", "")).strip()[:200]
|
|
87
|
+
except (ValueError, json.JSONDecodeError, AttributeError, Exception):
|
|
88
|
+
return ""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""LLM-as-reranker (Phase 22E) — sharpen the top of the retrieval pile.
|
|
2
|
+
|
|
3
|
+
Similarity ranking is approximate: the truly relevant chunk is usually in
|
|
4
|
+
the top 20 but not always in the top 5. The reranker shows the model the
|
|
5
|
+
claim plus a numbered candidate list (paths, names, first lines) and asks
|
|
6
|
+
which candidates actually bear on the claim, in order.
|
|
7
|
+
|
|
8
|
+
Honesty properties: the reranker can only REORDER and DROP existing
|
|
9
|
+
candidates — it cannot add anything, and every candidate keeps its true
|
|
10
|
+
semantic distance, so the verifier's threshold still applies afterwards.
|
|
11
|
+
On any parse failure the original ranking is kept. OFF by default
|
|
12
|
+
(``RECEIPTS_RERANK=1`` to enable); works on Ollama/Gemini — no
|
|
13
|
+
cross-encoder dependency.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from receipts.llm.json_utils import parse_json_loose
|
|
22
|
+
from receipts.llm.provider import LLMProvider
|
|
23
|
+
from receipts.verify.kb import SearchResult
|
|
24
|
+
|
|
25
|
+
_RERANK_SYSTEM = """\
|
|
26
|
+
You rank code-search candidates by relevance to a claim. You will get the
|
|
27
|
+
claim and a numbered list of code chunks. Reply with the numbers of the
|
|
28
|
+
chunks MOST relevant to judging the claim, best first. Only include
|
|
29
|
+
genuinely relevant ones — fewer is fine.
|
|
30
|
+
|
|
31
|
+
Respond in JSON: {"keep": [3, 1, 7]}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def rerank_enabled() -> bool:
|
|
36
|
+
"""22E toggle — off unless RECEIPTS_RERANK=1."""
|
|
37
|
+
return os.environ.get("RECEIPTS_RERANK", "0").strip() == "1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rerank(
|
|
41
|
+
claim_text: str,
|
|
42
|
+
candidates: list[SearchResult],
|
|
43
|
+
provider: LLMProvider,
|
|
44
|
+
*,
|
|
45
|
+
keep: int = 5,
|
|
46
|
+
) -> list[SearchResult]:
|
|
47
|
+
"""Rerank candidates for the claim; fall back to input order on failure."""
|
|
48
|
+
if len(candidates) <= keep:
|
|
49
|
+
return candidates
|
|
50
|
+
|
|
51
|
+
lines = [f"CLAIM: {claim_text}", "", "CANDIDATES:"]
|
|
52
|
+
for i, r in enumerate(candidates, 1):
|
|
53
|
+
head = " ".join(r.code[:220].split())
|
|
54
|
+
lines.append(f"[{i}] {r.rel_path} :: {r.name} ({r.kind}) — {head}")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
completion = provider.complete(_RERANK_SYSTEM, "\n".join(lines), json_mode=True)
|
|
58
|
+
parsed = parse_json_loose(completion.text)
|
|
59
|
+
raw = parsed.get("keep", [])
|
|
60
|
+
picked: list[SearchResult] = []
|
|
61
|
+
seen: set[int] = set()
|
|
62
|
+
for idx in raw if isinstance(raw, list) else []:
|
|
63
|
+
try:
|
|
64
|
+
pos = int(idx)
|
|
65
|
+
except (TypeError, ValueError):
|
|
66
|
+
continue
|
|
67
|
+
if 1 <= pos <= len(candidates) and pos not in seen:
|
|
68
|
+
seen.add(pos)
|
|
69
|
+
picked.append(candidates[pos - 1])
|
|
70
|
+
if picked:
|
|
71
|
+
return picked[:keep]
|
|
72
|
+
except (ValueError, json.JSONDecodeError, AttributeError, Exception):
|
|
73
|
+
pass
|
|
74
|
+
return candidates[:keep]
|