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
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""Verify resume claims against the code knowledge base.
|
|
2
|
+
|
|
3
|
+
For each claim, the verifier searches the KB for evidence and classifies
|
|
4
|
+
it as Verified, Plausible, or Unsupported. When no evidence is found
|
|
5
|
+
above the similarity threshold the claim is classified Unsupported
|
|
6
|
+
*directly* — the LLM is never asked to rationalise a claim it cannot
|
|
7
|
+
find support for.
|
|
8
|
+
|
|
9
|
+
Section-aware behavior (Phase 8):
|
|
10
|
+
- **project** sections: full KB search + LLM verification (original behavior).
|
|
11
|
+
- **experience** sections: same as project when a KB exists for that role's
|
|
12
|
+
codebase; otherwise classified as ``plausible`` with a note.
|
|
13
|
+
- **education / certifications / publications**: always ``plausible`` — these
|
|
14
|
+
are unprovable from code.
|
|
15
|
+
- **skills**: cross-referenced against the KB — if the skill appears in code
|
|
16
|
+
artifacts, ``verified``; if related code exists, ``plausible``; otherwise
|
|
17
|
+
``unsupported``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
from receipts.llm.json_utils import parse_json_loose
|
|
26
|
+
from receipts.llm.provider import CompletionResult, LLMProvider
|
|
27
|
+
from receipts.resume.claim_extractor import Claim
|
|
28
|
+
from receipts.verify.kb import KnowledgeBase, SearchResult
|
|
29
|
+
|
|
30
|
+
DISTANCE_THRESHOLD = 0.5
|
|
31
|
+
|
|
32
|
+
_SYSTEM = """\
|
|
33
|
+
You are a resume claim verifier. You will receive a resume claim and \
|
|
34
|
+
code evidence retrieved from the project's codebase. Decide whether \
|
|
35
|
+
the evidence supports the claim.
|
|
36
|
+
|
|
37
|
+
Verdicts
|
|
38
|
+
--------
|
|
39
|
+
* "verified" — the evidence directly and clearly proves the claim.
|
|
40
|
+
* "plausible" — the evidence is related but does not fully confirm it.
|
|
41
|
+
* "unsupported"— the evidence does not support the claim.
|
|
42
|
+
|
|
43
|
+
Rules
|
|
44
|
+
-----
|
|
45
|
+
- Base your verdict ONLY on the provided evidence. Never infer or guess.
|
|
46
|
+
- If evidence is thin or ambiguous, prefer "plausible" over "verified".
|
|
47
|
+
- For numeric claims the code must show the numbers or the mechanism.
|
|
48
|
+
- For technology claims the code must import, configure, or use it.
|
|
49
|
+
|
|
50
|
+
Respond in JSON:
|
|
51
|
+
{"verdict": "verified|plausible|unsupported", "explanation": "one sentence", \
|
|
52
|
+
"cited_ids": ["id1"]}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
_SKILLS_SYSTEM = """\
|
|
56
|
+
You are a resume skills verifier. You will receive a skill claim and \
|
|
57
|
+
code evidence retrieved from the project's codebase. Decide whether \
|
|
58
|
+
the codebase demonstrates use of this skill/technology.
|
|
59
|
+
|
|
60
|
+
Verdicts
|
|
61
|
+
--------
|
|
62
|
+
* "verified" — the code clearly imports, configures, or uses this technology.
|
|
63
|
+
* "plausible" — the code is related but doesn't directly demonstrate the skill.
|
|
64
|
+
* "unsupported"— no evidence of this skill in the codebase.
|
|
65
|
+
|
|
66
|
+
Respond in JSON:
|
|
67
|
+
{"verdict": "verified|plausible|unsupported", "explanation": "one sentence", \
|
|
68
|
+
"cited_ids": ["id1"]}
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
_BATCH_SYSTEM = """\
|
|
72
|
+
You are a resume claim verifier. You will receive SEVERAL claims that all
|
|
73
|
+
come from the same resume bullet, plus code evidence retrieved from the
|
|
74
|
+
project's codebase. Decide, for EACH claim independently, whether the
|
|
75
|
+
evidence supports it.
|
|
76
|
+
|
|
77
|
+
Verdicts
|
|
78
|
+
--------
|
|
79
|
+
* "verified" — the evidence directly and clearly proves the claim.
|
|
80
|
+
* "plausible" — the evidence is related but does not fully confirm it.
|
|
81
|
+
* "unsupported"— the evidence does not support the claim.
|
|
82
|
+
|
|
83
|
+
Rules
|
|
84
|
+
-----
|
|
85
|
+
- Base every verdict ONLY on the provided evidence. Never infer or guess.
|
|
86
|
+
- If evidence is thin or ambiguous, prefer "plausible" over "verified".
|
|
87
|
+
- For numeric claims the code must show the numbers or the mechanism.
|
|
88
|
+
- For technology claims the code must import, configure, or use it.
|
|
89
|
+
- Judge each claim on its own — one verified claim does not make its
|
|
90
|
+
neighbors verified.
|
|
91
|
+
|
|
92
|
+
Respond in JSON:
|
|
93
|
+
{"verdicts": [{"index": 1, "verdict": "verified|plausible|unsupported", \
|
|
94
|
+
"explanation": "one sentence"}, ...]}
|
|
95
|
+
Include one entry per claim, using the claim's index.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
_BATCH_SKILLS_SYSTEM = """\
|
|
99
|
+
You are a resume skills verifier. You will receive SEVERAL skill claims,
|
|
100
|
+
plus code evidence retrieved from the project's codebase. Decide, for
|
|
101
|
+
EACH skill independently, whether the codebase demonstrates it.
|
|
102
|
+
|
|
103
|
+
Verdicts
|
|
104
|
+
--------
|
|
105
|
+
* "verified" — the code clearly imports, configures, or uses this technology.
|
|
106
|
+
* "plausible" — the code is related but doesn't directly demonstrate the skill.
|
|
107
|
+
* "unsupported"— no evidence of this skill in the codebase.
|
|
108
|
+
|
|
109
|
+
Respond in JSON:
|
|
110
|
+
{"verdicts": [{"index": 1, "verdict": "verified|plausible|unsupported", \
|
|
111
|
+
"explanation": "one sentence"}, ...]}
|
|
112
|
+
Include one entry per claim, using the claim's index.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
_NOPROBE_SECTIONS = {"education", "other"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class VerificationResult:
|
|
120
|
+
claim: Claim
|
|
121
|
+
verdict: str # "verified" | "plausible" | "unsupported"
|
|
122
|
+
explanation: str
|
|
123
|
+
evidence: list[SearchResult] = field(default_factory=list)
|
|
124
|
+
llm_used: bool = False
|
|
125
|
+
completion: CompletionResult | None = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_user_prompt(claim: Claim, evidence: list[SearchResult]) -> str:
|
|
129
|
+
parts = [
|
|
130
|
+
f"CLAIM ({claim.claim_type}): {claim.value}",
|
|
131
|
+
f"CONTEXT: {claim.snippet}",
|
|
132
|
+
f"BULLET: {claim.bullet_text}",
|
|
133
|
+
"",
|
|
134
|
+
"CODE EVIDENCE:",
|
|
135
|
+
]
|
|
136
|
+
for e in evidence:
|
|
137
|
+
parts.append(f"--- [{e.artifact_id}] {e.rel_path} :: {e.name} ({e.kind}) ---")
|
|
138
|
+
parts.append(e.code[:2000])
|
|
139
|
+
parts.append("")
|
|
140
|
+
return "\n".join(parts)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _merge_pools(pools: list[list[SearchResult]]) -> list[SearchResult]:
|
|
144
|
+
"""Union result pools, dedupe by artifact, keep each one's best distance."""
|
|
145
|
+
best: dict[str, SearchResult] = {}
|
|
146
|
+
for pool in pools:
|
|
147
|
+
for r in pool:
|
|
148
|
+
cur = best.get(r.artifact_id)
|
|
149
|
+
if cur is None or r.distance < cur.distance:
|
|
150
|
+
best[r.artifact_id] = r
|
|
151
|
+
return sorted(best.values(), key=lambda r: r.distance)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _retrieve_evidence(
|
|
155
|
+
query: str,
|
|
156
|
+
kb: KnowledgeBase,
|
|
157
|
+
provider: LLMProvider,
|
|
158
|
+
*,
|
|
159
|
+
n_results: int,
|
|
160
|
+
distance_threshold: float,
|
|
161
|
+
) -> list[SearchResult]:
|
|
162
|
+
"""Retrieve threshold-passing evidence for a query.
|
|
163
|
+
|
|
164
|
+
The base path is one KB search. Two opt-in upgrades layer on top:
|
|
165
|
+
|
|
166
|
+
- 22D (``RECEIPTS_QUERY_EXPANSION=1``): also search 2-3 code-phrased
|
|
167
|
+
rewrites of the claim plus a HyDE snippet, merge the pools, and — if
|
|
168
|
+
nothing passes the threshold — reformulate ONCE and retry. Bounded.
|
|
169
|
+
- 22E (``RECEIPTS_RERANK=1``): over-fetch, let the model reorder the
|
|
170
|
+
top candidates, keep the best few.
|
|
171
|
+
|
|
172
|
+
Every path ends at the same gate: only results whose true semantic
|
|
173
|
+
distance clears the threshold count as evidence. The upgrades widen
|
|
174
|
+
the net; they never loosen the mesh.
|
|
175
|
+
"""
|
|
176
|
+
from receipts.verify.query_expand import (
|
|
177
|
+
expand_queries,
|
|
178
|
+
expansion_enabled,
|
|
179
|
+
hyde_snippet,
|
|
180
|
+
reformulate,
|
|
181
|
+
)
|
|
182
|
+
from receipts.verify.rerank import rerank as _rerank
|
|
183
|
+
from receipts.verify.rerank import rerank_enabled
|
|
184
|
+
|
|
185
|
+
use_expand = expansion_enabled()
|
|
186
|
+
use_rerank = rerank_enabled()
|
|
187
|
+
fetch_n = max(n_results * 4, 20) if use_rerank else n_results
|
|
188
|
+
|
|
189
|
+
pools = [kb.search(query, provider, n_results=fetch_n)]
|
|
190
|
+
if use_expand:
|
|
191
|
+
for q in expand_queries(query, provider):
|
|
192
|
+
pools.append(kb.search(q, provider, n_results=fetch_n))
|
|
193
|
+
snippet = hyde_snippet(query, provider)
|
|
194
|
+
if snippet:
|
|
195
|
+
pools.append(kb.search(snippet, provider, n_results=fetch_n))
|
|
196
|
+
merged = _merge_pools(pools)
|
|
197
|
+
|
|
198
|
+
if use_rerank and len(merged) > n_results:
|
|
199
|
+
merged = _rerank(query, merged[:20], provider, keep=n_results)
|
|
200
|
+
else:
|
|
201
|
+
merged = merged[:n_results]
|
|
202
|
+
|
|
203
|
+
relevant = [r for r in merged if r.distance <= distance_threshold]
|
|
204
|
+
|
|
205
|
+
if not relevant and use_expand:
|
|
206
|
+
# ONE bounded reformulate-and-retry hop — never a loop.
|
|
207
|
+
retry_q = reformulate(query, provider)
|
|
208
|
+
if retry_q and retry_q.lower() != query.lower():
|
|
209
|
+
retry_pool = kb.search(retry_q, provider, n_results=n_results)
|
|
210
|
+
relevant = [r for r in retry_pool if r.distance <= distance_threshold]
|
|
211
|
+
return relevant
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def verify_claim(
|
|
215
|
+
claim: Claim,
|
|
216
|
+
kb: KnowledgeBase,
|
|
217
|
+
provider: LLMProvider,
|
|
218
|
+
*,
|
|
219
|
+
distance_threshold: float = DISTANCE_THRESHOLD,
|
|
220
|
+
n_results: int = 5,
|
|
221
|
+
) -> VerificationResult:
|
|
222
|
+
"""Verify a single claim against the knowledge base.
|
|
223
|
+
|
|
224
|
+
Section-aware: education/certification/publication claims are always
|
|
225
|
+
classified as ``plausible`` (no code evidence possible). Skills claims
|
|
226
|
+
are cross-referenced against the KB. Experience/project claims get
|
|
227
|
+
full verification.
|
|
228
|
+
"""
|
|
229
|
+
sec_type = getattr(claim, "section_type", "project")
|
|
230
|
+
|
|
231
|
+
if sec_type in _NOPROBE_SECTIONS and claim.claim_type == "contextual":
|
|
232
|
+
return VerificationResult(
|
|
233
|
+
claim=claim,
|
|
234
|
+
verdict="plausible",
|
|
235
|
+
explanation=f"No code verification possible for {claim.section} claims.",
|
|
236
|
+
evidence=[],
|
|
237
|
+
llm_used=False,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
query = f"{claim.value} {claim.snippet}"
|
|
241
|
+
relevant = _retrieve_evidence(
|
|
242
|
+
query,
|
|
243
|
+
kb,
|
|
244
|
+
provider,
|
|
245
|
+
n_results=n_results,
|
|
246
|
+
distance_threshold=distance_threshold,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if not relevant:
|
|
250
|
+
if sec_type == "experience" and claim.claim_type == "contextual":
|
|
251
|
+
return VerificationResult(
|
|
252
|
+
claim=claim,
|
|
253
|
+
verdict="plausible",
|
|
254
|
+
explanation=(
|
|
255
|
+
"No codebase provided for this role — claim is"
|
|
256
|
+
" plausible but unverifiable."
|
|
257
|
+
),
|
|
258
|
+
evidence=[],
|
|
259
|
+
llm_used=False,
|
|
260
|
+
)
|
|
261
|
+
return VerificationResult(
|
|
262
|
+
claim=claim,
|
|
263
|
+
verdict="unsupported",
|
|
264
|
+
explanation="No relevant code evidence found in the knowledge base.",
|
|
265
|
+
evidence=[],
|
|
266
|
+
llm_used=False,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
system_prompt = _SKILLS_SYSTEM if sec_type == "skills" else _SYSTEM
|
|
270
|
+
user_prompt = _build_user_prompt(claim, relevant)
|
|
271
|
+
completion = provider.complete(system_prompt, user_prompt, json_mode=True)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
parsed = parse_json_loose(completion.text)
|
|
275
|
+
verdict = str(parsed.get("verdict", "unsupported")).lower()
|
|
276
|
+
if verdict not in ("verified", "plausible", "unsupported"):
|
|
277
|
+
verdict = "unsupported"
|
|
278
|
+
explanation = parsed.get("explanation", "")
|
|
279
|
+
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
280
|
+
verdict = "plausible"
|
|
281
|
+
explanation = completion.text[:200]
|
|
282
|
+
|
|
283
|
+
return VerificationResult(
|
|
284
|
+
claim=claim,
|
|
285
|
+
verdict=verdict,
|
|
286
|
+
explanation=explanation,
|
|
287
|
+
evidence=relevant,
|
|
288
|
+
llm_used=True,
|
|
289
|
+
completion=completion,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _short_circuit(claim: Claim) -> VerificationResult | None:
|
|
294
|
+
"""Return a result for claims that never need the KB or the LLM."""
|
|
295
|
+
sec_type = getattr(claim, "section_type", "project")
|
|
296
|
+
if sec_type in _NOPROBE_SECTIONS and claim.claim_type == "contextual":
|
|
297
|
+
return VerificationResult(
|
|
298
|
+
claim=claim,
|
|
299
|
+
verdict="plausible",
|
|
300
|
+
explanation=f"No code verification possible for {claim.section} claims.",
|
|
301
|
+
evidence=[],
|
|
302
|
+
llm_used=False,
|
|
303
|
+
)
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _no_evidence_result(claim: Claim) -> VerificationResult:
|
|
308
|
+
sec_type = getattr(claim, "section_type", "project")
|
|
309
|
+
if sec_type == "experience" and claim.claim_type == "contextual":
|
|
310
|
+
return VerificationResult(
|
|
311
|
+
claim=claim,
|
|
312
|
+
verdict="plausible",
|
|
313
|
+
explanation=(
|
|
314
|
+
"No codebase provided for this role — claim is"
|
|
315
|
+
" plausible but unverifiable."
|
|
316
|
+
),
|
|
317
|
+
evidence=[],
|
|
318
|
+
llm_used=False,
|
|
319
|
+
)
|
|
320
|
+
return VerificationResult(
|
|
321
|
+
claim=claim,
|
|
322
|
+
verdict="unsupported",
|
|
323
|
+
explanation="No relevant code evidence found in the knowledge base.",
|
|
324
|
+
evidence=[],
|
|
325
|
+
llm_used=False,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
_VALID_VERDICTS = ("verified", "plausible", "unsupported")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_batch_verdicts(
|
|
333
|
+
text: str, probe_claims: list[Claim]
|
|
334
|
+
) -> dict[int, tuple[str, str]]:
|
|
335
|
+
"""Normalize the many shapes small models return for batch verdicts.
|
|
336
|
+
|
|
337
|
+
Accepted shapes (all observed in the wild from lite-tier models):
|
|
338
|
+
- {"verdicts": [{"index": 1, "verdict": ..., "explanation": ...}, ...]}
|
|
339
|
+
- {"verdicts": {"1": {"verdict": ...}}} or {"verdicts": {"1": "verified"}}
|
|
340
|
+
- a bare top-level list of entry objects
|
|
341
|
+
- a single {"verdict": ..., "explanation": ...} object (one claim)
|
|
342
|
+
- entries with a string index ("1"), no index (positional), or a
|
|
343
|
+
"claim" key naming the claim value instead of an index
|
|
344
|
+
Anything unrecognizable is simply absent from the result — the caller's
|
|
345
|
+
honest per-claim fallback handles it. Never guess a verdict.
|
|
346
|
+
"""
|
|
347
|
+
try:
|
|
348
|
+
parsed = parse_json_loose(text)
|
|
349
|
+
except (ValueError, json.JSONDecodeError):
|
|
350
|
+
return {}
|
|
351
|
+
|
|
352
|
+
value_to_index = {
|
|
353
|
+
c.value.strip().lower(): i + 1 for i, c in enumerate(probe_claims)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
def _clean_verdict(raw) -> str | None:
|
|
357
|
+
v = str(raw or "").strip().lower()
|
|
358
|
+
if not v:
|
|
359
|
+
return None
|
|
360
|
+
return v if v in _VALID_VERDICTS else "unsupported"
|
|
361
|
+
|
|
362
|
+
def _entry_index(entry: dict, position: int) -> int | None:
|
|
363
|
+
idx = entry.get("index")
|
|
364
|
+
if idx is not None:
|
|
365
|
+
try:
|
|
366
|
+
return int(idx)
|
|
367
|
+
except (TypeError, ValueError):
|
|
368
|
+
pass
|
|
369
|
+
claim_key = str(entry.get("claim", "")).strip().lower()
|
|
370
|
+
if claim_key and claim_key in value_to_index:
|
|
371
|
+
return value_to_index[claim_key]
|
|
372
|
+
return position # fall back to positional order (1-based)
|
|
373
|
+
|
|
374
|
+
out: dict[int, tuple[str, str]] = {}
|
|
375
|
+
|
|
376
|
+
if isinstance(parsed, dict) and "verdicts" in parsed:
|
|
377
|
+
verdicts = parsed["verdicts"]
|
|
378
|
+
elif isinstance(parsed, list):
|
|
379
|
+
verdicts = parsed
|
|
380
|
+
elif isinstance(parsed, dict) and "verdict" in parsed:
|
|
381
|
+
verdict = _clean_verdict(parsed.get("verdict"))
|
|
382
|
+
if verdict:
|
|
383
|
+
out[1] = (verdict, str(parsed.get("explanation", "")))
|
|
384
|
+
return out
|
|
385
|
+
else:
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
if isinstance(verdicts, dict):
|
|
389
|
+
# {"1": {...}} or {"1": "verified"}
|
|
390
|
+
for key, value in verdicts.items():
|
|
391
|
+
try:
|
|
392
|
+
idx = int(key)
|
|
393
|
+
except (TypeError, ValueError):
|
|
394
|
+
idx = value_to_index.get(str(key).strip().lower(), 0)
|
|
395
|
+
if idx <= 0:
|
|
396
|
+
continue
|
|
397
|
+
if isinstance(value, dict):
|
|
398
|
+
verdict = _clean_verdict(value.get("verdict"))
|
|
399
|
+
explanation = str(value.get("explanation", ""))
|
|
400
|
+
else:
|
|
401
|
+
verdict = _clean_verdict(value)
|
|
402
|
+
explanation = ""
|
|
403
|
+
if verdict:
|
|
404
|
+
out[idx] = (verdict, explanation)
|
|
405
|
+
return out
|
|
406
|
+
|
|
407
|
+
if isinstance(verdicts, list):
|
|
408
|
+
for position, entry in enumerate(verdicts, 1):
|
|
409
|
+
if not isinstance(entry, dict):
|
|
410
|
+
continue
|
|
411
|
+
verdict = _clean_verdict(entry.get("verdict"))
|
|
412
|
+
if verdict is None:
|
|
413
|
+
continue
|
|
414
|
+
idx = _entry_index(entry, position)
|
|
415
|
+
if idx is not None and idx > 0:
|
|
416
|
+
out[idx] = (verdict, str(entry.get("explanation", "")))
|
|
417
|
+
|
|
418
|
+
return out
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _build_batch_prompt(claims: list[Claim], evidence: list[SearchResult]) -> str:
|
|
422
|
+
parts = ["CLAIMS (all from the same resume bullet):"]
|
|
423
|
+
for i, c in enumerate(claims, 1):
|
|
424
|
+
parts.append(f"{i}. ({c.claim_type}) {c.value} — context: {c.snippet}")
|
|
425
|
+
parts.append("")
|
|
426
|
+
parts.append(f"BULLET: {claims[0].bullet_text}")
|
|
427
|
+
parts.append("")
|
|
428
|
+
parts.append("CODE EVIDENCE:")
|
|
429
|
+
for e in evidence:
|
|
430
|
+
parts.append(f"--- [{e.artifact_id}] {e.rel_path} :: {e.name} ({e.kind}) ---")
|
|
431
|
+
parts.append(e.code[:2000])
|
|
432
|
+
parts.append("")
|
|
433
|
+
return "\n".join(parts)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def verify_bullet_claims(
|
|
437
|
+
claims: list[Claim],
|
|
438
|
+
kb: KnowledgeBase,
|
|
439
|
+
provider: LLMProvider,
|
|
440
|
+
*,
|
|
441
|
+
distance_threshold: float = DISTANCE_THRESHOLD,
|
|
442
|
+
n_results: int = 5,
|
|
443
|
+
) -> list[VerificationResult]:
|
|
444
|
+
"""Verify all claims from ONE bullet with a single KB search + LLM call.
|
|
445
|
+
|
|
446
|
+
Claims sharing a bullet share the same code context, so retrieving
|
|
447
|
+
evidence once and asking the model to judge each claim independently
|
|
448
|
+
in one response cuts API calls roughly 3x without softening any
|
|
449
|
+
verdict — the no-evidence guard still short-circuits to Unsupported
|
|
450
|
+
before the LLM is ever consulted.
|
|
451
|
+
"""
|
|
452
|
+
if not claims:
|
|
453
|
+
return []
|
|
454
|
+
|
|
455
|
+
results: list[VerificationResult | None] = [None] * len(claims)
|
|
456
|
+
probe_indices: list[int] = []
|
|
457
|
+
for i, claim in enumerate(claims):
|
|
458
|
+
short = _short_circuit(claim)
|
|
459
|
+
if short is not None:
|
|
460
|
+
results[i] = short
|
|
461
|
+
else:
|
|
462
|
+
probe_indices.append(i)
|
|
463
|
+
|
|
464
|
+
if not probe_indices:
|
|
465
|
+
return [r for r in results if r is not None]
|
|
466
|
+
|
|
467
|
+
probe_claims = [claims[i] for i in probe_indices]
|
|
468
|
+
|
|
469
|
+
# One KB search covering the bullet and every claim value in it.
|
|
470
|
+
values = " ".join(c.value for c in probe_claims)
|
|
471
|
+
query = f"{probe_claims[0].bullet_text} {values}"
|
|
472
|
+
relevant = _retrieve_evidence(
|
|
473
|
+
query,
|
|
474
|
+
kb,
|
|
475
|
+
provider,
|
|
476
|
+
n_results=n_results,
|
|
477
|
+
distance_threshold=distance_threshold,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if not relevant:
|
|
481
|
+
for i in probe_indices:
|
|
482
|
+
results[i] = _no_evidence_result(claims[i])
|
|
483
|
+
return [r for r in results if r is not None]
|
|
484
|
+
|
|
485
|
+
sec_type = getattr(probe_claims[0], "section_type", "project")
|
|
486
|
+
system_prompt = _BATCH_SKILLS_SYSTEM if sec_type == "skills" else _BATCH_SYSTEM
|
|
487
|
+
user_prompt = _build_batch_prompt(probe_claims, relevant)
|
|
488
|
+
completion = provider.complete(system_prompt, user_prompt, json_mode=True)
|
|
489
|
+
|
|
490
|
+
verdict_by_index = _parse_batch_verdicts(completion.text, probe_claims)
|
|
491
|
+
|
|
492
|
+
for pos, i in enumerate(probe_indices):
|
|
493
|
+
verdict, explanation = verdict_by_index.get(
|
|
494
|
+
pos + 1,
|
|
495
|
+
("plausible", "Model response could not be parsed for this claim."),
|
|
496
|
+
)
|
|
497
|
+
results[i] = VerificationResult(
|
|
498
|
+
claim=claims[i],
|
|
499
|
+
verdict=verdict,
|
|
500
|
+
explanation=explanation,
|
|
501
|
+
evidence=relevant,
|
|
502
|
+
llm_used=True,
|
|
503
|
+
# Attach the shared completion to the first probed claim only,
|
|
504
|
+
# so token counts aren't double-counted by callers.
|
|
505
|
+
completion=completion if pos == 0 else None,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
return [r for r in results if r is not None]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def group_claims_by_bullet(claims: list[Claim]) -> list[list[Claim]]:
|
|
512
|
+
"""Group claims that came from the same bullet, preserving order."""
|
|
513
|
+
groups: list[list[Claim]] = []
|
|
514
|
+
key_to_group: dict[tuple[str, str, str], list[Claim]] = {}
|
|
515
|
+
for claim in claims:
|
|
516
|
+
key = (claim.section, claim.entry_heading, claim.bullet_text)
|
|
517
|
+
group = key_to_group.get(key)
|
|
518
|
+
if group is None:
|
|
519
|
+
group = []
|
|
520
|
+
key_to_group[key] = group
|
|
521
|
+
groups.append(group)
|
|
522
|
+
group.append(claim)
|
|
523
|
+
return groups
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def verify_claims(
|
|
527
|
+
claims: list[Claim],
|
|
528
|
+
kb: KnowledgeBase,
|
|
529
|
+
provider: LLMProvider,
|
|
530
|
+
*,
|
|
531
|
+
distance_threshold: float = DISTANCE_THRESHOLD,
|
|
532
|
+
batch: bool = False,
|
|
533
|
+
) -> list[VerificationResult]:
|
|
534
|
+
"""Verify all claims against the knowledge base.
|
|
535
|
+
|
|
536
|
+
With ``batch=True``, claims from the same bullet are verified in one
|
|
537
|
+
KB search + one LLM call — use for rate-limited API providers. Local
|
|
538
|
+
Ollama models are better served by the simpler per-claim prompt.
|
|
539
|
+
"""
|
|
540
|
+
if batch:
|
|
541
|
+
out: list[VerificationResult] = []
|
|
542
|
+
for group in group_claims_by_bullet(claims):
|
|
543
|
+
out.extend(
|
|
544
|
+
verify_bullet_claims(
|
|
545
|
+
group, kb, provider, distance_threshold=distance_threshold
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
return out
|
|
549
|
+
return [
|
|
550
|
+
verify_claim(claim, kb, provider, distance_threshold=distance_threshold)
|
|
551
|
+
for claim in claims
|
|
552
|
+
]
|