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,172 @@
|
|
|
1
|
+
"""Propose honest rewrites for weak resume bullets.
|
|
2
|
+
|
|
3
|
+
A rewrite is only ever as strong as what the knowledge base can support.
|
|
4
|
+
Unsupported numbers are removed; plausible claims are softened; verified
|
|
5
|
+
claims stay intact. The rewriter never invents new unverifiable metrics —
|
|
6
|
+
but it CAN substitute numbers mined from the codebase itself (route
|
|
7
|
+
counts, model counts, commit history), because those are provable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from receipts.llm.json_utils import parse_json_loose
|
|
16
|
+
from receipts.llm.provider import CompletionResult, LLMProvider
|
|
17
|
+
from receipts.verify.verifier import VerificationResult
|
|
18
|
+
|
|
19
|
+
_SYSTEM = """\
|
|
20
|
+
You are a resume bullet rewriter. Given a bullet and its verification \
|
|
21
|
+
results from a code evidence check, rewrite the bullet to be honest yet \
|
|
22
|
+
strong.
|
|
23
|
+
|
|
24
|
+
Rules
|
|
25
|
+
-----
|
|
26
|
+
- ONLY claim what the code evidence supports. Never invent numbers.
|
|
27
|
+
- Remove or soften "unsupported" numeric claims.
|
|
28
|
+
- Remove "unsupported" technology claims from the rewrite.
|
|
29
|
+
- Keep "verified" claims as-is or strengthen them with evidence details.
|
|
30
|
+
- Soften "plausible" claims (drop exact numbers, use qualitative language).
|
|
31
|
+
- The rewrite must still read like a strong resume bullet: concise, \
|
|
32
|
+
active voice, impact-oriented — just grounded in provable facts.
|
|
33
|
+
- If everything was verified, return the original bullet unchanged.
|
|
34
|
+
|
|
35
|
+
Respond in JSON:
|
|
36
|
+
{"rewritten": "the rewritten bullet", "explanation": "what changed and why"}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_GROUNDING_TEMPLATE = """\
|
|
40
|
+
|
|
41
|
+
PROVABLE FACTS mined from the codebase and its git history. When you remove
|
|
42
|
+
an unprovable number, replace it with a relevant number from this list — a
|
|
43
|
+
provable specific beats a vague qualitative phrase. You may ONLY use numbers
|
|
44
|
+
that appear here or in verified claims; never any other number:
|
|
45
|
+
{facts}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_KEYWORDS_TEMPLATE = """\
|
|
49
|
+
|
|
50
|
+
CODE-SUPPORTED KEYWORDS from the target job description (the codebase
|
|
51
|
+
demonstrably uses these). Weave one in ONLY where it fits the bullet's
|
|
52
|
+
actual content — never force or stuff them:
|
|
53
|
+
{keywords}
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class RewriteResult:
|
|
59
|
+
original: str
|
|
60
|
+
rewritten: str
|
|
61
|
+
explanation: str
|
|
62
|
+
completion: CompletionResult | None = None
|
|
63
|
+
original_raw: str = "" # raw TeX of the source bullet, for exact replacement
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_user_prompt(bullet_text: str, results: list[VerificationResult]) -> str:
|
|
67
|
+
parts = [f"ORIGINAL BULLET: {bullet_text}", "", "VERIFICATION RESULTS:"]
|
|
68
|
+
for r in results:
|
|
69
|
+
parts.append(
|
|
70
|
+
f' - {r.claim.claim_type} "{r.claim.value}": '
|
|
71
|
+
f"{r.verdict} — {r.explanation}"
|
|
72
|
+
)
|
|
73
|
+
if r.evidence:
|
|
74
|
+
for e in r.evidence[:2]:
|
|
75
|
+
parts.append(f" evidence: {e.rel_path}::{e.name}")
|
|
76
|
+
return "\n".join(parts)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_system_prompt(
|
|
80
|
+
grounded_facts: str | None,
|
|
81
|
+
addable_keywords: list[str] | None,
|
|
82
|
+
extra_constraint: str | None,
|
|
83
|
+
) -> str:
|
|
84
|
+
system = _SYSTEM
|
|
85
|
+
if grounded_facts:
|
|
86
|
+
system += _GROUNDING_TEMPLATE.format(facts=grounded_facts)
|
|
87
|
+
if addable_keywords:
|
|
88
|
+
system += _KEYWORDS_TEMPLATE.format(keywords=", ".join(addable_keywords))
|
|
89
|
+
if extra_constraint:
|
|
90
|
+
system += f"\nADDITIONAL CONSTRAINT:\n{extra_constraint}\n"
|
|
91
|
+
return system
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def rewrite_bullet(
|
|
95
|
+
bullet_text: str,
|
|
96
|
+
results: list[VerificationResult],
|
|
97
|
+
provider: LLMProvider,
|
|
98
|
+
*,
|
|
99
|
+
grounded_facts: str | None = None,
|
|
100
|
+
addable_keywords: list[str] | None = None,
|
|
101
|
+
extra_constraint: str | None = None,
|
|
102
|
+
original_raw: str = "",
|
|
103
|
+
) -> RewriteResult:
|
|
104
|
+
"""Rewrite a single bullet based on its verification results.
|
|
105
|
+
|
|
106
|
+
``grounded_facts`` is a pre-formatted block of provable numbers (from
|
|
107
|
+
the code metrics miner / git history) the model may substitute in.
|
|
108
|
+
``addable_keywords`` are JD keywords the codebase demonstrably supports.
|
|
109
|
+
``extra_constraint`` is appended verbatim — used by the score-aware
|
|
110
|
+
retry loop to pin down a regression. ``original_raw`` is the bullet's
|
|
111
|
+
raw TeX, carried through so the .tex rewriter can replace it exactly.
|
|
112
|
+
"""
|
|
113
|
+
verdicts = {r.verdict for r in results}
|
|
114
|
+
|
|
115
|
+
if not original_raw and results:
|
|
116
|
+
original_raw = getattr(results[0].claim, "bullet_raw", "")
|
|
117
|
+
|
|
118
|
+
if verdicts == {"verified"} and not extra_constraint:
|
|
119
|
+
return RewriteResult(
|
|
120
|
+
original=bullet_text,
|
|
121
|
+
rewritten=bullet_text,
|
|
122
|
+
explanation="All claims verified — no changes needed.",
|
|
123
|
+
original_raw=original_raw,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
system = _build_system_prompt(grounded_facts, addable_keywords, extra_constraint)
|
|
127
|
+
user_prompt = _build_user_prompt(bullet_text, results)
|
|
128
|
+
completion = provider.complete(system, user_prompt, json_mode=True)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
parsed = parse_json_loose(completion.text)
|
|
132
|
+
rewritten = parsed.get("rewritten", bullet_text)
|
|
133
|
+
explanation = parsed.get("explanation", "")
|
|
134
|
+
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
135
|
+
rewritten = bullet_text
|
|
136
|
+
explanation = "Could not parse rewrite response."
|
|
137
|
+
|
|
138
|
+
return RewriteResult(
|
|
139
|
+
original=bullet_text,
|
|
140
|
+
rewritten=rewritten,
|
|
141
|
+
explanation=explanation,
|
|
142
|
+
completion=completion,
|
|
143
|
+
original_raw=original_raw,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def rewrite_weak_bullets(
|
|
148
|
+
results: list[VerificationResult],
|
|
149
|
+
provider: LLMProvider,
|
|
150
|
+
*,
|
|
151
|
+
grounded_facts: str | None = None,
|
|
152
|
+
addable_keywords: list[str] | None = None,
|
|
153
|
+
) -> list[RewriteResult]:
|
|
154
|
+
"""Rewrite bullets that have any non-verified claims."""
|
|
155
|
+
by_bullet: dict[str, list[VerificationResult]] = {}
|
|
156
|
+
for r in results:
|
|
157
|
+
by_bullet.setdefault(r.claim.bullet_text, []).append(r)
|
|
158
|
+
|
|
159
|
+
rewrites: list[RewriteResult] = []
|
|
160
|
+
for bullet_text, bullet_results in by_bullet.items():
|
|
161
|
+
verdicts = {r.verdict for r in bullet_results}
|
|
162
|
+
if verdicts != {"verified"}:
|
|
163
|
+
rewrites.append(
|
|
164
|
+
rewrite_bullet(
|
|
165
|
+
bullet_text,
|
|
166
|
+
bullet_results,
|
|
167
|
+
provider,
|
|
168
|
+
grounded_facts=grounded_facts,
|
|
169
|
+
addable_keywords=addable_keywords,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return rewrites
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Multi-repo verification routing.
|
|
2
|
+
|
|
3
|
+
Resumes list 2–5 projects, but a knowledge base holds one codebase — so
|
|
4
|
+
with a single KB every *other* project's claims read "unsupported" for
|
|
5
|
+
the wrong reason. This module maps resume entries to their own ingested
|
|
6
|
+
KBs and routes each bullet's claims to the right one:
|
|
7
|
+
|
|
8
|
+
- entry matched to a mapping → verified against that repo's KB
|
|
9
|
+
- skills-section claims → searched across ALL mapped KBs
|
|
10
|
+
- entry with no mapping → "no codebase mapped" (plausible),
|
|
11
|
+
zero LLM calls, zero embeds
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, replace
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from receipts.errors import ReceiptsError
|
|
21
|
+
from receipts.llm.provider import LLMProvider
|
|
22
|
+
from receipts.resume.claim_extractor import Claim
|
|
23
|
+
from receipts.verify.kb import KnowledgeBase, SearchResult, default_kb_dir
|
|
24
|
+
from receipts.verify.verifier import (
|
|
25
|
+
DISTANCE_THRESHOLD,
|
|
26
|
+
VerificationResult,
|
|
27
|
+
_short_circuit,
|
|
28
|
+
group_claims_by_bullet,
|
|
29
|
+
verify_bullet_claims,
|
|
30
|
+
verify_claim,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class EntryKBMap:
|
|
36
|
+
entry_filter: str # substring matched against entry headings
|
|
37
|
+
source_path: Path
|
|
38
|
+
# Phase 19: pre-resolved KB dir for URL/alias sources. None means the
|
|
39
|
+
# classic local-folder behavior (default_kb_dir of source_path).
|
|
40
|
+
kb_dir: Path | None = None
|
|
41
|
+
label: str = "" # display label; falls back to source_path.name
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def display_label(self) -> str:
|
|
45
|
+
return self.label or self.source_path.name
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_map_options(
|
|
49
|
+
raw: list[str],
|
|
50
|
+
*,
|
|
51
|
+
resolve: Callable[[str], tuple[Path, str]] | None = None,
|
|
52
|
+
) -> list[EntryKBMap]:
|
|
53
|
+
"""Parse repeatable ``--map "entry substring=<source>"`` options.
|
|
54
|
+
|
|
55
|
+
``<source>`` may be a local folder, and — when a ``resolve`` callable is
|
|
56
|
+
supplied (the CLI passes its source resolver) — also a git URL or a
|
|
57
|
+
manifest alias. ``resolve(spec)`` returns ``(kb_dir, label)``.
|
|
58
|
+
"""
|
|
59
|
+
maps: list[EntryKBMap] = []
|
|
60
|
+
for item in raw:
|
|
61
|
+
if "=" not in item:
|
|
62
|
+
raise ReceiptsError(
|
|
63
|
+
f'Bad --map value "{item}". Expected format:'
|
|
64
|
+
' --map "entry substring=<folder | git URL | alias>"'
|
|
65
|
+
)
|
|
66
|
+
entry_filter, _, spec = item.partition("=")
|
|
67
|
+
entry_filter = entry_filter.strip()
|
|
68
|
+
spec = spec.strip()
|
|
69
|
+
if not entry_filter or not spec:
|
|
70
|
+
raise ReceiptsError(
|
|
71
|
+
f'Bad --map value "{item}". Both sides of "=" are required.'
|
|
72
|
+
)
|
|
73
|
+
if resolve is not None and not Path(spec).is_dir():
|
|
74
|
+
kb_dir, label = resolve(spec)
|
|
75
|
+
maps.append(
|
|
76
|
+
EntryKBMap(
|
|
77
|
+
entry_filter=entry_filter,
|
|
78
|
+
source_path=Path(spec),
|
|
79
|
+
kb_dir=kb_dir,
|
|
80
|
+
label=label,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
maps.append(EntryKBMap(entry_filter=entry_filter, source_path=Path(spec)))
|
|
85
|
+
return maps
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MultiKB:
|
|
89
|
+
"""Facade over several KBs — search merges the best evidence of all.
|
|
90
|
+
|
|
91
|
+
Evidence paths are prefixed with the repo label so a verdict's citation
|
|
92
|
+
says which codebase it came from. Quacks like ``KnowledgeBase`` for the
|
|
93
|
+
parts the verifier uses (``search`` / ``count``).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, labeled_kbs: list[tuple[str, KnowledgeBase]]) -> None:
|
|
97
|
+
self._labeled = labeled_kbs
|
|
98
|
+
|
|
99
|
+
def search(
|
|
100
|
+
self,
|
|
101
|
+
query: str,
|
|
102
|
+
provider: LLMProvider,
|
|
103
|
+
n_results: int = 5,
|
|
104
|
+
) -> list[SearchResult]:
|
|
105
|
+
merged: list[SearchResult] = []
|
|
106
|
+
for label, kb in self._labeled:
|
|
107
|
+
for r in kb.search(query, provider, n_results=n_results):
|
|
108
|
+
merged.append(replace(r, rel_path=f"[{label}] {r.rel_path}"))
|
|
109
|
+
merged.sort(key=lambda r: r.distance)
|
|
110
|
+
return merged[:n_results]
|
|
111
|
+
|
|
112
|
+
def count(self) -> int:
|
|
113
|
+
return sum(kb.count() for _, kb in self._labeled)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class KBRouter:
|
|
117
|
+
"""Maps resume entry headings to their ingested knowledge bases."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
maps: list[EntryKBMap],
|
|
122
|
+
*,
|
|
123
|
+
kb_dir_fn: Callable[[Path], Path] = default_kb_dir,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.maps = maps
|
|
126
|
+
self._kbs: list[tuple[EntryKBMap, KnowledgeBase]] = [
|
|
127
|
+
(m, KnowledgeBase(m.kb_dir or kb_dir_fn(m.source_path))) for m in maps
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
def empty_sources(self) -> list[str]:
|
|
131
|
+
"""Display labels of mapped codebases whose KB was never ingested."""
|
|
132
|
+
return [m.display_label for m, kb in self._kbs if kb.count() == 0]
|
|
133
|
+
|
|
134
|
+
def kb_for_entry(self, entry_heading: str) -> KnowledgeBase | None:
|
|
135
|
+
lowered = entry_heading.lower()
|
|
136
|
+
for m, kb in self._kbs:
|
|
137
|
+
if m.entry_filter.lower() in lowered:
|
|
138
|
+
return kb
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def multi_kb(self) -> MultiKB:
|
|
142
|
+
return MultiKB([(m.display_label, kb) for m, kb in self._kbs])
|
|
143
|
+
|
|
144
|
+
def entry_filters(self) -> list[str]:
|
|
145
|
+
return [m.entry_filter for m in self.maps]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _unmapped_result(claim: Claim) -> VerificationResult:
|
|
149
|
+
short = _short_circuit(claim)
|
|
150
|
+
if short is not None:
|
|
151
|
+
return short
|
|
152
|
+
return VerificationResult(
|
|
153
|
+
claim=claim,
|
|
154
|
+
verdict="plausible",
|
|
155
|
+
explanation=(
|
|
156
|
+
"No codebase mapped for this entry — claim is plausible but"
|
|
157
|
+
" unverifiable. Map one with --map to verify it."
|
|
158
|
+
),
|
|
159
|
+
evidence=[],
|
|
160
|
+
llm_used=False,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def verify_claims_routed(
|
|
165
|
+
claims: list[Claim],
|
|
166
|
+
router: KBRouter,
|
|
167
|
+
provider: LLMProvider,
|
|
168
|
+
*,
|
|
169
|
+
distance_threshold: float = DISTANCE_THRESHOLD,
|
|
170
|
+
batch: bool = False,
|
|
171
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
172
|
+
) -> list[VerificationResult]:
|
|
173
|
+
"""Verify claims, routing each bullet to its entry's knowledge base.
|
|
174
|
+
|
|
175
|
+
Skills-section bullets search across every mapped KB (a skill is
|
|
176
|
+
demonstrated if ANY of your projects uses it). Bullets from entries
|
|
177
|
+
with no mapping are classified without spending a single token.
|
|
178
|
+
"""
|
|
179
|
+
results: list[VerificationResult] = []
|
|
180
|
+
groups = group_claims_by_bullet(claims)
|
|
181
|
+
done = 0
|
|
182
|
+
|
|
183
|
+
for group in groups:
|
|
184
|
+
first = group[0]
|
|
185
|
+
sec_type = getattr(first, "section_type", "project")
|
|
186
|
+
|
|
187
|
+
kb: KnowledgeBase | MultiKB | None
|
|
188
|
+
if sec_type == "skills":
|
|
189
|
+
kb = router.multi_kb()
|
|
190
|
+
else:
|
|
191
|
+
kb = router.kb_for_entry(first.entry_heading)
|
|
192
|
+
|
|
193
|
+
if kb is None:
|
|
194
|
+
results.extend(_unmapped_result(c) for c in group)
|
|
195
|
+
elif batch:
|
|
196
|
+
results.extend(
|
|
197
|
+
verify_bullet_claims(
|
|
198
|
+
group, kb, provider, distance_threshold=distance_threshold
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
results.extend(
|
|
203
|
+
verify_claim(c, kb, provider, distance_threshold=distance_threshold)
|
|
204
|
+
for c in group
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
done += len(group)
|
|
208
|
+
if on_progress is not None:
|
|
209
|
+
on_progress(done, len(claims))
|
|
210
|
+
|
|
211
|
+
return results
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Corpus-level intelligence (Phase 22C) — the "knows the whole repo" layer.
|
|
2
|
+
|
|
3
|
+
Single-chunk retrieval cannot evidence whole-repo claims: "microservices
|
|
4
|
+
architecture", "22 API routes", "modular service layer" are properties of
|
|
5
|
+
the corpus, not of any one function. This module synthesizes corpus-level
|
|
6
|
+
artifacts and stores them in the KB as first-class searchable chunks, so
|
|
7
|
+
retrieval can surface them exactly like code:
|
|
8
|
+
|
|
9
|
+
Zero-LLM (always on):
|
|
10
|
+
- a CODE FACTS chunk — Phase 12's mined counts (routes, models, components,
|
|
11
|
+
tests, languages) plus git evidence when available;
|
|
12
|
+
- an IMPORT MAP chunk — which modules import which, a lightweight
|
|
13
|
+
dependency picture.
|
|
14
|
+
|
|
15
|
+
LLM-powered (``RECEIPTS_SUMMARIES=1``, metered, cached by content hash):
|
|
16
|
+
- RAPTOR-style hierarchical summaries: per-file → per-directory → one
|
|
17
|
+
repo-level architecture summary, each embedded as its own chunk. A claim
|
|
18
|
+
about overall architecture retrieves the repo summary; a feature claim
|
|
19
|
+
retrieves its module summary.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from receipts.ingest.artifact_extractor import Artifact
|
|
32
|
+
from receipts.llm.json_utils import parse_json_loose
|
|
33
|
+
from receipts.llm.provider import LLMProvider
|
|
34
|
+
|
|
35
|
+
_IMPORT_RE = re.compile(
|
|
36
|
+
r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+)"
|
|
37
|
+
r"|import\s+.*?from\s+['\"]([^'\"]+)['\"])",
|
|
38
|
+
re.MULTILINE,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_SUMMARY_SYSTEM = """\
|
|
42
|
+
You summarize code for a search index. Given the artifacts of one file (or
|
|
43
|
+
the summaries of one directory), write 1-2 plain sentences describing what
|
|
44
|
+
it does and the key technologies involved. Concrete nouns, no marketing.
|
|
45
|
+
|
|
46
|
+
Respond in JSON: {"summary": "..."}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_REPO_SUMMARY_SYSTEM = """\
|
|
50
|
+
You are describing a repository's architecture for a search index. Given
|
|
51
|
+
per-directory summaries, write 3-5 sentences covering: what the project is,
|
|
52
|
+
the major components/layers, and the main technologies. Concrete, no fluff.
|
|
53
|
+
|
|
54
|
+
Respond in JSON: {"summary": "..."}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def summaries_enabled() -> bool:
|
|
59
|
+
"""Hierarchical LLM summaries toggle — off unless RECEIPTS_SUMMARIES=1."""
|
|
60
|
+
return os.environ.get("RECEIPTS_SUMMARIES", "0").strip() == "1"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _tokens(text: str) -> int:
|
|
64
|
+
return max(1, len(text) // 4)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _global_artifact(name: str, text: str) -> Artifact:
|
|
68
|
+
return Artifact(
|
|
69
|
+
rel_path=f"[repo] {name}",
|
|
70
|
+
language="text",
|
|
71
|
+
kind="facts",
|
|
72
|
+
name=name,
|
|
73
|
+
start_line=1,
|
|
74
|
+
end_line=max(1, text.count("\n") + 1),
|
|
75
|
+
code=text,
|
|
76
|
+
token_estimate=_tokens(text),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_facts_artifact(source_path: Path) -> Artifact | None:
|
|
81
|
+
"""CODE FACTS chunk from Phase 12's zero-LLM miners. None on failure."""
|
|
82
|
+
try:
|
|
83
|
+
from receipts.mine.code_metrics import mine_code_facts
|
|
84
|
+
from receipts.mine.git_evidence import mine_git_evidence
|
|
85
|
+
|
|
86
|
+
facts = mine_code_facts(source_path)
|
|
87
|
+
lines = ["CODE FACTS (mined from the codebase, zero guesswork):"]
|
|
88
|
+
lines.extend(facts.summary_lines())
|
|
89
|
+
git_ev = mine_git_evidence(source_path)
|
|
90
|
+
if git_ev.available:
|
|
91
|
+
lines.extend(git_ev.summary_lines())
|
|
92
|
+
except Exception:
|
|
93
|
+
return None
|
|
94
|
+
return _global_artifact("code-facts", "\n".join(lines))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_import_map_artifact(artifacts: list[Artifact]) -> Artifact | None:
|
|
98
|
+
"""IMPORT MAP chunk: per-file imports, a cheap dependency picture."""
|
|
99
|
+
per_file: dict[str, set[str]] = {}
|
|
100
|
+
for a in artifacts:
|
|
101
|
+
if a.kind == "facts" or a.language == "text":
|
|
102
|
+
continue
|
|
103
|
+
found: set[str] = set()
|
|
104
|
+
for m in _IMPORT_RE.finditer(a.code):
|
|
105
|
+
target = next((g for g in m.groups() if g), "")
|
|
106
|
+
if target:
|
|
107
|
+
found.add(target)
|
|
108
|
+
if found:
|
|
109
|
+
per_file.setdefault(a.rel_path, set()).update(found)
|
|
110
|
+
|
|
111
|
+
if not per_file:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
lines = ["IMPORT MAP (which modules depend on which):"]
|
|
115
|
+
for rel_path in sorted(per_file)[:80]: # cap — this is a sketch, not a graph db
|
|
116
|
+
deps = ", ".join(sorted(per_file[rel_path])[:12])
|
|
117
|
+
lines.append(f" {rel_path} -> {deps}")
|
|
118
|
+
return _global_artifact("import-map", "\n".join(lines))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _cached_summary(
|
|
122
|
+
cache: dict[str, str],
|
|
123
|
+
cache_path: Path,
|
|
124
|
+
key_text: str,
|
|
125
|
+
system: str,
|
|
126
|
+
user: str,
|
|
127
|
+
provider: LLMProvider,
|
|
128
|
+
) -> str:
|
|
129
|
+
key = hashlib.sha256(key_text.encode("utf-8", errors="replace")).hexdigest()[:16]
|
|
130
|
+
hit = cache.get(key)
|
|
131
|
+
if hit is not None:
|
|
132
|
+
return hit
|
|
133
|
+
try:
|
|
134
|
+
completion = provider.complete(system, user, json_mode=True)
|
|
135
|
+
parsed = parse_json_loose(completion.text)
|
|
136
|
+
summary = str(parsed.get("summary", "")).strip()
|
|
137
|
+
except Exception:
|
|
138
|
+
summary = ""
|
|
139
|
+
cache[key] = summary
|
|
140
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
tmp = cache_path.with_suffix(".json.tmp")
|
|
142
|
+
tmp.write_text(json.dumps(cache, indent=0), encoding="utf-8")
|
|
143
|
+
tmp.replace(cache_path)
|
|
144
|
+
return summary
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_hierarchical_summaries(
|
|
148
|
+
artifacts: list[Artifact],
|
|
149
|
+
provider: LLMProvider,
|
|
150
|
+
*,
|
|
151
|
+
cache_path: Path,
|
|
152
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
153
|
+
) -> list[Artifact]:
|
|
154
|
+
"""File → directory → repo summaries, each as a searchable artifact.
|
|
155
|
+
|
|
156
|
+
Every summary is cached by a hash of its inputs, so re-ingesting an
|
|
157
|
+
unchanged corpus makes zero LLM calls here.
|
|
158
|
+
"""
|
|
159
|
+
cache: dict[str, str] = {}
|
|
160
|
+
if cache_path.is_file():
|
|
161
|
+
try:
|
|
162
|
+
loaded = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
163
|
+
cache = loaded if isinstance(loaded, dict) else {}
|
|
164
|
+
except (OSError, json.JSONDecodeError):
|
|
165
|
+
cache = {}
|
|
166
|
+
|
|
167
|
+
by_file: dict[str, list[Artifact]] = {}
|
|
168
|
+
for a in artifacts:
|
|
169
|
+
if a.kind == "facts":
|
|
170
|
+
continue
|
|
171
|
+
by_file.setdefault(a.rel_path, []).append(a)
|
|
172
|
+
|
|
173
|
+
out: list[Artifact] = []
|
|
174
|
+
file_summaries: dict[str, str] = {}
|
|
175
|
+
total = len(by_file)
|
|
176
|
+
for i, (rel_path, arts) in enumerate(sorted(by_file.items()), 1):
|
|
177
|
+
heads = "\n".join(f"- {a.kind} {a.name}: {a.code[:200]}" for a in arts[:12])
|
|
178
|
+
summary = _cached_summary(
|
|
179
|
+
cache,
|
|
180
|
+
cache_path,
|
|
181
|
+
f"file:{rel_path}\n{heads}",
|
|
182
|
+
_SUMMARY_SYSTEM,
|
|
183
|
+
f"FILE: {rel_path}\nARTIFACTS:\n{heads}",
|
|
184
|
+
provider,
|
|
185
|
+
)
|
|
186
|
+
if summary:
|
|
187
|
+
file_summaries[rel_path] = summary
|
|
188
|
+
out.append(
|
|
189
|
+
Artifact(
|
|
190
|
+
rel_path=f"[summary] {rel_path}",
|
|
191
|
+
language="text",
|
|
192
|
+
kind="summary",
|
|
193
|
+
name=rel_path,
|
|
194
|
+
start_line=1,
|
|
195
|
+
end_line=1,
|
|
196
|
+
code=f"FILE SUMMARY {rel_path}: {summary}",
|
|
197
|
+
token_estimate=_tokens(summary),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
if on_progress is not None:
|
|
201
|
+
on_progress(i, total)
|
|
202
|
+
|
|
203
|
+
by_dir: dict[str, list[str]] = {}
|
|
204
|
+
for rel_path, summary in file_summaries.items():
|
|
205
|
+
top = rel_path.split("/", 1)[0] if "/" in rel_path else "."
|
|
206
|
+
by_dir.setdefault(top, []).append(f"{rel_path}: {summary}")
|
|
207
|
+
|
|
208
|
+
dir_summaries: dict[str, str] = {}
|
|
209
|
+
for dir_name, entries in sorted(by_dir.items()):
|
|
210
|
+
joined = "\n".join(entries[:40])
|
|
211
|
+
summary = _cached_summary(
|
|
212
|
+
cache,
|
|
213
|
+
cache_path,
|
|
214
|
+
f"dir:{dir_name}\n{joined}",
|
|
215
|
+
_SUMMARY_SYSTEM,
|
|
216
|
+
f"DIRECTORY: {dir_name}\nFILE SUMMARIES:\n{joined}",
|
|
217
|
+
provider,
|
|
218
|
+
)
|
|
219
|
+
if summary:
|
|
220
|
+
dir_summaries[dir_name] = summary
|
|
221
|
+
out.append(
|
|
222
|
+
Artifact(
|
|
223
|
+
rel_path=f"[summary] {dir_name}/",
|
|
224
|
+
language="text",
|
|
225
|
+
kind="summary",
|
|
226
|
+
name=f"{dir_name}/",
|
|
227
|
+
start_line=1,
|
|
228
|
+
end_line=1,
|
|
229
|
+
code=f"DIRECTORY SUMMARY {dir_name}/: {summary}",
|
|
230
|
+
token_estimate=_tokens(summary),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if dir_summaries:
|
|
235
|
+
joined = "\n".join(f"{d}: {s}" for d, s in sorted(dir_summaries.items()))
|
|
236
|
+
repo_summary = _cached_summary(
|
|
237
|
+
cache,
|
|
238
|
+
cache_path,
|
|
239
|
+
f"repo:\n{joined}",
|
|
240
|
+
_REPO_SUMMARY_SYSTEM,
|
|
241
|
+
f"DIRECTORY SUMMARIES:\n{joined}",
|
|
242
|
+
provider,
|
|
243
|
+
)
|
|
244
|
+
if repo_summary:
|
|
245
|
+
out.append(
|
|
246
|
+
Artifact(
|
|
247
|
+
rel_path="[summary] <repository>",
|
|
248
|
+
language="text",
|
|
249
|
+
kind="summary",
|
|
250
|
+
name="<repository>",
|
|
251
|
+
start_line=1,
|
|
252
|
+
end_line=1,
|
|
253
|
+
code=f"REPOSITORY ARCHITECTURE: {repo_summary}",
|
|
254
|
+
token_estimate=_tokens(repo_summary),
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return out
|