codebase-receipts-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
  2. codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
  3. codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
  4. codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
  5. codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. receipts/__init__.py +0 -0
  7. receipts/ama/__init__.py +0 -0
  8. receipts/ama/interviewer.py +415 -0
  9. receipts/ats/__init__.py +1 -0
  10. receipts/ats/comparator.py +98 -0
  11. receipts/ats/scorer.py +550 -0
  12. receipts/ats/stats.py +164 -0
  13. receipts/cli.py +2062 -0
  14. receipts/config.py +106 -0
  15. receipts/errors.py +18 -0
  16. receipts/export.py +120 -0
  17. receipts/ingest/__init__.py +0 -0
  18. receipts/ingest/artifact_extractor.py +679 -0
  19. receipts/ingest/git_source.py +159 -0
  20. receipts/ingest/manifest.py +114 -0
  21. receipts/ingest/scanner.py +141 -0
  22. receipts/ingest/secrets_scanner.py +225 -0
  23. receipts/interactive/__init__.py +1 -0
  24. receipts/interactive/repl.py +755 -0
  25. receipts/ledger/__init__.py +0 -0
  26. receipts/ledger/pricing_table.py +54 -0
  27. receipts/ledger/token_ledger.py +226 -0
  28. receipts/llm/__init__.py +0 -0
  29. receipts/llm/anthropic_provider.py +95 -0
  30. receipts/llm/factory.py +124 -0
  31. receipts/llm/fake_provider.py +79 -0
  32. receipts/llm/gemini_provider.py +205 -0
  33. receipts/llm/json_utils.py +46 -0
  34. receipts/llm/metered.py +62 -0
  35. receipts/llm/ollama_provider.py +117 -0
  36. receipts/llm/openai_provider.py +118 -0
  37. receipts/llm/provider.py +42 -0
  38. receipts/llm/split.py +78 -0
  39. receipts/llm/token_estimate.py +35 -0
  40. receipts/mine/__init__.py +1 -0
  41. receipts/mine/code_metrics.py +246 -0
  42. receipts/mine/git_evidence.py +109 -0
  43. receipts/prep/__init__.py +1 -0
  44. receipts/prep/dossier.py +313 -0
  45. receipts/prep/readiness.py +227 -0
  46. receipts/resume/__init__.py +0 -0
  47. receipts/resume/claim_extractor.py +338 -0
  48. receipts/resume/loader.py +35 -0
  49. receipts/resume/pdf_parser.py +259 -0
  50. receipts/resume/tex_parser.py +394 -0
  51. receipts/rewrite/__init__.py +0 -0
  52. receipts/rewrite/compiler.py +180 -0
  53. receipts/rewrite/optimizer.py +140 -0
  54. receipts/rewrite/tex_rewriter.py +227 -0
  55. receipts/tui/__init__.py +0 -0
  56. receipts/tui/ama_app.py +454 -0
  57. receipts/tui/app.py +316 -0
  58. receipts/tui/dossier_app.py +170 -0
  59. receipts/tui/hub.py +367 -0
  60. receipts/tui/ingest_app.py +183 -0
  61. receipts/tui/rewrite_app.py +463 -0
  62. receipts/tui/score_app.py +237 -0
  63. receipts/tui/widgets/__init__.py +0 -0
  64. receipts/tui/widgets/claims_table.py +50 -0
  65. receipts/tui/widgets/diff_view.py +38 -0
  66. receipts/tui/widgets/status_bar.py +38 -0
  67. receipts/verify/__init__.py +0 -0
  68. receipts/verify/bm25.py +112 -0
  69. receipts/verify/enrich.py +128 -0
  70. receipts/verify/jd_fetch.py +101 -0
  71. receipts/verify/kb.py +379 -0
  72. receipts/verify/keyword_gap.py +127 -0
  73. receipts/verify/query_expand.py +88 -0
  74. receipts/verify/rerank.py +74 -0
  75. receipts/verify/rewriter.py +172 -0
  76. receipts/verify/router.py +211 -0
  77. receipts/verify/summaries.py +258 -0
  78. receipts/verify/verifier.py +552 -0
receipts/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]