codebase-intel 0.1.0__py3-none-any.whl → 0.3.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 (55) hide show
  1. codebase_intel/__init__.py +1 -1
  2. codebase_intel/analytics/benchmark.py +60 -68
  3. codebase_intel/analytics/eval.py +288 -0
  4. codebase_intel/analytics/feedback.py +39 -22
  5. codebase_intel/analytics/naive.py +49 -0
  6. codebase_intel/analytics/tracker.py +68 -6
  7. codebase_intel/cli/main.py +787 -38
  8. codebase_intel/contracts/auto_generator.py +199 -129
  9. codebase_intel/contracts/evaluator.py +59 -62
  10. codebase_intel/contracts/models.py +28 -11
  11. codebase_intel/contracts/registry.py +9 -11
  12. codebase_intel/core/config.py +24 -6
  13. codebase_intel/core/exceptions.py +8 -14
  14. codebase_intel/core/text.py +51 -0
  15. codebase_intel/core/tokens.py +35 -0
  16. codebase_intel/core/types.py +10 -12
  17. codebase_intel/crossrepo/__init__.py +1 -0
  18. codebase_intel/crossrepo/models.py +196 -0
  19. codebase_intel/crossrepo/registry.py +377 -0
  20. codebase_intel/crossrepo/scanner.py +490 -0
  21. codebase_intel/decisions/miner.py +63 -38
  22. codebase_intel/decisions/models.py +9 -13
  23. codebase_intel/decisions/store.py +24 -25
  24. codebase_intel/drift/detector.py +121 -100
  25. codebase_intel/graph/builder.py +30 -16
  26. codebase_intel/graph/parser.py +193 -149
  27. codebase_intel/graph/query.py +8 -21
  28. codebase_intel/graph/storage.py +12 -14
  29. codebase_intel/intent/__init__.py +1 -0
  30. codebase_intel/intent/models.py +185 -0
  31. codebase_intel/intent/store.py +195 -0
  32. codebase_intel/intent/verifier.py +374 -0
  33. codebase_intel/intentgraph/__init__.py +64 -0
  34. codebase_intel/intentgraph/anchors.py +372 -0
  35. codebase_intel/intentgraph/bootstrap.py +179 -0
  36. codebase_intel/intentgraph/capture.py +240 -0
  37. codebase_intel/intentgraph/classifier.py +175 -0
  38. codebase_intel/intentgraph/gate.py +128 -0
  39. codebase_intel/intentgraph/journal.py +118 -0
  40. codebase_intel/intentgraph/migrate.py +183 -0
  41. codebase_intel/intentgraph/models.py +294 -0
  42. codebase_intel/intentgraph/render.py +163 -0
  43. codebase_intel/intentgraph/store.py +398 -0
  44. codebase_intel/intentgraph/traversal.py +87 -0
  45. codebase_intel/mcp/server.py +899 -61
  46. codebase_intel/orchestrator/assembler.py +80 -104
  47. codebase_intel/workspace/__init__.py +13 -0
  48. codebase_intel/workspace/manager.py +329 -0
  49. codebase_intel/workspace/registry.py +238 -0
  50. {codebase_intel-0.1.0.dist-info → codebase_intel-0.3.0.dist-info}/METADATA +167 -18
  51. codebase_intel-0.3.0.dist-info/RECORD +63 -0
  52. {codebase_intel-0.1.0.dist-info → codebase_intel-0.3.0.dist-info}/WHEEL +1 -1
  53. {codebase_intel-0.1.0.dist-info → codebase_intel-0.3.0.dist-info}/licenses/LICENSE +1 -1
  54. codebase_intel-0.1.0.dist-info/RECORD +0 -36
  55. {codebase_intel-0.1.0.dist-info → codebase_intel-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,3 @@
1
1
  """Codebase Intelligence Platform — structured context for AI coding agents."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
@@ -17,13 +17,15 @@ numbers every time for the same repo state.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import contextlib
20
21
  import logging
21
22
  import time
22
23
  from dataclasses import dataclass, field
23
24
  from pathlib import Path
24
- from typing import Any
25
+ from typing import TYPE_CHECKING, Any
25
26
 
26
- from codebase_intel.analytics.tracker import AnalyticsTracker
27
+ if TYPE_CHECKING:
28
+ from codebase_intel.analytics.tracker import AnalyticsTracker
27
29
 
28
30
  logger = logging.getLogger(__name__)
29
31
 
@@ -137,11 +139,13 @@ class BenchmarkReport:
137
139
  """Format as a markdown table for README/reports."""
138
140
  lines = [
139
141
  f"## Benchmark: {self.repo_name}",
140
- f"",
141
- f"**Graph:** {self.total_files} files → {self.total_nodes} nodes, {self.total_edges} edges (built in {self.build_time_ms:.0f}ms)",
142
- f"",
143
- f"| Scenario | Naive Tokens | Graph Tokens | Full Tokens | Reduction | Multiplier | Decisions | Contracts |",
144
- f"|---|---:|---:|---:|---:|---:|---:|---:|",
142
+ "",
143
+ f"**Graph:** {self.total_files} files → {self.total_nodes} nodes, "
144
+ f"{self.total_edges} edges (built in {self.build_time_ms:.0f}ms)",
145
+ "",
146
+ "| Scenario | Naive Tokens | Graph Tokens | Full Tokens | Reduction | "
147
+ "Multiplier | Decisions | Contracts |",
148
+ "|---|---:|---:|---:|---:|---:|---:|---:|",
145
149
  ]
146
150
 
147
151
  for s in self.scenarios:
@@ -207,9 +211,7 @@ class BenchmarkRunner:
207
211
 
208
212
  # Run each scenario
209
213
  for scenario in scenarios:
210
- result = await self._run_scenario(
211
- scenario, config, storage, engine
212
- )
214
+ result = await self._run_scenario(scenario, config, storage, engine)
213
215
  report.scenarios.append(result)
214
216
 
215
217
  # Record in tracker if provided
@@ -234,7 +236,7 @@ class BenchmarkRunner:
234
236
  engine: Any,
235
237
  ) -> ScenarioResult:
236
238
  """Run a single scenario in naive, graph, and full modes."""
237
- from codebase_intel.orchestrator.assembler import estimate_tokens
239
+ from codebase_intel.core.tokens import estimate_tokens
238
240
 
239
241
  target_paths = [self._project_root / f for f in scenario.target_files]
240
242
  existing_paths = [p for p in target_paths if p.exists()]
@@ -253,29 +255,9 @@ class BenchmarkRunner:
253
255
  )
254
256
 
255
257
  # --- NAIVE: what an agent without context tools typically reads ---
256
- # Agents usually read the target file + parent dir + any file they
257
- # can grep for imports. We simulate: all files in parent dir + parent's
258
- # parent dir (2 levels up from each target file).
259
- naive_content = ""
260
- naive_files: set[Path] = set()
261
- code_extensions = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".rb"}
262
- for fp in existing_paths:
263
- for ancestor_dir in [fp.parent, fp.parent.parent]:
264
- if not ancestor_dir.exists():
265
- continue
266
- try:
267
- for child in ancestor_dir.iterdir():
268
- if child.is_file() and child.suffix in code_extensions and child not in naive_files:
269
- naive_files.add(child)
270
- try:
271
- naive_content += child.read_text(encoding="utf-8", errors="ignore")
272
- except OSError:
273
- pass
274
- except OSError:
275
- pass
276
-
277
- result.naive_tokens = estimate_tokens(naive_content)
278
- result.naive_files = len(naive_files)
258
+ from codebase_intel.analytics.naive import estimate_naive_read_cost
259
+
260
+ result.naive_tokens, result.naive_files = estimate_naive_read_cost(existing_paths)
279
261
 
280
262
  # --- GRAPH: use code graph for relevant files ---
281
263
  start = time.monotonic()
@@ -287,10 +269,8 @@ class BenchmarkRunner:
287
269
  for node in graph_result.nodes:
288
270
  if node.file_path not in graph_files_set and node.file_path.exists():
289
271
  graph_files_set.add(node.file_path)
290
- try:
272
+ with contextlib.suppress(OSError):
291
273
  graph_content += node.file_path.read_text(encoding="utf-8", errors="ignore")
292
- except OSError:
293
- pass
294
274
 
295
275
  result.graph_tokens = estimate_tokens(graph_content)
296
276
  result.graph_files = len(graph_files_set)
@@ -299,9 +279,9 @@ class BenchmarkRunner:
299
279
  start = time.monotonic()
300
280
 
301
281
  from codebase_intel.contracts.registry import ContractRegistry
282
+ from codebase_intel.core.types import TokenBudget
302
283
  from codebase_intel.decisions.store import DecisionStore
303
284
  from codebase_intel.orchestrator.assembler import ContextAssembler
304
- from codebase_intel.core.types import TokenBudget
305
285
 
306
286
  decision_store = DecisionStore(config.decisions, self._project_root)
307
287
  contract_registry = ContractRegistry(config.contracts, self._project_root)
@@ -317,16 +297,20 @@ class BenchmarkRunner:
317
297
  assembled = await assembler.assemble(
318
298
  task_description=scenario.task_description,
319
299
  file_paths=existing_paths,
320
- budget=TokenBudget(total=8_000), # Realistic agent budget — shows how well we prioritize
300
+ budget=TokenBudget(
301
+ total=8_000
302
+ ), # Realistic agent budget — shows how well we prioritize
321
303
  )
322
304
 
323
305
  result.full_assembly_ms = (time.monotonic() - start) * 1000
324
306
  result.full_tokens = assembled.total_tokens
325
- result.full_files = len({
326
- item.metadata.get("file_path")
327
- for item in assembled.items
328
- if item.metadata.get("file_path")
329
- })
307
+ result.full_files = len(
308
+ {
309
+ item.metadata.get("file_path")
310
+ for item in assembled.items
311
+ if item.metadata.get("file_path")
312
+ }
313
+ )
330
314
  result.decisions_surfaced = sum(
331
315
  1 for item in assembled.items if item.item_type == "decision"
332
316
  )
@@ -365,42 +349,50 @@ class BenchmarkRunner:
365
349
  # Core file (most nodes — likely a models or utils file)
366
350
  if len(files) >= 1:
367
351
  core_file = files[0][0]
368
- scenarios.append(BenchmarkScenario(
369
- name="Core module change",
370
- task_description=f"Refactor the core module at {Path(core_file).name}",
371
- target_files=[core_file],
372
- expected_scope="wide",
373
- ))
352
+ scenarios.append(
353
+ BenchmarkScenario(
354
+ name="Core module change",
355
+ task_description=f"Refactor the core module at {Path(core_file).name}",
356
+ target_files=[core_file],
357
+ expected_scope="wide",
358
+ )
359
+ )
374
360
 
375
361
  # Mid-level file
376
362
  mid_idx = len(files) // 3
377
363
  if len(files) > mid_idx:
378
364
  mid_file = files[mid_idx][0]
379
- scenarios.append(BenchmarkScenario(
380
- name="Feature module change",
381
- task_description=f"Add a new endpoint to {Path(mid_file).name}",
382
- target_files=[mid_file],
383
- expected_scope="medium",
384
- ))
365
+ scenarios.append(
366
+ BenchmarkScenario(
367
+ name="Feature module change",
368
+ task_description=f"Add a new endpoint to {Path(mid_file).name}",
369
+ target_files=[mid_file],
370
+ expected_scope="medium",
371
+ )
372
+ )
385
373
 
386
374
  # Leaf file (fewest nodes)
387
375
  if len(files) >= 3:
388
376
  leaf_file = files[-1][0]
389
- scenarios.append(BenchmarkScenario(
390
- name="Leaf file change",
391
- task_description=f"Fix a bug in {Path(leaf_file).name}",
392
- target_files=[leaf_file],
393
- expected_scope="narrow",
394
- ))
377
+ scenarios.append(
378
+ BenchmarkScenario(
379
+ name="Leaf file change",
380
+ task_description=f"Fix a bug in {Path(leaf_file).name}",
381
+ target_files=[leaf_file],
382
+ expected_scope="narrow",
383
+ )
384
+ )
395
385
 
396
386
  # Multi-file change
397
387
  if len(files) >= 5:
398
388
  multi_files = [files[0][0], files[len(files) // 2][0]]
399
- scenarios.append(BenchmarkScenario(
400
- name="Cross-module refactor",
401
- task_description="Refactor shared types used across multiple modules",
402
- target_files=multi_files,
403
- expected_scope="wide",
404
- ))
389
+ scenarios.append(
390
+ BenchmarkScenario(
391
+ name="Cross-module refactor",
392
+ task_description="Refactor shared types used across multiple modules",
393
+ target_files=multi_files,
394
+ expected_scope="wide",
395
+ )
396
+ )
405
397
 
406
398
  return scenarios
@@ -0,0 +1,288 @@
1
+ """Intent-graph eval harness — token AND correctness A/B against the v1 baseline.
2
+
3
+ analytics/benchmark.py measures v1's code-graph pipeline: naive file reads vs
4
+ graph-targeted vs full (graph + decisions + contracts) — tokens only. It has
5
+ no v2 intent-graph scenario and no correctness signal, so the vision's central
6
+ claim ("token AND correctness gains") stays unmeasured. This module closes
7
+ that gap.
8
+
9
+ Methodology — for each eligible intent node (ACTIVE, level COMPONENT or
10
+ DECISION, a `why` with enough content beyond its own title):
11
+ 1. Synthesize a query from the node's WHY text with its own TITLE terms
12
+ removed. This forces a real query — the harness must find the node from
13
+ how its reasoning is described, not by echoing its name back at it. This
14
+ is the same regression shape validated in test_bootstrap.py's
15
+ TestTenCannedQuestions, generalized to run against any repo.
16
+ 2. Run IntentBootstrapper.bootstrap(query) and check whether the expected
17
+ node comes back — this is the correctness signal (accuracy@1 / accuracy@3).
18
+ 3. Compare token cost: the winning citation's own token cost vs
19
+ estimate_naive_read_cost() over the node's anchored files — the same
20
+ naive-cost methodology analytics/benchmark.py uses, so the two harnesses'
21
+ "naive" baseline means the same thing.
22
+
23
+ Edge cases:
24
+ - Empty or sparse graph: zero cases, explicit warning, never crashes —
25
+ the eval must degrade gracefully like every other v2 tool
26
+ - Node has no anchors: naive_tokens is 0 for that case, excluded from the
27
+ reduction/multiplier averages (can't compute a ratio against zero)
28
+ - Reproducibility: candidates are sorted by node_id before truncating to
29
+ max_cases, so the same graph state always yields the same report
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass, field
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ from codebase_intel.analytics.naive import estimate_naive_read_cost
38
+ from codebase_intel.core.text import extract_terms
39
+
40
+ if TYPE_CHECKING:
41
+ from pathlib import Path
42
+
43
+ from codebase_intel.analytics.tracker import AnalyticsTracker
44
+ from codebase_intel.intentgraph.bootstrap import IntentBootstrapper
45
+ from codebase_intel.intentgraph.models import IntentNode
46
+
47
+ DEFAULT_MAX_CASES = 10
48
+ DEFAULT_BUDGET_TOKENS = 2000
49
+ MIN_DISTINGUISHING_TERMS = 3
50
+ QUERY_TERM_COUNT = 6
51
+
52
+
53
+ @dataclass
54
+ class IntentEvalCase:
55
+ """One held-out question: can bootstrap find this node from its own why?"""
56
+
57
+ node_id: str
58
+ node_title: str
59
+ query: str
60
+ anchor_paths: list[Path]
61
+
62
+
63
+ @dataclass
64
+ class IntentEvalResult:
65
+ """Outcome of running one case through the bootstrapper."""
66
+
67
+ node_id: str
68
+ node_title: str
69
+ query: str
70
+ found: bool
71
+ rank: int | None
72
+ correct: bool # rank == 1
73
+ top3: bool # rank in {1, 2, 3}
74
+ naive_tokens: int
75
+ naive_files: int
76
+ citation_tokens: int # tokens of just the winning citation
77
+ session_tokens: int # tokens of the whole packed bootstrap response
78
+ coverage: str
79
+
80
+ @property
81
+ def reduction_pct(self) -> float:
82
+ if self.naive_tokens == 0 or self.citation_tokens == 0:
83
+ return 0.0
84
+ return (1 - self.citation_tokens / self.naive_tokens) * 100
85
+
86
+ @property
87
+ def multiplier(self) -> float:
88
+ if self.citation_tokens == 0:
89
+ return 0.0
90
+ return self.naive_tokens / self.citation_tokens
91
+
92
+ def to_dict(self) -> dict[str, Any]:
93
+ return {
94
+ "node_id": self.node_id,
95
+ "node_title": self.node_title,
96
+ "query": self.query,
97
+ "found": self.found,
98
+ "rank": self.rank,
99
+ "correct": self.correct,
100
+ "top3": self.top3,
101
+ "naive_tokens": self.naive_tokens,
102
+ "citation_tokens": self.citation_tokens,
103
+ "session_tokens": self.session_tokens,
104
+ "reduction_pct": round(self.reduction_pct, 1),
105
+ "multiplier": round(self.multiplier, 1),
106
+ "coverage": self.coverage,
107
+ }
108
+
109
+
110
+ @dataclass
111
+ class IntentEvalReport:
112
+ """Complete eval report for a project."""
113
+
114
+ repo_name: str
115
+ repo_path: str
116
+ total_cases: int = 0
117
+ results: list[IntentEvalResult] = field(default_factory=list)
118
+ warnings: list[str] = field(default_factory=list)
119
+
120
+ @property
121
+ def accuracy_at_1(self) -> float:
122
+ if not self.results:
123
+ return 0.0
124
+ return sum(1 for r in self.results if r.correct) / len(self.results)
125
+
126
+ @property
127
+ def accuracy_at_3(self) -> float:
128
+ if not self.results:
129
+ return 0.0
130
+ return sum(1 for r in self.results if r.top3) / len(self.results)
131
+
132
+ @property
133
+ def avg_reduction_pct(self) -> float:
134
+ vals = [
135
+ r.reduction_pct for r in self.results if r.naive_tokens > 0 and r.citation_tokens > 0
136
+ ]
137
+ return sum(vals) / max(len(vals), 1)
138
+
139
+ @property
140
+ def avg_multiplier(self) -> float:
141
+ vals = [r.multiplier for r in self.results if r.citation_tokens > 0]
142
+ return sum(vals) / max(len(vals), 1)
143
+
144
+ def format_table(self) -> str:
145
+ """Markdown table for README/reports."""
146
+ lines = [
147
+ f"## Intent eval: {self.repo_name}",
148
+ "",
149
+ f"**Cases:** {self.total_cases} · **Accuracy@1:** {self.accuracy_at_1:.0%} · "
150
+ f"**Accuracy@3:** {self.accuracy_at_3:.0%}",
151
+ "",
152
+ ]
153
+ if not self.results:
154
+ lines.append(f"_{self.warnings[0] if self.warnings else 'No eligible cases.'}_")
155
+ return "\n".join(lines)
156
+
157
+ lines.extend(
158
+ [
159
+ "| Query (from why, title-terms held out) | Found | Rank | Naive | "
160
+ "Citation | Reduction |",
161
+ "|---|---|---:|---:|---:|---:|",
162
+ ]
163
+ )
164
+ for r in self.results:
165
+ found_mark = "✓" if r.correct else ("~" if r.found else "✗")
166
+ lines.append(
167
+ f"| {r.query[:60]} | {found_mark} | {r.rank or '-'} | {r.naive_tokens:,} | "
168
+ f"{r.citation_tokens:,} | {r.reduction_pct:.0f}% |"
169
+ )
170
+
171
+ lines.append(
172
+ f"| **Average** | | | | | **{self.avg_reduction_pct:.0f}% "
173
+ f"({self.avg_multiplier:.1f}x)** |"
174
+ )
175
+ return "\n".join(lines)
176
+
177
+
178
+ class IntentEvalRunner:
179
+ """Runs the intent-graph eval against a project's existing intent journal."""
180
+
181
+ def __init__(self, project_root: Path) -> None:
182
+ self._project_root = project_root
183
+
184
+ async def run(
185
+ self,
186
+ tracker: AnalyticsTracker | None = None,
187
+ max_cases: int = DEFAULT_MAX_CASES,
188
+ budget_tokens: int = DEFAULT_BUDGET_TOKENS,
189
+ ) -> IntentEvalReport:
190
+ from codebase_intel.core.config import ProjectConfig
191
+ from codebase_intel.intentgraph import (
192
+ IntentBootstrapper,
193
+ IntentGraphStore,
194
+ IntentJournal,
195
+ IntentLevel,
196
+ )
197
+
198
+ config = ProjectConfig(project_root=self._project_root)
199
+ journal = IntentJournal(config.intentgraph.journal_dir)
200
+ db_path = config.intentgraph.db_path
201
+
202
+ report = IntentEvalReport(
203
+ repo_name=self._project_root.name, repo_path=str(self._project_root)
204
+ )
205
+
206
+ async with IntentGraphStore.open(db_path) as store:
207
+ await store.materialize(journal)
208
+ eligible: list[IntentNode] = []
209
+ for level in (IntentLevel.COMPONENT, IntentLevel.DECISION):
210
+ eligible.extend(await store.nodes_at_level(level))
211
+
212
+ cases = self._build_cases(eligible, max_cases)
213
+ report.total_cases = len(cases)
214
+ if not cases:
215
+ report.warnings.append(
216
+ "no eligible intent nodes (need ACTIVE component/decision nodes with a "
217
+ "why that says more than the title) — run `codebase-intel why ...` first"
218
+ )
219
+ return report
220
+
221
+ bootstrapper = IntentBootstrapper(journal, db_path)
222
+ for case in cases:
223
+ report.results.append(await self._run_case(bootstrapper, case, budget_tokens))
224
+
225
+ if tracker:
226
+ tracker.record_intent_eval(
227
+ repo_name=report.repo_name,
228
+ repo_path=report.repo_path,
229
+ total_cases=report.total_cases,
230
+ accuracy_at_1=report.accuracy_at_1,
231
+ accuracy_at_3=report.accuracy_at_3,
232
+ avg_reduction_pct=report.avg_reduction_pct,
233
+ avg_multiplier=report.avg_multiplier,
234
+ results=[r.to_dict() for r in report.results],
235
+ )
236
+
237
+ return report
238
+
239
+ async def _run_case(
240
+ self, bootstrapper: IntentBootstrapper, case: IntentEvalCase, budget_tokens: int
241
+ ) -> IntentEvalResult:
242
+ boot = await bootstrapper.bootstrap(case.query, budget_tokens=budget_tokens)
243
+ relevant = [item for item in boot.items if item.role == "relevant"]
244
+ rank = next(
245
+ (idx + 1 for idx, item in enumerate(relevant) if item.node_id == case.node_id),
246
+ None,
247
+ )
248
+ citation = next((item for item in relevant if item.node_id == case.node_id), None)
249
+ naive_tokens, naive_files = estimate_naive_read_cost(case.anchor_paths)
250
+
251
+ return IntentEvalResult(
252
+ node_id=case.node_id,
253
+ node_title=case.node_title,
254
+ query=case.query,
255
+ found=rank is not None,
256
+ rank=rank,
257
+ correct=rank == 1,
258
+ top3=rank is not None and rank <= 3,
259
+ naive_tokens=naive_tokens,
260
+ naive_files=naive_files,
261
+ citation_tokens=citation.tokens if citation else 0,
262
+ session_tokens=boot.total_tokens,
263
+ coverage=boot.coverage,
264
+ )
265
+
266
+ def _build_cases(self, nodes: list[IntentNode], max_cases: int) -> list[IntentEvalCase]:
267
+ from codebase_intel.intentgraph import IntentNodeStatus
268
+
269
+ candidates: list[IntentEvalCase] = []
270
+ for node in nodes:
271
+ if node.status != IntentNodeStatus.ACTIVE:
272
+ continue
273
+ title_terms = extract_terms(node.title)
274
+ why_terms = sorted(extract_terms(node.why) - title_terms)
275
+ if len(why_terms) < MIN_DISTINGUISHING_TERMS:
276
+ continue
277
+ query = "why " + " ".join(why_terms[:QUERY_TERM_COUNT])
278
+ candidates.append(
279
+ IntentEvalCase(
280
+ node_id=node.node_id,
281
+ node_title=node.title,
282
+ query=query,
283
+ anchor_paths=[a.file_path for a in node.anchors],
284
+ )
285
+ )
286
+
287
+ candidates.sort(key=lambda c: c.node_id) # deterministic across runs
288
+ return candidates[:max_cases]
@@ -24,12 +24,14 @@ from __future__ import annotations
24
24
  import json
25
25
  import sqlite3
26
26
  from datetime import UTC, datetime
27
- from enum import Enum
28
- from pathlib import Path
29
- from typing import Any
27
+ from enum import StrEnum
28
+ from typing import TYPE_CHECKING, Any
30
29
 
30
+ if TYPE_CHECKING:
31
+ from pathlib import Path
31
32
 
32
- class FeedbackType(str, Enum):
33
+
34
+ class FeedbackType(StrEnum):
33
35
  """How the agent's output was received."""
34
36
 
35
37
  ACCEPTED = "accepted" # Output used as-is or with minor edits
@@ -38,7 +40,7 @@ class FeedbackType(str, Enum):
38
40
  PARTIAL = "partial" # Some parts used, some discarded
39
41
 
40
42
 
41
- class RejectionReason(str, Enum):
43
+ class RejectionReason(StrEnum):
42
44
  """Why the output was rejected — maps to improvement actions."""
43
45
 
44
46
  WRONG_PATTERN = "wrong_pattern" # Used a pattern the project doesn't follow
@@ -207,7 +209,8 @@ class FeedbackTracker:
207
209
  """
208
210
  SELECT
209
211
  COUNT(*) as total,
210
- SUM(CASE WHEN feedback_type IN ('accepted', 'modified') THEN 1 ELSE 0 END) as accepted
212
+ SUM(CASE WHEN feedback_type IN ('accepted', 'modified') THEN 1 ELSE 0 END)
213
+ as accepted
211
214
  FROM feedback_events
212
215
  WHERE timestamp >= datetime('now', '-7 days')
213
216
  """
@@ -261,12 +264,14 @@ class FeedbackTracker:
261
264
  # Map rejection reasons to actionable suggestions
262
265
  suggestion = self._suggestion_for_reason(reason)
263
266
 
264
- reasons.append({
265
- "reason": reason,
266
- "count": count,
267
- "percentage": round(pct, 1),
268
- "suggestion": suggestion,
269
- })
267
+ reasons.append(
268
+ {
269
+ "reason": reason,
270
+ "count": count,
271
+ "percentage": round(pct, 1),
272
+ "suggestion": suggestion,
273
+ }
274
+ )
270
275
 
271
276
  return {
272
277
  "total_rejections": total_rejections,
@@ -306,7 +311,8 @@ class FeedbackTracker:
306
311
  """
307
312
  SELECT
308
313
  COUNT(DISTINCT cs.session_id) as total,
309
- SUM(CASE WHEN fe.feedback_type IN ('accepted', 'modified') THEN 1 ELSE 0 END) as accepted
314
+ SUM(CASE WHEN fe.feedback_type IN ('accepted', 'modified') THEN 1 ELSE 0 END)
315
+ as accepted
310
316
  FROM context_sessions cs
311
317
  JOIN feedback_events fe ON cs.session_id = fe.session_id
312
318
  WHERE cs.decisions_provided > 0
@@ -318,7 +324,8 @@ class FeedbackTracker:
318
324
  """
319
325
  SELECT
320
326
  COUNT(DISTINCT cs.session_id) as total,
321
- SUM(CASE WHEN fe.feedback_type IN ('accepted', 'modified') THEN 1 ELSE 0 END) as accepted
327
+ SUM(CASE WHEN fe.feedback_type IN ('accepted', 'modified') THEN 1 ELSE 0 END)
328
+ as accepted
322
329
  FROM context_sessions cs
323
330
  JOIN feedback_events fe ON cs.session_id = fe.session_id
324
331
  WHERE cs.decisions_provided = 0
@@ -330,7 +337,8 @@ class FeedbackTracker:
330
337
  """
331
338
  SELECT
332
339
  COUNT(DISTINCT cs.session_id) as total,
333
- SUM(CASE WHEN fe.feedback_type IN ('accepted', 'modified') THEN 1 ELSE 0 END) as accepted
340
+ SUM(CASE WHEN fe.feedback_type IN ('accepted', 'modified') THEN 1 ELSE 0 END)
341
+ as accepted
334
342
  FROM context_sessions cs
335
343
  JOIN feedback_events fe ON cs.session_id = fe.session_id
336
344
  WHERE cs.contracts_provided > 0
@@ -387,13 +395,15 @@ class FeedbackTracker:
387
395
  suggestion = self._suggestion_for_reason(reason.value)
388
396
  conn.execute(
389
397
  """
390
- INSERT INTO learning_insights (timestamp, insight_type, description, confidence, action_taken)
398
+ INSERT INTO learning_insights
399
+ (timestamp, insight_type, description, confidence, action_taken)
391
400
  VALUES (?, ?, ?, ?, ?)
392
401
  """,
393
402
  (
394
403
  datetime.now(UTC).isoformat(),
395
404
  "pattern_detected",
396
- f"Repeated rejection reason: {reason.value} ({count} times in 30 days). {suggestion}",
405
+ f"Repeated rejection reason: {reason.value} "
406
+ f"({count} times in 30 days). {suggestion}",
397
407
  min(0.9, 0.3 + count * 0.1),
398
408
  None,
399
409
  ),
@@ -447,9 +457,7 @@ class FeedbackTracker:
447
457
  wod_rate = (without_dec["accepted"] or 0) / wod_total * 100
448
458
  diff = wd_rate - wod_rate
449
459
  if diff > 5:
450
- parts.append(
451
- f"Sessions with decisions had {diff:.0f}% higher acceptance rate."
452
- )
460
+ parts.append(f"Sessions with decisions had {diff:.0f}% higher acceptance rate.")
453
461
 
454
462
  wc_total = with_contracts["total"] or 0
455
463
  if wc_total >= 5:
@@ -483,7 +491,8 @@ class FeedbackTracker:
483
491
 
484
492
  conn.execute(
485
493
  """
486
- INSERT INTO acceptance_trend (date, total_sessions, accepted, modified, rejected, partial, acceptance_rate)
494
+ INSERT INTO acceptance_trend
495
+ (date, total_sessions, accepted, modified, rejected, partial, acceptance_rate)
487
496
  VALUES (?, ?, ?, ?, ?, ?, ?)
488
497
  ON CONFLICT(date) DO UPDATE SET
489
498
  total_sessions=excluded.total_sessions,
@@ -491,6 +500,14 @@ class FeedbackTracker:
491
500
  rejected=excluded.rejected, partial=excluded.partial,
492
501
  acceptance_rate=excluded.acceptance_rate
493
502
  """,
494
- (today, total, row["accepted"] or 0, row["modified"] or 0, row["rejected"] or 0, row["partial"] or 0, round(rate, 1)),
503
+ (
504
+ today,
505
+ total,
506
+ row["accepted"] or 0,
507
+ row["modified"] or 0,
508
+ row["rejected"] or 0,
509
+ row["partial"] or 0,
510
+ round(rate, 1),
511
+ ),
495
512
  )
496
513
  conn.commit()