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,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