codebase-intel 0.2.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.
- codebase_intel/__init__.py +1 -1
- codebase_intel/analytics/benchmark.py +60 -68
- codebase_intel/analytics/eval.py +288 -0
- codebase_intel/analytics/feedback.py +39 -22
- codebase_intel/analytics/naive.py +49 -0
- codebase_intel/analytics/tracker.py +68 -6
- codebase_intel/cli/main.py +494 -72
- codebase_intel/contracts/auto_generator.py +199 -129
- codebase_intel/contracts/evaluator.py +59 -62
- codebase_intel/contracts/models.py +28 -11
- codebase_intel/contracts/registry.py +9 -11
- codebase_intel/core/config.py +24 -6
- codebase_intel/core/exceptions.py +8 -14
- codebase_intel/core/text.py +51 -0
- codebase_intel/core/tokens.py +35 -0
- codebase_intel/core/types.py +10 -12
- codebase_intel/crossrepo/models.py +6 -9
- codebase_intel/crossrepo/registry.py +51 -46
- codebase_intel/crossrepo/scanner.py +105 -86
- codebase_intel/decisions/miner.py +63 -38
- codebase_intel/decisions/models.py +9 -13
- codebase_intel/decisions/store.py +24 -25
- codebase_intel/drift/detector.py +121 -100
- codebase_intel/graph/builder.py +30 -16
- codebase_intel/graph/parser.py +193 -149
- codebase_intel/graph/query.py +8 -21
- codebase_intel/graph/storage.py +12 -14
- codebase_intel/intent/models.py +5 -5
- codebase_intel/intent/store.py +19 -11
- codebase_intel/intent/verifier.py +33 -21
- codebase_intel/intentgraph/__init__.py +64 -0
- codebase_intel/intentgraph/anchors.py +372 -0
- codebase_intel/intentgraph/bootstrap.py +179 -0
- codebase_intel/intentgraph/capture.py +240 -0
- codebase_intel/intentgraph/classifier.py +175 -0
- codebase_intel/intentgraph/gate.py +128 -0
- codebase_intel/intentgraph/journal.py +118 -0
- codebase_intel/intentgraph/migrate.py +183 -0
- codebase_intel/intentgraph/models.py +294 -0
- codebase_intel/intentgraph/render.py +163 -0
- codebase_intel/intentgraph/store.py +398 -0
- codebase_intel/intentgraph/traversal.py +87 -0
- codebase_intel/mcp/server.py +407 -88
- codebase_intel/orchestrator/assembler.py +80 -104
- codebase_intel/workspace/registry.py +2 -6
- {codebase_intel-0.2.0.dist-info → codebase_intel-0.3.0.dist-info}/METADATA +163 -14
- codebase_intel-0.3.0.dist-info/RECORD +63 -0
- {codebase_intel-0.2.0.dist-info → codebase_intel-0.3.0.dist-info}/WHEEL +1 -1
- {codebase_intel-0.2.0.dist-info → codebase_intel-0.3.0.dist-info}/licenses/LICENSE +1 -1
- codebase_intel-0.2.0.dist-info/RECORD +0 -47
- {codebase_intel-0.2.0.dist-info → codebase_intel-0.3.0.dist-info}/entry_points.txt +0 -0
codebase_intel/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
141
|
-
f"**Graph:** {self.total_files} files → {self.total_nodes} nodes,
|
|
142
|
-
f"",
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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(
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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(
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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(
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
28
|
-
from
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
(
|
|
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()
|