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
@@ -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
+ ]