codecortex-context-engine 0.1.0a1__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.
- codecortex/__init__.py +3 -0
- codecortex/architecture/__init__.py +23 -0
- codecortex/architecture/drift.py +182 -0
- codecortex/architecture/inference.py +160 -0
- codecortex/backends/__init__.py +35 -0
- codecortex/backends/base.py +38 -0
- codecortex/backends/context.py +118 -0
- codecortex/backends/contracts.py +65 -0
- codecortex/backends/factory.py +60 -0
- codecortex/backends/graph.py +107 -0
- codecortex/backends/manager.py +303 -0
- codecortex/backends/mcp_client.py +196 -0
- codecortex/backends/pool.py +128 -0
- codecortex/backends/spec.py +83 -0
- codecortex/backends/symbols.py +189 -0
- codecortex/benchmark.py +211 -0
- codecortex/cli.py +409 -0
- codecortex/config.py +27 -0
- codecortex/context/__init__.py +13 -0
- codecortex/context/budget.py +62 -0
- codecortex/context/integrated.py +60 -0
- codecortex/context/pipeline.py +223 -0
- codecortex/core/__init__.py +1 -0
- codecortex/core/contracts.py +45 -0
- codecortex/core/errors.py +17 -0
- codecortex/core/models.py +69 -0
- codecortex/dashboard.py +259 -0
- codecortex/editing.py +41 -0
- codecortex/engines/__init__.py +5 -0
- codecortex/engines/builtin/__init__.py +5 -0
- codecortex/engines/builtin/factory.py +26 -0
- codecortex/engines/builtin/memory.py +35 -0
- codecortex/engines/builtin/repository.py +73 -0
- codecortex/engines/builtin/symbols.py +89 -0
- codecortex/engines/builtin/validation.py +54 -0
- codecortex/engines/registry.py +26 -0
- codecortex/entrypoint.py +266 -0
- codecortex/evaluation/__init__.py +55 -0
- codecortex/evaluation/external.py +265 -0
- codecortex/evaluation/production.py +670 -0
- codecortex/evaluation/regression.py +188 -0
- codecortex/gateway.py +38 -0
- codecortex/git_intelligence.py +252 -0
- codecortex/indexing/__init__.py +6 -0
- codecortex/indexing/graph.py +78 -0
- codecortex/indexing/impact.py +127 -0
- codecortex/indexing/incremental.py +163 -0
- codecortex/indexing/incremental_graph.py +189 -0
- codecortex/indexing/indexer.py +172 -0
- codecortex/indexing/relationships.py +179 -0
- codecortex/indexing/resolution.py +88 -0
- codecortex/integrations/__init__.py +5 -0
- codecortex/integrations/agents.py +235 -0
- codecortex/interfaces/__init__.py +1 -0
- codecortex/interfaces/mcp_bridge.py +66 -0
- codecortex/languages/__init__.py +5 -0
- codecortex/languages/native.py +166 -0
- codecortex/languages/registry.py +232 -0
- codecortex/mcp/__init__.py +5 -0
- codecortex/mcp/extended.py +114 -0
- codecortex/mcp/server.py +473 -0
- codecortex/memory/__init__.py +11 -0
- codecortex/memory/json_store.py +52 -0
- codecortex/memory/knowledge.py +193 -0
- codecortex/memory/team_store.py +193 -0
- codecortex/orchestrator.py +153 -0
- codecortex/pr_intelligence.py +214 -0
- codecortex/retrieval/__init__.py +16 -0
- codecortex/retrieval/hybrid.py +67 -0
- codecortex/retrieval/index.py +135 -0
- codecortex/retrieval/providers.py +67 -0
- codecortex/retrieval/repository.py +94 -0
- codecortex/router/__init__.py +5 -0
- codecortex/router/router.py +79 -0
- codecortex/runtime.py +69 -0
- codecortex/setup.py +100 -0
- codecortex/symbols/__init__.py +5 -0
- codecortex/symbols/providers.py +192 -0
- codecortex/telemetry/__init__.py +5 -0
- codecortex/telemetry/collector.py +43 -0
- codecortex/tracing/__init__.py +9 -0
- codecortex/tracing/task_trace.py +235 -0
- codecortex/workspace/__init__.py +9 -0
- codecortex/workspace/federation.py +173 -0
- codecortex_context_engine-0.1.0a1.dist-info/METADATA +381 -0
- codecortex_context_engine-0.1.0a1.dist-info/RECORD +90 -0
- codecortex_context_engine-0.1.0a1.dist-info/WHEEL +4 -0
- codecortex_context_engine-0.1.0a1.dist-info/entry_points.txt +3 -0
- codecortex_context_engine-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- codecortex_context_engine-0.1.0a1.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Pull-request change intelligence from Git diffs and the repository graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from codecortex.indexing.graph import GraphNode, ProjectGraph
|
|
11
|
+
from codecortex.indexing.impact import ImpactAnalyzer
|
|
12
|
+
from codecortex.languages import LanguageRegistry
|
|
13
|
+
|
|
14
|
+
_HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(?P<start>\d+)(?:,(?P<count>\d+))? @@")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class LineRange:
|
|
19
|
+
start: int
|
|
20
|
+
end: int
|
|
21
|
+
|
|
22
|
+
def contains(self, line: int | None) -> bool:
|
|
23
|
+
return line is not None and self.start <= line <= self.end
|
|
24
|
+
|
|
25
|
+
def overlaps(self, other: LineRange) -> bool:
|
|
26
|
+
return self.start <= other.end and other.start <= self.end
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class PRFileChange:
|
|
31
|
+
path: str
|
|
32
|
+
status: str
|
|
33
|
+
additions: int
|
|
34
|
+
deletions: int
|
|
35
|
+
changed_ranges: tuple[LineRange, ...]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ChangedSymbol:
|
|
40
|
+
node: GraphNode
|
|
41
|
+
direct_change: bool
|
|
42
|
+
impact_risk: float
|
|
43
|
+
affected_nodes: int
|
|
44
|
+
affected_tests: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class PRReport:
|
|
49
|
+
base_ref: str
|
|
50
|
+
head_ref: str
|
|
51
|
+
files: tuple[PRFileChange, ...]
|
|
52
|
+
symbols: tuple[ChangedSymbol, ...]
|
|
53
|
+
affected_tests: tuple[str, ...]
|
|
54
|
+
risk_score: float
|
|
55
|
+
risk_level: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PRIntelligence:
|
|
59
|
+
def __init__(self, root: Path, graph: ProjectGraph, timeout_seconds: float = 15.0) -> None:
|
|
60
|
+
self.root = root.resolve()
|
|
61
|
+
self.graph = graph
|
|
62
|
+
self.timeout_seconds = timeout_seconds
|
|
63
|
+
self.languages = LanguageRegistry()
|
|
64
|
+
|
|
65
|
+
def _git(self, *args: str) -> str:
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["git", "-C", str(self.root), *args],
|
|
69
|
+
check=False,
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
encoding="utf-8",
|
|
73
|
+
errors="replace",
|
|
74
|
+
timeout=self.timeout_seconds,
|
|
75
|
+
)
|
|
76
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
77
|
+
return ""
|
|
78
|
+
return result.stdout if result.returncode == 0 else ""
|
|
79
|
+
|
|
80
|
+
def analyze(self, base_ref: str, head_ref: str = "HEAD") -> PRReport:
|
|
81
|
+
files = self._file_changes(base_ref, head_ref)
|
|
82
|
+
changed_by_path = {change.path: change for change in files}
|
|
83
|
+
symbols: list[ChangedSymbol] = []
|
|
84
|
+
tests: set[str] = set()
|
|
85
|
+
analyzer = ImpactAnalyzer(self.graph)
|
|
86
|
+
ranges_by_path: dict[str, dict[tuple[str, int], LineRange]] = {}
|
|
87
|
+
|
|
88
|
+
for node in self.graph.nodes:
|
|
89
|
+
if not node.path or node.path not in changed_by_path:
|
|
90
|
+
continue
|
|
91
|
+
if node.kind in {"file", "module", "reference"} or node.line is None:
|
|
92
|
+
continue
|
|
93
|
+
change = changed_by_path[node.path]
|
|
94
|
+
symbol_ranges = ranges_by_path.setdefault(
|
|
95
|
+
node.path,
|
|
96
|
+
self._symbol_ranges(node.path),
|
|
97
|
+
)
|
|
98
|
+
symbol_range = symbol_ranges.get(
|
|
99
|
+
(node.name, node.line),
|
|
100
|
+
LineRange(node.line, node.line),
|
|
101
|
+
)
|
|
102
|
+
direct = not change.changed_ranges or any(
|
|
103
|
+
symbol_range.overlaps(changed) for changed in change.changed_ranges
|
|
104
|
+
)
|
|
105
|
+
if not direct:
|
|
106
|
+
continue
|
|
107
|
+
try:
|
|
108
|
+
impact = analyzer.analyze(node.name)
|
|
109
|
+
affected_nodes = len(impact.direct) + len(impact.indirect)
|
|
110
|
+
affected_tests = len(impact.affected_tests)
|
|
111
|
+
tests.update(
|
|
112
|
+
item.node.path or item.node.name for item in impact.affected_tests
|
|
113
|
+
)
|
|
114
|
+
risk = impact.risk_score
|
|
115
|
+
except ValueError:
|
|
116
|
+
affected_nodes = 0
|
|
117
|
+
affected_tests = 0
|
|
118
|
+
risk = 0.0
|
|
119
|
+
symbols.append(
|
|
120
|
+
ChangedSymbol(
|
|
121
|
+
node=node,
|
|
122
|
+
direct_change=True,
|
|
123
|
+
impact_risk=risk,
|
|
124
|
+
affected_nodes=affected_nodes,
|
|
125
|
+
affected_tests=affected_tests,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
churn = sum(item.additions + item.deletions for item in files)
|
|
130
|
+
max_impact = max((item.impact_risk for item in symbols), default=0.0)
|
|
131
|
+
breadth = min(1.0, len(files) / 20)
|
|
132
|
+
churn_score = min(1.0, churn / 600)
|
|
133
|
+
risk_score = min(1.0, max_impact * 0.55 + breadth * 0.25 + churn_score * 0.20)
|
|
134
|
+
if risk_score >= 0.70:
|
|
135
|
+
level = "high"
|
|
136
|
+
elif risk_score >= 0.40:
|
|
137
|
+
level = "medium"
|
|
138
|
+
else:
|
|
139
|
+
level = "low"
|
|
140
|
+
symbols.sort(key=lambda item: (-item.impact_risk, item.node.path or "", item.node.line or 0))
|
|
141
|
+
return PRReport(
|
|
142
|
+
base_ref=base_ref,
|
|
143
|
+
head_ref=head_ref,
|
|
144
|
+
files=tuple(files),
|
|
145
|
+
symbols=tuple(symbols),
|
|
146
|
+
affected_tests=tuple(sorted(tests)),
|
|
147
|
+
risk_score=round(risk_score, 4),
|
|
148
|
+
risk_level=level,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def _symbol_ranges(self, relative: str) -> dict[tuple[str, int], LineRange]:
|
|
152
|
+
path = self.root / relative
|
|
153
|
+
try:
|
|
154
|
+
source = path.read_text(encoding="utf-8")
|
|
155
|
+
except (OSError, UnicodeDecodeError):
|
|
156
|
+
return {}
|
|
157
|
+
units = sorted(
|
|
158
|
+
self.languages.parse(path, source),
|
|
159
|
+
key=lambda item: (item.line, item.name),
|
|
160
|
+
)
|
|
161
|
+
total_lines = max(1, len(source.splitlines()))
|
|
162
|
+
ranges: dict[tuple[str, int], LineRange] = {}
|
|
163
|
+
for index, unit in enumerate(units):
|
|
164
|
+
if unit.end_line is not None:
|
|
165
|
+
end = unit.end_line
|
|
166
|
+
else:
|
|
167
|
+
next_line = units[index + 1].line if index + 1 < len(units) else total_lines + 1
|
|
168
|
+
end = max(unit.line, next_line - 1)
|
|
169
|
+
ranges[(unit.name, unit.line)] = LineRange(unit.line, end)
|
|
170
|
+
return ranges
|
|
171
|
+
|
|
172
|
+
def _file_changes(self, base_ref: str, head_ref: str) -> list[PRFileChange]:
|
|
173
|
+
range_ref = f"{base_ref}...{head_ref}"
|
|
174
|
+
numstat = self._git("diff", "--numstat", range_ref)
|
|
175
|
+
statuses = self._git("diff", "--name-status", range_ref)
|
|
176
|
+
patch = self._git("diff", "--unified=0", "--no-color", range_ref)
|
|
177
|
+
|
|
178
|
+
stats: dict[str, tuple[int, int]] = {}
|
|
179
|
+
for line in numstat.splitlines():
|
|
180
|
+
parts = line.split("\t")
|
|
181
|
+
if len(parts) >= 3:
|
|
182
|
+
additions = int(parts[0]) if parts[0].isdigit() else 0
|
|
183
|
+
deletions = int(parts[1]) if parts[1].isdigit() else 0
|
|
184
|
+
stats[parts[-1]] = (additions, deletions)
|
|
185
|
+
status_map: dict[str, str] = {}
|
|
186
|
+
for line in statuses.splitlines():
|
|
187
|
+
parts = line.split("\t")
|
|
188
|
+
if len(parts) >= 2:
|
|
189
|
+
status_map[parts[-1]] = parts[0]
|
|
190
|
+
ranges: dict[str, list[LineRange]] = {}
|
|
191
|
+
current_path: str | None = None
|
|
192
|
+
for line in patch.splitlines():
|
|
193
|
+
if line.startswith("+++ b/"):
|
|
194
|
+
current_path = line[6:]
|
|
195
|
+
ranges.setdefault(current_path, [])
|
|
196
|
+
continue
|
|
197
|
+
match = _HUNK.match(line)
|
|
198
|
+
if current_path and match:
|
|
199
|
+
start = int(match.group("start"))
|
|
200
|
+
count = int(match.group("count") or "1")
|
|
201
|
+
if count > 0:
|
|
202
|
+
ranges[current_path].append(LineRange(start, start + count - 1))
|
|
203
|
+
|
|
204
|
+
paths = sorted(set(stats) | set(status_map) | set(ranges))
|
|
205
|
+
return [
|
|
206
|
+
PRFileChange(
|
|
207
|
+
path=path,
|
|
208
|
+
status=status_map.get(path, "M"),
|
|
209
|
+
additions=stats.get(path, (0, 0))[0],
|
|
210
|
+
deletions=stats.get(path, (0, 0))[1],
|
|
211
|
+
changed_ranges=tuple(ranges.get(path, [])),
|
|
212
|
+
)
|
|
213
|
+
for path in paths
|
|
214
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Semantic and hybrid retrieval."""
|
|
2
|
+
|
|
3
|
+
from codecortex.retrieval.hybrid import HybridRetriever, RetrievalHit
|
|
4
|
+
from codecortex.retrieval.index import SemanticDocument, SemanticIndex
|
|
5
|
+
from codecortex.retrieval.providers import EmbeddingProvider, FeatureHashEmbeddingProvider
|
|
6
|
+
from codecortex.retrieval.repository import RepositorySemanticIndex
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"EmbeddingProvider",
|
|
10
|
+
"FeatureHashEmbeddingProvider",
|
|
11
|
+
"HybridRetriever",
|
|
12
|
+
"RepositorySemanticIndex",
|
|
13
|
+
"RetrievalHit",
|
|
14
|
+
"SemanticDocument",
|
|
15
|
+
"SemanticIndex",
|
|
16
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Hybrid retrieval combining vector, lexical, and structural priors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from codecortex.retrieval.index import SemanticDocument, SemanticIndex
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class RetrievalHit:
|
|
13
|
+
document: SemanticDocument
|
|
14
|
+
score: float
|
|
15
|
+
vector_score: float
|
|
16
|
+
lexical_score: float
|
|
17
|
+
structural_score: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HybridRetriever:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
index: SemanticIndex,
|
|
24
|
+
vector_weight: float = 0.60,
|
|
25
|
+
lexical_weight: float = 0.30,
|
|
26
|
+
structural_weight: float = 0.10,
|
|
27
|
+
) -> None:
|
|
28
|
+
total = vector_weight + lexical_weight + structural_weight
|
|
29
|
+
if total <= 0:
|
|
30
|
+
raise ValueError("retrieval weights must sum to a positive value")
|
|
31
|
+
self.index = index
|
|
32
|
+
self.vector_weight = vector_weight / total
|
|
33
|
+
self.lexical_weight = lexical_weight / total
|
|
34
|
+
self.structural_weight = structural_weight / total
|
|
35
|
+
|
|
36
|
+
def search(self, query: str, limit: int = 20) -> list[RetrievalHit]:
|
|
37
|
+
candidates = self.index.search(query, limit=max(limit * 4, 40))
|
|
38
|
+
query_terms = {term.lower() for term in re.findall(r"[A-Za-z_][\w.-]*", query)}
|
|
39
|
+
hits: list[RetrievalHit] = []
|
|
40
|
+
for match in candidates:
|
|
41
|
+
text_terms = {term.lower() for term in re.findall(r"[A-Za-z_][\w.-]*", match.document.text)}
|
|
42
|
+
lexical = len(query_terms & text_terms) / max(1, len(query_terms))
|
|
43
|
+
metadata = match.document.metadata
|
|
44
|
+
structural = 0.0
|
|
45
|
+
path = str(metadata.get("path", "")).lower()
|
|
46
|
+
symbol = str(metadata.get("symbol", "")).lower()
|
|
47
|
+
if any(term in path for term in query_terms):
|
|
48
|
+
structural += 0.45
|
|
49
|
+
if any(term == symbol for term in query_terms):
|
|
50
|
+
structural += 0.55
|
|
51
|
+
vector_score = (match.score + 1.0) / 2.0
|
|
52
|
+
score = (
|
|
53
|
+
self.vector_weight * vector_score
|
|
54
|
+
+ self.lexical_weight * lexical
|
|
55
|
+
+ self.structural_weight * min(1.0, structural)
|
|
56
|
+
)
|
|
57
|
+
hits.append(
|
|
58
|
+
RetrievalHit(
|
|
59
|
+
document=match.document,
|
|
60
|
+
score=score,
|
|
61
|
+
vector_score=vector_score,
|
|
62
|
+
lexical_score=lexical,
|
|
63
|
+
structural_score=min(1.0, structural),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
hits.sort(key=lambda item: (-item.score, item.document.id))
|
|
67
|
+
return hits[:limit]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Persistent semantic vector index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from codecortex.retrieval.providers import EmbeddingProvider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class SemanticDocument:
|
|
16
|
+
id: str
|
|
17
|
+
text: str
|
|
18
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class SemanticMatch:
|
|
23
|
+
document: SemanticDocument
|
|
24
|
+
score: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SemanticIndex:
|
|
28
|
+
VERSION = 1
|
|
29
|
+
|
|
30
|
+
def __init__(self, provider: EmbeddingProvider, path: Path | None = None) -> None:
|
|
31
|
+
self.provider = provider
|
|
32
|
+
self.path = path
|
|
33
|
+
self._documents: dict[str, SemanticDocument] = {}
|
|
34
|
+
self._vectors: dict[str, list[float]] = {}
|
|
35
|
+
if path and path.exists():
|
|
36
|
+
self.load()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def document_ids(self) -> set[str]:
|
|
40
|
+
return set(self._documents)
|
|
41
|
+
|
|
42
|
+
def upsert(self, documents: list[SemanticDocument]) -> None:
|
|
43
|
+
if not documents:
|
|
44
|
+
return
|
|
45
|
+
vectors = self.provider.embed([document.text for document in documents])
|
|
46
|
+
for document, vector in zip(documents, vectors, strict=True):
|
|
47
|
+
self._documents[document.id] = document
|
|
48
|
+
self._vectors[document.id] = vector
|
|
49
|
+
if self.path:
|
|
50
|
+
self.save()
|
|
51
|
+
|
|
52
|
+
def replace(self, documents: list[SemanticDocument]) -> None:
|
|
53
|
+
self._documents.clear()
|
|
54
|
+
self._vectors.clear()
|
|
55
|
+
self.upsert(documents)
|
|
56
|
+
if not documents and self.path:
|
|
57
|
+
self.save()
|
|
58
|
+
|
|
59
|
+
def delete(self, ids: set[str]) -> None:
|
|
60
|
+
for document_id in ids:
|
|
61
|
+
self._documents.pop(document_id, None)
|
|
62
|
+
self._vectors.pop(document_id, None)
|
|
63
|
+
if self.path:
|
|
64
|
+
self.save()
|
|
65
|
+
|
|
66
|
+
def search(self, query: str, limit: int = 20, min_score: float = -1.0) -> list[SemanticMatch]:
|
|
67
|
+
if not self._documents:
|
|
68
|
+
return []
|
|
69
|
+
query_vector = self.provider.embed([query])[0]
|
|
70
|
+
ranked: list[tuple[float, str]] = []
|
|
71
|
+
for document_id, vector in self._vectors.items():
|
|
72
|
+
score = self._cosine(query_vector, vector)
|
|
73
|
+
if score >= min_score:
|
|
74
|
+
ranked.append((score, document_id))
|
|
75
|
+
ranked.sort(key=lambda item: (-item[0], item[1]))
|
|
76
|
+
return [
|
|
77
|
+
SemanticMatch(document=self._documents[document_id], score=score)
|
|
78
|
+
for score, document_id in ranked[:limit]
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def save(self) -> None:
|
|
82
|
+
if self.path is None:
|
|
83
|
+
return
|
|
84
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
payload = {
|
|
86
|
+
"version": self.VERSION,
|
|
87
|
+
"provider": self.provider.name,
|
|
88
|
+
"dimensions": self.provider.dimensions,
|
|
89
|
+
"documents": {
|
|
90
|
+
document_id: asdict(document)
|
|
91
|
+
for document_id, document in sorted(self._documents.items())
|
|
92
|
+
},
|
|
93
|
+
"vectors": self._vectors,
|
|
94
|
+
}
|
|
95
|
+
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
96
|
+
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
97
|
+
temp.replace(self.path)
|
|
98
|
+
|
|
99
|
+
def load(self) -> None:
|
|
100
|
+
if self.path is None:
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
|
104
|
+
except (OSError, json.JSONDecodeError):
|
|
105
|
+
return
|
|
106
|
+
if payload.get("version") != self.VERSION:
|
|
107
|
+
return
|
|
108
|
+
if payload.get("provider") != self.provider.name:
|
|
109
|
+
return
|
|
110
|
+
if int(payload.get("dimensions", -1)) != self.provider.dimensions:
|
|
111
|
+
return
|
|
112
|
+
documents = payload.get("documents", {})
|
|
113
|
+
vectors = payload.get("vectors", {})
|
|
114
|
+
self._documents = {
|
|
115
|
+
document_id: SemanticDocument(
|
|
116
|
+
id=str(value["id"]),
|
|
117
|
+
text=str(value["text"]),
|
|
118
|
+
metadata=dict(value.get("metadata", {})),
|
|
119
|
+
)
|
|
120
|
+
for document_id, value in documents.items()
|
|
121
|
+
}
|
|
122
|
+
self._vectors = {
|
|
123
|
+
document_id: [float(value) for value in vector]
|
|
124
|
+
for document_id, vector in vectors.items()
|
|
125
|
+
if document_id in self._documents
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _cosine(left: list[float], right: list[float]) -> float:
|
|
130
|
+
if len(left) != len(right):
|
|
131
|
+
return -1.0
|
|
132
|
+
dot = sum(a * b for a, b in zip(left, right, strict=True))
|
|
133
|
+
left_norm = math.sqrt(sum(value * value for value in left)) or 1.0
|
|
134
|
+
right_norm = math.sqrt(sum(value * value for value in right)) or 1.0
|
|
135
|
+
return dot / (left_norm * right_norm)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Embedding provider contracts and local/optional implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
_TOKEN = re.compile(r"[A-Za-z_][A-Za-z0-9_.$:/-]*")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EmbeddingProvider(Protocol):
|
|
14
|
+
name: str
|
|
15
|
+
dimensions: int
|
|
16
|
+
|
|
17
|
+
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FeatureHashEmbeddingProvider:
|
|
21
|
+
"""Deterministic zero-network fallback suitable for local code retrieval."""
|
|
22
|
+
|
|
23
|
+
name = "feature-hash-v1"
|
|
24
|
+
|
|
25
|
+
def __init__(self, dimensions: int = 384) -> None:
|
|
26
|
+
if dimensions < 64:
|
|
27
|
+
raise ValueError("dimensions must be >= 64")
|
|
28
|
+
self.dimensions = dimensions
|
|
29
|
+
|
|
30
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
31
|
+
return [self._embed_one(text) for text in texts]
|
|
32
|
+
|
|
33
|
+
def _embed_one(self, text: str) -> list[float]:
|
|
34
|
+
vector = [0.0] * self.dimensions
|
|
35
|
+
tokens = [token.lower() for token in _TOKEN.findall(text)]
|
|
36
|
+
features = tokens + [
|
|
37
|
+
f"{left}::{right}"
|
|
38
|
+
for left, right in zip(tokens, tokens[1:], strict=False)
|
|
39
|
+
]
|
|
40
|
+
for feature in features:
|
|
41
|
+
digest = hashlib.blake2b(feature.encode("utf-8"), digest_size=8).digest()
|
|
42
|
+
raw = int.from_bytes(digest, "little")
|
|
43
|
+
index = raw % self.dimensions
|
|
44
|
+
sign = -1.0 if raw & 1 else 1.0
|
|
45
|
+
vector[index] += sign
|
|
46
|
+
norm = math.sqrt(sum(value * value for value in vector)) or 1.0
|
|
47
|
+
return [value / norm for value in vector]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SentenceTransformerEmbeddingProvider:
|
|
51
|
+
"""Optional local neural provider loaded only when the semantic extra is installed."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, model: str = "sentence-transformers/all-MiniLM-L6-v2") -> None:
|
|
54
|
+
try:
|
|
55
|
+
from sentence_transformers import SentenceTransformer
|
|
56
|
+
except ImportError as exc:
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
"Install CodeCortex with the 'semantic' extra to use neural embeddings."
|
|
59
|
+
) from exc
|
|
60
|
+
self._model = SentenceTransformer(model)
|
|
61
|
+
self.name = f"sentence-transformer:{model}"
|
|
62
|
+
probe = self._model.encode(["codecortex"], normalize_embeddings=True)
|
|
63
|
+
self.dimensions = int(len(probe[0]))
|
|
64
|
+
|
|
65
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
66
|
+
vectors = self._model.encode(texts, normalize_embeddings=True)
|
|
67
|
+
return [[float(value) for value in row] for row in vectors]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Repository-to-semantic-index ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codecortex.indexing.graph import ProjectGraph
|
|
8
|
+
from codecortex.indexing.incremental_graph import IncrementalGraphIndex
|
|
9
|
+
from codecortex.retrieval.hybrid import HybridRetriever, RetrievalHit
|
|
10
|
+
from codecortex.retrieval.index import SemanticDocument, SemanticIndex
|
|
11
|
+
from codecortex.retrieval.providers import EmbeddingProvider, FeatureHashEmbeddingProvider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RepositorySemanticIndex:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
root: Path,
|
|
18
|
+
provider: EmbeddingProvider | None = None,
|
|
19
|
+
index_path: Path | None = None,
|
|
20
|
+
max_snippet_chars: int = 3_000,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.root = root.resolve()
|
|
23
|
+
self.provider = provider or FeatureHashEmbeddingProvider()
|
|
24
|
+
self.index_path = index_path or self.root / ".codecortex" / "index" / "semantic.json"
|
|
25
|
+
self.index = SemanticIndex(self.provider, self.index_path)
|
|
26
|
+
self.max_snippet_chars = max_snippet_chars
|
|
27
|
+
|
|
28
|
+
def refresh(self, graph: ProjectGraph | None = None) -> int:
|
|
29
|
+
graph = graph or IncrementalGraphIndex(self.root).refresh()[0]
|
|
30
|
+
documents = [self._document(node, graph) for node in graph.nodes]
|
|
31
|
+
filtered = [document for document in documents if document is not None]
|
|
32
|
+
self.index.replace(filtered)
|
|
33
|
+
return len(filtered)
|
|
34
|
+
|
|
35
|
+
def search(self, query: str, limit: int = 20) -> list[RetrievalHit]:
|
|
36
|
+
if not self.index.document_ids:
|
|
37
|
+
self.refresh()
|
|
38
|
+
return HybridRetriever(self.index).search(query, limit)
|
|
39
|
+
|
|
40
|
+
def _document(self, node, graph: ProjectGraph) -> SemanticDocument | None:
|
|
41
|
+
if node.kind in {"module", "reference"}:
|
|
42
|
+
return None
|
|
43
|
+
metadata = {
|
|
44
|
+
"path": node.path or "",
|
|
45
|
+
"symbol": node.name if node.kind != "file" else "",
|
|
46
|
+
"kind": node.kind,
|
|
47
|
+
"line": node.line or 0,
|
|
48
|
+
}
|
|
49
|
+
structural = self._structural_context(node.id, graph)
|
|
50
|
+
if node.path:
|
|
51
|
+
path = self.root / node.path
|
|
52
|
+
snippet = self._snippet(path, node.line)
|
|
53
|
+
else:
|
|
54
|
+
snippet = ""
|
|
55
|
+
text = "\n".join(
|
|
56
|
+
part
|
|
57
|
+
for part in (
|
|
58
|
+
f"{node.kind} {node.name}",
|
|
59
|
+
f"path {node.path}" if node.path else "",
|
|
60
|
+
structural,
|
|
61
|
+
snippet,
|
|
62
|
+
)
|
|
63
|
+
if part
|
|
64
|
+
)
|
|
65
|
+
return SemanticDocument(id=node.id, text=text, metadata=metadata)
|
|
66
|
+
|
|
67
|
+
def _snippet(self, path: Path, line: int | None) -> str:
|
|
68
|
+
try:
|
|
69
|
+
source = path.read_text(encoding="utf-8")
|
|
70
|
+
except (OSError, UnicodeDecodeError):
|
|
71
|
+
return ""
|
|
72
|
+
if line is None:
|
|
73
|
+
return source[: self.max_snippet_chars]
|
|
74
|
+
lines = source.splitlines()
|
|
75
|
+
start = max(0, line - 8)
|
|
76
|
+
end = min(len(lines), line + 20)
|
|
77
|
+
return "\n".join(lines[start:end])[: self.max_snippet_chars]
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _structural_context(node_id: str, graph: ProjectGraph) -> str:
|
|
81
|
+
relations: list[str] = []
|
|
82
|
+
node_map = {node.id: node for node in graph.nodes}
|
|
83
|
+
for edge in graph.edges:
|
|
84
|
+
if edge.source == node_id:
|
|
85
|
+
target = node_map.get(edge.target)
|
|
86
|
+
if target:
|
|
87
|
+
relations.append(f"{edge.kind} {target.name}")
|
|
88
|
+
elif edge.target == node_id:
|
|
89
|
+
source = node_map.get(edge.source)
|
|
90
|
+
if source:
|
|
91
|
+
relations.append(f"used-by {source.name} via {edge.kind}")
|
|
92
|
+
if len(relations) >= 20:
|
|
93
|
+
break
|
|
94
|
+
return "\n".join(relations)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Deterministic adaptive router used by the orchestration core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codecortex.core.models import AgentRequest, Capability, RequestKind, RoutePlan, RouteScore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AdaptiveRouter:
|
|
9
|
+
"""Classify requests and produce an observable capability plan."""
|
|
10
|
+
|
|
11
|
+
_kind_terms: dict[RequestKind, tuple[str, ...]] = {
|
|
12
|
+
RequestKind.LOCATE: ("find", "where", "locate", "symbol", "definition", "reference"),
|
|
13
|
+
RequestKind.DEBUG: ("bug", "debug", "error", "fail", "crash", "race", "trace"),
|
|
14
|
+
RequestKind.REFACTOR: ("refactor", "rename", "extract", "move", "restructure"),
|
|
15
|
+
RequestKind.CHANGE: ("change", "edit", "implement", "add", "remove", "fix"),
|
|
16
|
+
RequestKind.REVIEW: ("review", "risk", "regression", "security", "inspect"),
|
|
17
|
+
RequestKind.EXPLAIN: ("explain", "why", "how", "architecture", "understand"),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def __init__(self, default_budget: int = 32_000) -> None:
|
|
21
|
+
self.default_budget = default_budget
|
|
22
|
+
|
|
23
|
+
def route(self, request: AgentRequest) -> RoutePlan:
|
|
24
|
+
text = request.query.lower()
|
|
25
|
+
kind = request.kind if request.kind != RequestKind.UNKNOWN else self._classify(text)
|
|
26
|
+
|
|
27
|
+
scores = {
|
|
28
|
+
Capability.REPOSITORY: 0.35,
|
|
29
|
+
Capability.SYMBOLS: 0.30,
|
|
30
|
+
Capability.CONTEXT: 0.45,
|
|
31
|
+
Capability.MEMORY: 0.20,
|
|
32
|
+
Capability.VALIDATION: 0.10,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if kind in {RequestKind.EXPLAIN, RequestKind.DEBUG, RequestKind.REVIEW}:
|
|
36
|
+
scores[Capability.REPOSITORY] += 0.45
|
|
37
|
+
if kind in {RequestKind.LOCATE, RequestKind.DEBUG, RequestKind.REFACTOR, RequestKind.CHANGE}:
|
|
38
|
+
scores[Capability.SYMBOLS] += 0.50
|
|
39
|
+
if kind in {RequestKind.DEBUG, RequestKind.REFACTOR, RequestKind.CHANGE, RequestKind.REVIEW}:
|
|
40
|
+
scores[Capability.VALIDATION] += 0.55
|
|
41
|
+
if any(term in text for term in ("history", "previous", "decision", "remember", "again")):
|
|
42
|
+
scores[Capability.MEMORY] += 0.65
|
|
43
|
+
if any(term in text for term in ("large", "logs", "context", "tokens", "many files")):
|
|
44
|
+
scores[Capability.CONTEXT] += 0.35
|
|
45
|
+
|
|
46
|
+
ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True)
|
|
47
|
+
route_scores = [
|
|
48
|
+
RouteScore(
|
|
49
|
+
capability=capability,
|
|
50
|
+
score=min(score, 1.0),
|
|
51
|
+
reason=self._reason(capability, kind),
|
|
52
|
+
)
|
|
53
|
+
for capability, score in ranked
|
|
54
|
+
]
|
|
55
|
+
selected = [item.capability for item in route_scores if item.score >= 0.50]
|
|
56
|
+
if not selected:
|
|
57
|
+
selected = [Capability.REPOSITORY]
|
|
58
|
+
|
|
59
|
+
return RoutePlan(
|
|
60
|
+
request_kind=kind,
|
|
61
|
+
scores=route_scores,
|
|
62
|
+
selected=selected,
|
|
63
|
+
context_budget=self.default_budget,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _classify(self, text: str) -> RequestKind:
|
|
67
|
+
matches: list[tuple[int, RequestKind]] = []
|
|
68
|
+
for kind, terms in self._kind_terms.items():
|
|
69
|
+
count = sum(1 for term in terms if term in text)
|
|
70
|
+
if count:
|
|
71
|
+
matches.append((count, kind))
|
|
72
|
+
if not matches:
|
|
73
|
+
return RequestKind.EXPLAIN
|
|
74
|
+
matches.sort(key=lambda item: item[0], reverse=True)
|
|
75
|
+
return matches[0][1]
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _reason(capability: Capability, kind: RequestKind) -> str:
|
|
79
|
+
return f"{capability.value} is relevant to a {kind.value} request"
|