coderag-ai 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.
- coderag/__init__.py +12 -0
- coderag/analyzers/__init__.py +0 -0
- coderag/analyzers/base.py +36 -0
- coderag/analyzers/flake8_adapter.py +43 -0
- coderag/analyzers/pylint_adapter.py +50 -0
- coderag/analyzers/workflow.py +107 -0
- coderag/api/__init__.py +0 -0
- coderag/api/app.py +351 -0
- coderag/api/dashboard.py +237 -0
- coderag/api/schemas.py +181 -0
- coderag/cli/__init__.py +0 -0
- coderag/cli/main.py +359 -0
- coderag/context/__init__.py +0 -0
- coderag/context/builder.py +206 -0
- coderag/context/package.py +100 -0
- coderag/core/__init__.py +0 -0
- coderag/core/config.py +100 -0
- coderag/core/logging.py +58 -0
- coderag/core/text.py +80 -0
- coderag/core/tokens.py +66 -0
- coderag/db/__init__.py +0 -0
- coderag/db/base.py +58 -0
- coderag/db/migrations/env.py +77 -0
- coderag/db/migrations/script.py.mako +26 -0
- coderag/db/migrations/versions/.gitkeep +0 -0
- coderag/db/migrations/versions/9a973932b94d_measure_baseline_tokens.py +41 -0
- coderag/db/migrations/versions/c8a40feeda41_initial_schema.py +227 -0
- coderag/db/models.py +275 -0
- coderag/embeddings/__init__.py +0 -0
- coderag/embeddings/base.py +53 -0
- coderag/embeddings/hashing.py +43 -0
- coderag/embeddings/pipeline.py +87 -0
- coderag/embeddings/registry.py +38 -0
- coderag/embeddings/sentence_transformer.py +47 -0
- coderag/evaluation/__init__.py +0 -0
- coderag/evaluation/datasets.py +36 -0
- coderag/evaluation/harness.py +246 -0
- coderag/git/__init__.py +0 -0
- coderag/git/repo.py +139 -0
- coderag/indexing/__init__.py +0 -0
- coderag/indexing/ignore.py +101 -0
- coderag/indexing/indexer.py +445 -0
- coderag/llm/__init__.py +0 -0
- coderag/llm/anthropic.py +117 -0
- coderag/llm/base.py +60 -0
- coderag/llm/null.py +16 -0
- coderag/llm/registry.py +17 -0
- coderag/localdb.py +100 -0
- coderag/mcp_server.py +209 -0
- coderag/parsing/__init__.py +0 -0
- coderag/parsing/base.py +76 -0
- coderag/parsing/python.py +294 -0
- coderag/parsing/registry.py +40 -0
- coderag/retrieval/__init__.py +0 -0
- coderag/retrieval/base.py +60 -0
- coderag/retrieval/engine.py +176 -0
- coderag/retrieval/fusion.py +41 -0
- coderag/retrieval/graph.py +139 -0
- coderag/retrieval/lexical.py +57 -0
- coderag/retrieval/reranking.py +99 -0
- coderag/retrieval/semantic.py +53 -0
- coderag/retrieval/symbol.py +91 -0
- coderag/security/__init__.py +0 -0
- coderag/security/authz.py +35 -0
- coderag/security/secrets.py +110 -0
- coderag/service.py +160 -0
- coderag/telemetry.py +72 -0
- coderag_ai-0.3.0.dist-info/METADATA +364 -0
- coderag_ai-0.3.0.dist-info/RECORD +73 -0
- coderag_ai-0.3.0.dist-info/WHEEL +4 -0
- coderag_ai-0.3.0.dist-info/entry_points.txt +3 -0
- coderag_ai-0.3.0.dist-info/licenses/LICENSE +202 -0
- coderag_ai-0.3.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Tree-sitter based Python parser.
|
|
2
|
+
|
|
3
|
+
Extracts module/class/function/method symbols with precise line ranges,
|
|
4
|
+
signatures, docstrings, and full-text search terms. Chunk boundaries always
|
|
5
|
+
follow constructs (ADR-002). Relationship extraction is added in Phase 4 via
|
|
6
|
+
``extract_relationships`` on the produced tree.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tree_sitter_python as tspython
|
|
12
|
+
from tree_sitter import Language, Parser
|
|
13
|
+
|
|
14
|
+
from coderag.core.text import build_search_terms, extract_identifiers, split_identifier
|
|
15
|
+
from coderag.parsing.base import (
|
|
16
|
+
CALLS,
|
|
17
|
+
CLASS,
|
|
18
|
+
FUNCTION,
|
|
19
|
+
IMPORTS,
|
|
20
|
+
INHERITS,
|
|
21
|
+
METHOD,
|
|
22
|
+
MODULE,
|
|
23
|
+
LanguageParser,
|
|
24
|
+
ParsedRelationship,
|
|
25
|
+
ParsedSymbol,
|
|
26
|
+
ParseResult,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_DEF_TYPES = {"function_definition", "class_definition", "decorated_definition"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PythonParser(LanguageParser):
|
|
33
|
+
language = "python"
|
|
34
|
+
extensions = (".py",)
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._lang = Language(tspython.language(), "python")
|
|
38
|
+
self._parser = Parser()
|
|
39
|
+
self._parser.set_language(self._lang)
|
|
40
|
+
|
|
41
|
+
def parse(self, module_qualified_name: str, source: str) -> ParseResult:
|
|
42
|
+
src = source.encode("utf-8")
|
|
43
|
+
tree = self._parser.parse(src)
|
|
44
|
+
root = tree.root_node
|
|
45
|
+
symbols: list[ParsedSymbol] = []
|
|
46
|
+
relationships: list[ParsedRelationship] = []
|
|
47
|
+
counter = _Counter()
|
|
48
|
+
|
|
49
|
+
def text(node) -> str:
|
|
50
|
+
return src[node.start_byte : node.end_byte].decode("utf-8", "replace")
|
|
51
|
+
|
|
52
|
+
# -- module symbol: top-level code excluding class/function bodies ----
|
|
53
|
+
header_nodes = [c for c in root.named_children if c.type not in _DEF_TYPES]
|
|
54
|
+
header_src = "\n".join(text(n) for n in header_nodes)
|
|
55
|
+
mod_end = header_nodes[-1].end_point[0] + 1 if header_nodes else 1
|
|
56
|
+
mod_name = module_qualified_name.rsplit(".", 1)[-1]
|
|
57
|
+
module_sym = ParsedSymbol(
|
|
58
|
+
local_id=counter.next(),
|
|
59
|
+
parent_local_id=None,
|
|
60
|
+
symbol_name=mod_name,
|
|
61
|
+
qualified_name=module_qualified_name,
|
|
62
|
+
symbol_type=MODULE,
|
|
63
|
+
start_line=1,
|
|
64
|
+
end_line=mod_end,
|
|
65
|
+
source_code=header_src,
|
|
66
|
+
signature=None,
|
|
67
|
+
docstring=_docstring_of(root, src),
|
|
68
|
+
search_terms=build_search_terms(
|
|
69
|
+
split_identifier(mod_name),
|
|
70
|
+
extract_identifiers(header_src),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
symbols.append(module_sym)
|
|
74
|
+
|
|
75
|
+
# -- module imports --------------------------------------------------
|
|
76
|
+
for node in header_nodes:
|
|
77
|
+
for target in _imports(node, src):
|
|
78
|
+
relationships.append(
|
|
79
|
+
ParsedRelationship(module_sym.local_id, IMPORTS, target, confidence=0.9)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# -- recurse into definitions ----------------------------------------
|
|
83
|
+
def emit(node, parent_local_id: int, qual_prefix: str, enclosing_is_class: bool):
|
|
84
|
+
inner = _unwrap(node)
|
|
85
|
+
name_node = inner.child_by_field_name("name")
|
|
86
|
+
if name_node is None:
|
|
87
|
+
return
|
|
88
|
+
name = text(name_node)
|
|
89
|
+
qualified = f"{qual_prefix}.{name}"
|
|
90
|
+
is_class = inner.type == "class_definition"
|
|
91
|
+
symbol_type = CLASS if is_class else (METHOD if enclosing_is_class else FUNCTION)
|
|
92
|
+
body = inner.child_by_field_name("body")
|
|
93
|
+
signature = _signature(node, inner, body, src)
|
|
94
|
+
source_code = text(node) # node may be the decorated wrapper
|
|
95
|
+
sym = ParsedSymbol(
|
|
96
|
+
local_id=counter.next(),
|
|
97
|
+
parent_local_id=parent_local_id,
|
|
98
|
+
symbol_name=name,
|
|
99
|
+
qualified_name=qualified,
|
|
100
|
+
symbol_type=symbol_type,
|
|
101
|
+
start_line=node.start_point[0] + 1,
|
|
102
|
+
end_line=node.end_point[0] + 1,
|
|
103
|
+
source_code=source_code,
|
|
104
|
+
signature=signature,
|
|
105
|
+
docstring=_docstring_of(inner, src),
|
|
106
|
+
search_terms=build_search_terms(
|
|
107
|
+
split_identifier(name),
|
|
108
|
+
[p for seg in qualified.split(".") for p in split_identifier(seg)],
|
|
109
|
+
extract_identifiers(signature or ""),
|
|
110
|
+
extract_identifiers(source_code),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
symbols.append(sym)
|
|
114
|
+
|
|
115
|
+
if is_class:
|
|
116
|
+
for base in _bases(inner, src):
|
|
117
|
+
relationships.append(
|
|
118
|
+
ParsedRelationship(sym.local_id, INHERITS, base, confidence=0.9)
|
|
119
|
+
)
|
|
120
|
+
elif body is not None:
|
|
121
|
+
for target, via in _calls(body, src):
|
|
122
|
+
relationships.append(
|
|
123
|
+
ParsedRelationship(
|
|
124
|
+
sym.local_id, CALLS, target,
|
|
125
|
+
confidence=0.9 if via == "self" else 0.6,
|
|
126
|
+
metadata={"via": via},
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if body is not None:
|
|
131
|
+
for child in body.named_children:
|
|
132
|
+
if child.type in _DEF_TYPES:
|
|
133
|
+
emit(child, sym.local_id, qualified, enclosing_is_class=is_class)
|
|
134
|
+
|
|
135
|
+
for child in root.named_children:
|
|
136
|
+
if child.type in _DEF_TYPES:
|
|
137
|
+
emit(child, module_sym.local_id, module_qualified_name, enclosing_is_class=False)
|
|
138
|
+
|
|
139
|
+
return ParseResult(symbols=symbols, relationships=relationships)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _Counter:
|
|
143
|
+
def __init__(self) -> None:
|
|
144
|
+
self._n = -1
|
|
145
|
+
|
|
146
|
+
def next(self) -> int:
|
|
147
|
+
self._n += 1
|
|
148
|
+
return self._n
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _unwrap(node):
|
|
152
|
+
"""decorated_definition -> the inner function/class definition."""
|
|
153
|
+
if node.type == "decorated_definition":
|
|
154
|
+
for c in node.named_children:
|
|
155
|
+
if c.type in ("function_definition", "class_definition"):
|
|
156
|
+
return c
|
|
157
|
+
return node
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _signature(outer, inner, body, src: bytes) -> str | None:
|
|
161
|
+
end = body.start_byte if body is not None else inner.end_byte
|
|
162
|
+
text = src[inner.start_byte : end].decode("utf-8", "replace").strip()
|
|
163
|
+
return text.rstrip(":").strip() or None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _docstring_of(scope_node, src: bytes) -> str | None:
|
|
167
|
+
"""Return the cleaned docstring of a module/function/class node, if any."""
|
|
168
|
+
body = scope_node.child_by_field_name("body")
|
|
169
|
+
candidates = body.named_children if body is not None else scope_node.named_children
|
|
170
|
+
for child in candidates:
|
|
171
|
+
if child.type == "expression_statement" and child.named_children:
|
|
172
|
+
first = child.named_children[0]
|
|
173
|
+
if first.type == "string":
|
|
174
|
+
raw = src[first.start_byte : first.end_byte].decode("utf-8", "replace")
|
|
175
|
+
return _clean_docstring(raw)
|
|
176
|
+
# only the *first* statement can be a docstring
|
|
177
|
+
break
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _node_text(node, src: bytes) -> str:
|
|
182
|
+
return src[node.start_byte : node.end_byte].decode("utf-8", "replace")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _imports(node, src: bytes) -> list[str]:
|
|
186
|
+
"""Return imported dotted names from an import/import-from statement."""
|
|
187
|
+
out: list[str] = []
|
|
188
|
+
if node.type == "import_statement":
|
|
189
|
+
for child in node.named_children:
|
|
190
|
+
if child.type in ("dotted_name", "aliased_import"):
|
|
191
|
+
dn = child if child.type == "dotted_name" else child.child_by_field_name("name")
|
|
192
|
+
if dn is not None:
|
|
193
|
+
out.append(_node_text(dn, src))
|
|
194
|
+
elif node.type == "import_from_statement":
|
|
195
|
+
mod_node = node.child_by_field_name("module_name")
|
|
196
|
+
# module_name field may not be set on older grammars; fall back to first dotted_name
|
|
197
|
+
module = None
|
|
198
|
+
if mod_node is not None:
|
|
199
|
+
module = _node_text(mod_node, src)
|
|
200
|
+
else:
|
|
201
|
+
for child in node.named_children:
|
|
202
|
+
if child.type in ("dotted_name", "relative_import"):
|
|
203
|
+
module = _node_text(child, src)
|
|
204
|
+
break
|
|
205
|
+
# imported names are the dotted_name/aliased_import nodes after the module
|
|
206
|
+
names: list[str] = []
|
|
207
|
+
seen_module = False
|
|
208
|
+
for child in node.named_children:
|
|
209
|
+
if not seen_module:
|
|
210
|
+
if module and _node_text(child, src) == module:
|
|
211
|
+
seen_module = True
|
|
212
|
+
continue
|
|
213
|
+
if child.type == "dotted_name":
|
|
214
|
+
names.append(_node_text(child, src))
|
|
215
|
+
elif child.type == "aliased_import":
|
|
216
|
+
nm = child.child_by_field_name("name")
|
|
217
|
+
if nm is not None:
|
|
218
|
+
names.append(_node_text(nm, src))
|
|
219
|
+
elif child.type == "wildcard_import":
|
|
220
|
+
pass
|
|
221
|
+
if names and module:
|
|
222
|
+
out.extend(f"{module}.{n}" for n in names)
|
|
223
|
+
elif module:
|
|
224
|
+
out.append(module)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _bases(class_inner, src: bytes) -> list[str]:
|
|
229
|
+
sup = class_inner.child_by_field_name("superclasses")
|
|
230
|
+
if sup is None:
|
|
231
|
+
return []
|
|
232
|
+
bases: list[str] = []
|
|
233
|
+
for child in sup.named_children:
|
|
234
|
+
if child.type == "identifier":
|
|
235
|
+
bases.append(_node_text(child, src))
|
|
236
|
+
elif child.type == "attribute":
|
|
237
|
+
attr = child.child_by_field_name("attribute")
|
|
238
|
+
if attr is not None:
|
|
239
|
+
bases.append(_node_text(attr, src))
|
|
240
|
+
return bases
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _calls(body, src: bytes) -> list[tuple[str, str]]:
|
|
244
|
+
"""Collect (target_name, via) call targets within a body.
|
|
245
|
+
|
|
246
|
+
Does not descend into nested definitions (their calls belong to them).
|
|
247
|
+
``via`` is "self" for self.method(), else "attr" or "bare".
|
|
248
|
+
"""
|
|
249
|
+
out: list[tuple[str, str]] = []
|
|
250
|
+
seen: set[tuple[str, str]] = set()
|
|
251
|
+
|
|
252
|
+
def visit(node):
|
|
253
|
+
for child in node.children:
|
|
254
|
+
if child.type in _DEF_TYPES:
|
|
255
|
+
continue # nested symbol; handled separately
|
|
256
|
+
if child.type == "call":
|
|
257
|
+
fn = child.child_by_field_name("function")
|
|
258
|
+
hit = _call_target(fn, src)
|
|
259
|
+
if hit and hit not in seen:
|
|
260
|
+
seen.add(hit)
|
|
261
|
+
out.append(hit)
|
|
262
|
+
visit(child)
|
|
263
|
+
|
|
264
|
+
visit(body)
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _call_target(fn, src: bytes) -> tuple[str, str] | None:
|
|
269
|
+
if fn is None:
|
|
270
|
+
return None
|
|
271
|
+
if fn.type == "identifier":
|
|
272
|
+
return (_node_text(fn, src), "bare")
|
|
273
|
+
if fn.type == "attribute":
|
|
274
|
+
obj = fn.child_by_field_name("object")
|
|
275
|
+
attr = fn.child_by_field_name("attribute")
|
|
276
|
+
if attr is None:
|
|
277
|
+
return None
|
|
278
|
+
via = "self" if obj is not None and _node_text(obj, src) == "self" else "attr"
|
|
279
|
+
return (_node_text(attr, src), via)
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _clean_docstring(raw: str) -> str:
|
|
284
|
+
s = raw.strip()
|
|
285
|
+
# strip a leading string prefix (r, b, f, u and combinations)
|
|
286
|
+
i = 0
|
|
287
|
+
while i < len(s) and s[i] in "rbfuRBFU":
|
|
288
|
+
i += 1
|
|
289
|
+
s = s[i:]
|
|
290
|
+
for q in ('"""', "'''", '"', "'"):
|
|
291
|
+
if s.startswith(q) and s.endswith(q) and len(s) >= 2 * len(q):
|
|
292
|
+
s = s[len(q) : -len(q)]
|
|
293
|
+
break
|
|
294
|
+
return s.strip()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Parser selection by file extension, plus module-name derivation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
|
|
7
|
+
from coderag.parsing.base import LanguageParser
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache
|
|
11
|
+
def _python_parser() -> LanguageParser:
|
|
12
|
+
from coderag.parsing.python import PythonParser
|
|
13
|
+
|
|
14
|
+
return PythonParser()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_parser_for_path(path: str) -> LanguageParser | None:
|
|
18
|
+
from coderag.indexing.ignore import language_for_path
|
|
19
|
+
|
|
20
|
+
lang = language_for_path(path)
|
|
21
|
+
if lang == "python":
|
|
22
|
+
return _python_parser()
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def module_qualified_name(rel_path: str) -> str:
|
|
27
|
+
"""Derive a dotted module name from a repo-relative path.
|
|
28
|
+
|
|
29
|
+
``services/payment_service.py`` -> ``services.payment_service``
|
|
30
|
+
``services/__init__.py`` -> ``services``
|
|
31
|
+
``main.py`` -> ``main``
|
|
32
|
+
"""
|
|
33
|
+
p = rel_path.replace("\\", "/")
|
|
34
|
+
dot = p.rfind(".")
|
|
35
|
+
if dot != -1:
|
|
36
|
+
p = p[:dot]
|
|
37
|
+
parts = [seg for seg in p.split("/") if seg]
|
|
38
|
+
if parts and parts[-1] == "__init__":
|
|
39
|
+
parts = parts[:-1]
|
|
40
|
+
return ".".join(parts) if parts else "root"
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Retrieval value objects and the Retriever protocol.
|
|
2
|
+
|
|
3
|
+
A ``Retriever`` returns ``RawHit``s (symbol id + a source-local score) ordered
|
|
4
|
+
best-first. The engine fuses hits from all retrievers into ``Candidate``s, each
|
|
5
|
+
of which records *why* it was retrieved (its reasons) and its per-source ranks —
|
|
6
|
+
so retrieval is explainable, per the design principles.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from sqlalchemy.orm import Session
|
|
15
|
+
|
|
16
|
+
# Source / reason labels.
|
|
17
|
+
EXACT_SYMBOL = "exact_symbol"
|
|
18
|
+
LEXICAL = "lexical"
|
|
19
|
+
SEMANTIC = "semantic"
|
|
20
|
+
GRAPH = "graph" # refined to graph_call/graph_test/... in Phase 4
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RawHit:
|
|
25
|
+
symbol_id: int
|
|
26
|
+
score: float
|
|
27
|
+
# Optional richer reason (e.g. "graph_call"); defaults to the source label.
|
|
28
|
+
reason: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Candidate:
|
|
33
|
+
symbol_id: int
|
|
34
|
+
qualified_name: str
|
|
35
|
+
symbol_name: str
|
|
36
|
+
symbol_type: str
|
|
37
|
+
file_path: str
|
|
38
|
+
start_line: int
|
|
39
|
+
end_line: int
|
|
40
|
+
token_count: int
|
|
41
|
+
reasons: set[str] = field(default_factory=set)
|
|
42
|
+
source_ranks: dict[str, int] = field(default_factory=dict)
|
|
43
|
+
source_scores: dict[str, float] = field(default_factory=dict)
|
|
44
|
+
fused_score: float = 0.0
|
|
45
|
+
rerank_score: float | None = None # set when a reranker runs (Phase 9)
|
|
46
|
+
# Populated lazily by the context builder when code is actually needed.
|
|
47
|
+
signature: str | None = None
|
|
48
|
+
docstring: str | None = None
|
|
49
|
+
source_code: str | None = None
|
|
50
|
+
|
|
51
|
+
def explain(self) -> str:
|
|
52
|
+
return ",".join(sorted(self.reasons))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Retriever(Protocol):
|
|
56
|
+
source: str
|
|
57
|
+
|
|
58
|
+
def retrieve(
|
|
59
|
+
self, session: Session, repository_id: int, query: str, limit: int
|
|
60
|
+
) -> list[RawHit]: ...
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Hybrid retrieval engine.
|
|
2
|
+
|
|
3
|
+
Runs the configured retrievers, fuses them with RRF, (optionally, from Phase 4)
|
|
4
|
+
expands one graph hop, loads symbol metadata, and returns explainable
|
|
5
|
+
``Candidate``s. Every query is scoped to a single ``repository_id`` — there is no
|
|
6
|
+
retrieval path that ignores it (ADR-006).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
|
|
17
|
+
from coderag.core.config import Settings, get_settings
|
|
18
|
+
from coderag.db.models import Symbol
|
|
19
|
+
from coderag.retrieval.base import (
|
|
20
|
+
EXACT_SYMBOL,
|
|
21
|
+
GRAPH,
|
|
22
|
+
LEXICAL,
|
|
23
|
+
SEMANTIC,
|
|
24
|
+
Candidate,
|
|
25
|
+
RawHit,
|
|
26
|
+
Retriever,
|
|
27
|
+
)
|
|
28
|
+
from coderag.retrieval.fusion import reciprocal_rank_fusion
|
|
29
|
+
from coderag.retrieval.lexical import LexicalRetriever
|
|
30
|
+
from coderag.retrieval.symbol import SymbolRetriever
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RetrievalOutcome:
|
|
35
|
+
candidates: list[Candidate]
|
|
36
|
+
latency_ms: float
|
|
37
|
+
per_source_counts: dict[str, int]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RetrievalEngine:
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
retrievers: list[Retriever],
|
|
44
|
+
settings: Settings | None = None,
|
|
45
|
+
graph_expander=None,
|
|
46
|
+
reranker=None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.retrievers = retrievers
|
|
49
|
+
self.settings = settings or get_settings()
|
|
50
|
+
self.graph_expander = graph_expander # set in Phase 4
|
|
51
|
+
self.reranker = reranker # set in Phase 9 (optional)
|
|
52
|
+
|
|
53
|
+
def _weights(self) -> dict[str, float]:
|
|
54
|
+
s = self.settings
|
|
55
|
+
return {
|
|
56
|
+
EXACT_SYMBOL: s.weight_symbol,
|
|
57
|
+
LEXICAL: s.weight_lexical,
|
|
58
|
+
SEMANTIC: s.weight_semantic,
|
|
59
|
+
GRAPH: s.weight_graph,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def _source_limit(self, source: str) -> int:
|
|
63
|
+
s = self.settings
|
|
64
|
+
return {
|
|
65
|
+
EXACT_SYMBOL: s.symbol_limit,
|
|
66
|
+
LEXICAL: s.lexical_limit,
|
|
67
|
+
SEMANTIC: s.semantic_limit,
|
|
68
|
+
}.get(source, s.lexical_limit)
|
|
69
|
+
|
|
70
|
+
def search(
|
|
71
|
+
self, session: Session, repository_id: int, query: str, top_n: int | None = None
|
|
72
|
+
) -> RetrievalOutcome:
|
|
73
|
+
started = time.perf_counter()
|
|
74
|
+
source_hits: dict[str, list[RawHit]] = {}
|
|
75
|
+
per_source_counts: dict[str, int] = {}
|
|
76
|
+
for retriever in self.retrievers:
|
|
77
|
+
hits = retriever.retrieve(
|
|
78
|
+
session, repository_id, query, self._source_limit(retriever.source)
|
|
79
|
+
)
|
|
80
|
+
source_hits[retriever.source] = hits
|
|
81
|
+
per_source_counts[retriever.source] = len(hits)
|
|
82
|
+
|
|
83
|
+
fused = reciprocal_rank_fusion(source_hits, self._weights(), self.settings.rrf_k)
|
|
84
|
+
fused = fused[: self.settings.max_candidates]
|
|
85
|
+
|
|
86
|
+
candidates = self._load_candidates(session, repository_id, fused)
|
|
87
|
+
# deterministic order BEFORE expansion so graph seeds are the true top-N
|
|
88
|
+
candidates.sort(key=lambda c: (-c.fused_score, c.symbol_id))
|
|
89
|
+
|
|
90
|
+
# Phase 4: bounded one-hop graph expansion (added candidates with graph reasons).
|
|
91
|
+
if self.graph_expander is not None and self.settings.graph_enabled:
|
|
92
|
+
candidates = self.graph_expander.expand(
|
|
93
|
+
session, repository_id, candidates, query
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if self.reranker is not None:
|
|
97
|
+
candidates = self.reranker.rerank(session, repository_id, query, candidates)
|
|
98
|
+
else:
|
|
99
|
+
candidates.sort(key=lambda c: (-c.fused_score, c.symbol_id))
|
|
100
|
+
if top_n is not None:
|
|
101
|
+
candidates = candidates[:top_n]
|
|
102
|
+
latency_ms = (time.perf_counter() - started) * 1000
|
|
103
|
+
return RetrievalOutcome(
|
|
104
|
+
candidates=candidates,
|
|
105
|
+
latency_ms=latency_ms,
|
|
106
|
+
per_source_counts=per_source_counts,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _load_candidates(self, session, repository_id, fused) -> list[Candidate]:
|
|
110
|
+
if not fused:
|
|
111
|
+
return []
|
|
112
|
+
by_id = {e.symbol_id: e for e in fused}
|
|
113
|
+
rows = session.execute(
|
|
114
|
+
select(
|
|
115
|
+
Symbol.id, Symbol.qualified_name, Symbol.symbol_name, Symbol.symbol_type,
|
|
116
|
+
Symbol.file_path, Symbol.start_line, Symbol.end_line, Symbol.token_count,
|
|
117
|
+
Symbol.signature, Symbol.docstring,
|
|
118
|
+
)
|
|
119
|
+
# repository scope is enforced here as well as in each retriever.
|
|
120
|
+
.where(Symbol.repository_id == repository_id)
|
|
121
|
+
.where(Symbol.id.in_(list(by_id)))
|
|
122
|
+
).all()
|
|
123
|
+
candidates: list[Candidate] = []
|
|
124
|
+
for r in rows:
|
|
125
|
+
e = by_id[r.id]
|
|
126
|
+
candidates.append(
|
|
127
|
+
Candidate(
|
|
128
|
+
symbol_id=r.id,
|
|
129
|
+
qualified_name=r.qualified_name,
|
|
130
|
+
symbol_name=r.symbol_name,
|
|
131
|
+
symbol_type=r.symbol_type,
|
|
132
|
+
file_path=r.file_path,
|
|
133
|
+
start_line=r.start_line,
|
|
134
|
+
end_line=r.end_line,
|
|
135
|
+
token_count=r.token_count,
|
|
136
|
+
reasons=set(e.reasons),
|
|
137
|
+
source_ranks=dict(e.source_ranks),
|
|
138
|
+
source_scores=dict(e.source_scores),
|
|
139
|
+
fused_score=e.score,
|
|
140
|
+
signature=r.signature,
|
|
141
|
+
docstring=r.docstring,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return candidates
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_engine(
|
|
148
|
+
settings: Settings | None = None,
|
|
149
|
+
embedding_provider=None,
|
|
150
|
+
with_graph: bool = False,
|
|
151
|
+
with_reranker: bool = False,
|
|
152
|
+
) -> RetrievalEngine:
|
|
153
|
+
"""Construct an engine with the default retriever set.
|
|
154
|
+
|
|
155
|
+
The semantic retriever (Phase 3) is added when an embedding provider is
|
|
156
|
+
supplied; graph expansion (Phase 4) when ``with_graph`` is set.
|
|
157
|
+
"""
|
|
158
|
+
settings = settings or get_settings()
|
|
159
|
+
retrievers: list[Retriever] = [SymbolRetriever(), LexicalRetriever()]
|
|
160
|
+
if embedding_provider is not None:
|
|
161
|
+
from coderag.retrieval.semantic import SemanticRetriever
|
|
162
|
+
|
|
163
|
+
retrievers.append(SemanticRetriever(embedding_provider, settings))
|
|
164
|
+
graph_expander = None
|
|
165
|
+
if with_graph:
|
|
166
|
+
from coderag.retrieval.graph import GraphExpander
|
|
167
|
+
|
|
168
|
+
graph_expander = GraphExpander(settings)
|
|
169
|
+
reranker = None
|
|
170
|
+
if with_reranker or settings.reranker_enabled:
|
|
171
|
+
from coderag.retrieval.reranking import get_reranker
|
|
172
|
+
|
|
173
|
+
reranker = get_reranker(settings)
|
|
174
|
+
return RetrievalEngine(
|
|
175
|
+
retrievers, settings=settings, graph_expander=graph_expander, reranker=reranker
|
|
176
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Reciprocal Rank Fusion (RRF).
|
|
2
|
+
|
|
3
|
+
RRF fuses ranked lists from incomparable sources without score normalisation:
|
|
4
|
+
a hit at rank ``r`` (1-based) from a source contributes ``weight / (k + r)``.
|
|
5
|
+
Contributions are summed across sources. ``k`` and the per-source weights come
|
|
6
|
+
from configuration (ADR-003) — no magic constants here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from coderag.retrieval.base import RawHit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FusedEntry:
|
|
18
|
+
symbol_id: int
|
|
19
|
+
score: float = 0.0
|
|
20
|
+
reasons: set[str] = field(default_factory=set)
|
|
21
|
+
source_ranks: dict[str, int] = field(default_factory=dict)
|
|
22
|
+
source_scores: dict[str, float] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reciprocal_rank_fusion(
|
|
26
|
+
source_hits: dict[str, list[RawHit]],
|
|
27
|
+
weights: dict[str, float],
|
|
28
|
+
k: int = 60,
|
|
29
|
+
) -> list[FusedEntry]:
|
|
30
|
+
entries: dict[int, FusedEntry] = {}
|
|
31
|
+
for source, hits in source_hits.items():
|
|
32
|
+
weight = weights.get(source, 1.0)
|
|
33
|
+
for rank, hit in enumerate(hits, start=1):
|
|
34
|
+
entry = entries.setdefault(hit.symbol_id, FusedEntry(symbol_id=hit.symbol_id))
|
|
35
|
+
entry.score += weight / (k + rank)
|
|
36
|
+
entry.reasons.add(hit.reason or source)
|
|
37
|
+
# keep the best (lowest) rank per source
|
|
38
|
+
if source not in entry.source_ranks or rank < entry.source_ranks[source]:
|
|
39
|
+
entry.source_ranks[source] = rank
|
|
40
|
+
entry.source_scores[source] = hit.score
|
|
41
|
+
return sorted(entries.values(), key=lambda e: (-e.score, e.symbol_id))
|