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.
Files changed (73) hide show
  1. coderag/__init__.py +12 -0
  2. coderag/analyzers/__init__.py +0 -0
  3. coderag/analyzers/base.py +36 -0
  4. coderag/analyzers/flake8_adapter.py +43 -0
  5. coderag/analyzers/pylint_adapter.py +50 -0
  6. coderag/analyzers/workflow.py +107 -0
  7. coderag/api/__init__.py +0 -0
  8. coderag/api/app.py +351 -0
  9. coderag/api/dashboard.py +237 -0
  10. coderag/api/schemas.py +181 -0
  11. coderag/cli/__init__.py +0 -0
  12. coderag/cli/main.py +359 -0
  13. coderag/context/__init__.py +0 -0
  14. coderag/context/builder.py +206 -0
  15. coderag/context/package.py +100 -0
  16. coderag/core/__init__.py +0 -0
  17. coderag/core/config.py +100 -0
  18. coderag/core/logging.py +58 -0
  19. coderag/core/text.py +80 -0
  20. coderag/core/tokens.py +66 -0
  21. coderag/db/__init__.py +0 -0
  22. coderag/db/base.py +58 -0
  23. coderag/db/migrations/env.py +77 -0
  24. coderag/db/migrations/script.py.mako +26 -0
  25. coderag/db/migrations/versions/.gitkeep +0 -0
  26. coderag/db/migrations/versions/9a973932b94d_measure_baseline_tokens.py +41 -0
  27. coderag/db/migrations/versions/c8a40feeda41_initial_schema.py +227 -0
  28. coderag/db/models.py +275 -0
  29. coderag/embeddings/__init__.py +0 -0
  30. coderag/embeddings/base.py +53 -0
  31. coderag/embeddings/hashing.py +43 -0
  32. coderag/embeddings/pipeline.py +87 -0
  33. coderag/embeddings/registry.py +38 -0
  34. coderag/embeddings/sentence_transformer.py +47 -0
  35. coderag/evaluation/__init__.py +0 -0
  36. coderag/evaluation/datasets.py +36 -0
  37. coderag/evaluation/harness.py +246 -0
  38. coderag/git/__init__.py +0 -0
  39. coderag/git/repo.py +139 -0
  40. coderag/indexing/__init__.py +0 -0
  41. coderag/indexing/ignore.py +101 -0
  42. coderag/indexing/indexer.py +445 -0
  43. coderag/llm/__init__.py +0 -0
  44. coderag/llm/anthropic.py +117 -0
  45. coderag/llm/base.py +60 -0
  46. coderag/llm/null.py +16 -0
  47. coderag/llm/registry.py +17 -0
  48. coderag/localdb.py +100 -0
  49. coderag/mcp_server.py +209 -0
  50. coderag/parsing/__init__.py +0 -0
  51. coderag/parsing/base.py +76 -0
  52. coderag/parsing/python.py +294 -0
  53. coderag/parsing/registry.py +40 -0
  54. coderag/retrieval/__init__.py +0 -0
  55. coderag/retrieval/base.py +60 -0
  56. coderag/retrieval/engine.py +176 -0
  57. coderag/retrieval/fusion.py +41 -0
  58. coderag/retrieval/graph.py +139 -0
  59. coderag/retrieval/lexical.py +57 -0
  60. coderag/retrieval/reranking.py +99 -0
  61. coderag/retrieval/semantic.py +53 -0
  62. coderag/retrieval/symbol.py +91 -0
  63. coderag/security/__init__.py +0 -0
  64. coderag/security/authz.py +35 -0
  65. coderag/security/secrets.py +110 -0
  66. coderag/service.py +160 -0
  67. coderag/telemetry.py +72 -0
  68. coderag_ai-0.3.0.dist-info/METADATA +364 -0
  69. coderag_ai-0.3.0.dist-info/RECORD +73 -0
  70. coderag_ai-0.3.0.dist-info/WHEEL +4 -0
  71. coderag_ai-0.3.0.dist-info/entry_points.txt +3 -0
  72. coderag_ai-0.3.0.dist-info/licenses/LICENSE +202 -0
  73. 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))