synap-git 0.2.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 (43) hide show
  1. synap_git/__init__.py +5 -0
  2. synap_git/api/__init__.py +5 -0
  3. synap_git/api/app.py +111 -0
  4. synap_git/api/static/index.html +615 -0
  5. synap_git/cli/__init__.py +3 -0
  6. synap_git/cli/__main__.py +4 -0
  7. synap_git/cli/main.py +1174 -0
  8. synap_git/config.py +169 -0
  9. synap_git/diagnostics/__init__.py +0 -0
  10. synap_git/diagnostics/logger.py +82 -0
  11. synap_git/diagnostics/tracing.py +71 -0
  12. synap_git/embeddings/__init__.py +0 -0
  13. synap_git/git/__init__.py +11 -0
  14. synap_git/git/state.py +139 -0
  15. synap_git/indexer/__init__.py +0 -0
  16. synap_git/indexer/daemon.py +143 -0
  17. synap_git/indexer/engine.py +327 -0
  18. synap_git/indexer/scanner.py +463 -0
  19. synap_git/indexer/wiki.py +78 -0
  20. synap_git/mcp/__init__.py +5 -0
  21. synap_git/mcp/server.py +232 -0
  22. synap_git/parser/__init__.py +0 -0
  23. synap_git/parser/registry.py +209 -0
  24. synap_git/provider/anthropic.py +86 -0
  25. synap_git/provider/base.py +44 -0
  26. synap_git/provider/factory.py +101 -0
  27. synap_git/provider/gemini.py +98 -0
  28. synap_git/provider/mock.py +66 -0
  29. synap_git/provider/ollama.py +98 -0
  30. synap_git/provider/openai.py +90 -0
  31. synap_git/py.typed +1 -0
  32. synap_git/retrieval/__init__.py +0 -0
  33. synap_git/retrieval/engine.py +258 -0
  34. synap_git/retrieval/memory.py +123 -0
  35. synap_git/storage/__init__.py +3 -0
  36. synap_git/storage/sqlite.py +575 -0
  37. synap_git/utils/__init__.py +0 -0
  38. synap_git/utils/serialization.py +68 -0
  39. synap_git-0.2.0.dist-info/METADATA +251 -0
  40. synap_git-0.2.0.dist-info/RECORD +43 -0
  41. synap_git-0.2.0.dist-info/WHEEL +4 -0
  42. synap_git-0.2.0.dist-info/entry_points.txt +2 -0
  43. synap_git-0.2.0.dist-info/licenses/LICENSE.md +201 -0
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.request
5
+ from typing import Any
6
+
7
+ from synap_git.provider.base import LLMProvider, LLMResponse
8
+
9
+
10
+ class GeminiProvider(LLMProvider):
11
+ """Gemini API provider implementation."""
12
+
13
+ def __init__(self, api_key: str, default_model: str = "gemini-1.5-flash") -> None:
14
+ self.api_key = api_key
15
+ self.default_model = default_model
16
+
17
+ def generate(
18
+ self,
19
+ system_prompt: str,
20
+ user_prompt: str,
21
+ *,
22
+ model: str | None = None,
23
+ max_tokens: int | None = None,
24
+ temperature: float = 0.2,
25
+ ) -> LLMResponse:
26
+ model_name = model or self.default_model
27
+ # Strip models/ prefix if present
28
+ if model_name.startswith("models/"):
29
+ model_name = model_name[7:]
30
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={self.api_key}"
31
+ headers = {
32
+ "Content-Type": "application/json",
33
+ }
34
+ payload: dict[str, Any] = {
35
+ "contents": [
36
+ {
37
+ "role": "user",
38
+ "parts": [{"text": user_prompt}],
39
+ }
40
+ ],
41
+ "systemInstruction": {"parts": [{"text": system_prompt}]},
42
+ "generationConfig": {
43
+ "temperature": temperature,
44
+ },
45
+ }
46
+ if max_tokens:
47
+ payload["generationConfig"]["maxOutputTokens"] = max_tokens
48
+
49
+ req = urllib.request.Request(
50
+ url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST"
51
+ )
52
+ try:
53
+ with urllib.request.urlopen(req, timeout=30.0) as resp: # nosec B310
54
+ data = json.loads(resp.read().decode("utf-8"))
55
+ candidates = data.get("candidates", [])
56
+ if not candidates:
57
+ raise RuntimeError(f"Gemini API returned no candidates: {data}")
58
+ text_val = candidates[0]["content"]["parts"][0]["text"]
59
+ # Gemini does not always provide exact token counts in basic responses, or they might be in usageMetadata
60
+ usage = data.get("usageMetadata", {})
61
+ return LLMResponse(
62
+ content=str(text_val),
63
+ prompt_tokens=int(usage.get("promptTokenCount", 0)),
64
+ completion_tokens=int(usage.get("candidatesTokenCount", 0)),
65
+ )
66
+ except Exception as exc:
67
+ raise RuntimeError(f"Gemini generate failed: {exc}") from exc
68
+
69
+ def embed(
70
+ self,
71
+ text: str,
72
+ *,
73
+ model: str | None = None,
74
+ ) -> list[float]:
75
+ model_name = model or "text-embedding-004"
76
+ if not model_name.startswith("models/"):
77
+ model_name = f"models/{model_name}"
78
+ url = f"https://generativelanguage.googleapis.com/v1beta/{model_name}:embedContent?key={self.api_key}"
79
+ headers = {
80
+ "Content-Type": "application/json",
81
+ }
82
+ payload = {"model": model_name, "content": {"parts": [{"text": text}]}}
83
+ req = urllib.request.Request(
84
+ url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST"
85
+ )
86
+ try:
87
+ with urllib.request.urlopen(req, timeout=30.0) as resp: # nosec B310
88
+ data = json.loads(resp.read().decode("utf-8"))
89
+ embedding = data["embedding"]["values"]
90
+ return [float(x) for x in embedding]
91
+ except Exception as exc:
92
+ raise RuntimeError(f"Gemini embed failed: {exc}") from exc
93
+
94
+ def count_tokens(self, text: str) -> int:
95
+ import tiktoken
96
+
97
+ encoding = tiktoken.get_encoding("cl100k_base")
98
+ return len(encoding.encode(text, disallowed_special=()))
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+
5
+ from synap_git.provider.base import LLMProvider, LLMResponse
6
+
7
+
8
+ class MockLLMProvider(LLMProvider):
9
+ """Mock LLM provider returning deterministic replies."""
10
+
11
+ def __init__(self, preset_replies: dict[str, str] | None = None) -> None:
12
+ self.preset_replies = preset_replies or {}
13
+
14
+ def generate(
15
+ self,
16
+ system_prompt: str,
17
+ user_prompt: str,
18
+ *,
19
+ model: str | None = None,
20
+ max_tokens: int | None = None,
21
+ temperature: float = 0.2,
22
+ ) -> LLMResponse:
23
+ _ = (model, max_tokens, temperature)
24
+ # Match preset replies if any substring matches
25
+ combined = f"{system_prompt}\n{user_prompt}".lower()
26
+ for pattern, reply in self.preset_replies.items():
27
+ if pattern.lower() in combined:
28
+ return LLMResponse(content=reply, prompt_tokens=10, completion_tokens=10)
29
+
30
+ # Default fallback responses based on keywords in prompt
31
+ if "explain" in combined or "how does" in combined:
32
+ content = (
33
+ "Mock Explanation: This subsystem handles core state management. "
34
+ "It uses an append-only event log stored in SQLite and serializes active graph nodes."
35
+ )
36
+ elif "overlay" in combined or "annotate" in combined:
37
+ content = "Mock Overlay: This module is stable and optimized. It implements caching to prevent graph explosion."
38
+ else:
39
+ content = f"Mock LLM generated response for query. Model: {model or 'mock-model'}."
40
+
41
+ return LLMResponse(
42
+ content=content,
43
+ prompt_tokens=20,
44
+ completion_tokens=30,
45
+ )
46
+
47
+ def embed(
48
+ self,
49
+ text: str,
50
+ *,
51
+ model: str | None = None,
52
+ ) -> list[float]:
53
+ _ = model
54
+ # Deterministic pseudo-embedding based on string md5 hash
55
+ hasher = hashlib.md5(text.encode("utf-8"), usedforsecurity=False) # noqa: S324
56
+ digest = hasher.digest()
57
+ embedding = []
58
+ for i in range(16):
59
+ # Normalize to [-1.0, 1.0] range
60
+ val = (digest[i] - 128.0) / 128.0
61
+ embedding.append(val)
62
+ # Pad or repeat to match embedding dimensions (e.g. 128 dimensions)
63
+ return embedding * 8
64
+
65
+ def count_tokens(self, text: str) -> int:
66
+ return len(text.split())
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.request
5
+ from typing import Any
6
+
7
+ from synap_git.provider.base import LLMProvider, LLMResponse
8
+
9
+
10
+ class OllamaProvider(LLMProvider):
11
+ """Ollama API provider implementation for local running models."""
12
+
13
+ def __init__(
14
+ self, base_url: str = "http://127.0.0.1:11434", default_model: str = "llama3"
15
+ ) -> None:
16
+ self.base_url = base_url.rstrip("/")
17
+ self.default_model = default_model
18
+
19
+ def generate(
20
+ self,
21
+ system_prompt: str,
22
+ user_prompt: str,
23
+ *,
24
+ model: str | None = None,
25
+ max_tokens: int | None = None,
26
+ temperature: float = 0.2,
27
+ ) -> LLMResponse:
28
+ model_name = model or self.default_model
29
+ url = f"{self.base_url}/api/chat"
30
+ headers = {
31
+ "Content-Type": "application/json",
32
+ }
33
+ payload: dict[str, Any] = {
34
+ "model": model_name,
35
+ "messages": [
36
+ {"role": "system", "content": system_prompt},
37
+ {"role": "user", "content": user_prompt},
38
+ ],
39
+ "options": {
40
+ "temperature": temperature,
41
+ },
42
+ "stream": False,
43
+ }
44
+ if max_tokens:
45
+ payload["options"]["num_predict"] = max_tokens
46
+
47
+ req = urllib.request.Request(
48
+ url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST"
49
+ )
50
+ try:
51
+ with urllib.request.urlopen(req, timeout=300.0) as resp: # nosec B310
52
+ data = json.loads(resp.read().decode("utf-8"))
53
+ message = data["message"]
54
+ # Ollama provides prompt_eval_count and eval_count
55
+ prompt_tokens = int(data.get("prompt_eval_count", 0))
56
+ completion_tokens = int(data.get("eval_count", 0))
57
+ return LLMResponse(
58
+ content=str(message["content"]),
59
+ prompt_tokens=prompt_tokens,
60
+ completion_tokens=completion_tokens,
61
+ )
62
+ except urllib.error.HTTPError as exc:
63
+ err_body = exc.read().decode("utf-8")
64
+ raise RuntimeError(f"Ollama generate HTTP error {exc.code}: {err_body}") from exc
65
+ except Exception as exc:
66
+ raise RuntimeError(f"Ollama generate failed: {exc}") from exc
67
+
68
+ def embed(
69
+ self,
70
+ text: str,
71
+ *,
72
+ model: str | None = None,
73
+ ) -> list[float]:
74
+ model_name = model or self.default_model
75
+ url = f"{self.base_url}/api/embeddings"
76
+ headers = {
77
+ "Content-Type": "application/json",
78
+ }
79
+ payload = {"model": model_name, "prompt": text}
80
+ req = urllib.request.Request(
81
+ url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST"
82
+ )
83
+ try:
84
+ with urllib.request.urlopen(req, timeout=60.0) as resp: # nosec B310
85
+ data = json.loads(resp.read().decode("utf-8"))
86
+ embedding = data["embedding"]
87
+ return [float(x) for x in embedding]
88
+ except urllib.error.HTTPError as exc:
89
+ err_body = exc.read().decode("utf-8")
90
+ raise RuntimeError(f"Ollama embed HTTP error {exc.code}: {err_body}") from exc
91
+ except Exception as exc:
92
+ raise RuntimeError(f"Ollama embed failed: {exc}") from exc
93
+
94
+ def count_tokens(self, text: str) -> int:
95
+ import tiktoken
96
+
97
+ encoding = tiktoken.get_encoding("cl100k_base")
98
+ return len(encoding.encode(text, disallowed_special=()))
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.request
5
+ from typing import Any
6
+
7
+ from synap_git.provider.base import LLMProvider, LLMResponse
8
+
9
+
10
+ class OpenAIProvider(LLMProvider):
11
+ """OpenAI API provider implementation."""
12
+
13
+ def __init__(self, api_key: str, default_model: str = "gpt-4o-mini") -> None:
14
+ self.api_key = api_key
15
+ self.default_model = default_model
16
+
17
+ def generate(
18
+ self,
19
+ system_prompt: str,
20
+ user_prompt: str,
21
+ *,
22
+ model: str | None = None,
23
+ max_tokens: int | None = None,
24
+ temperature: float = 0.2,
25
+ ) -> LLMResponse:
26
+ model_name = model or self.default_model
27
+ url = "https://api.openai.com/v1/chat/completions"
28
+ headers = {
29
+ "Content-Type": "application/json",
30
+ "Authorization": f"Bearer {self.api_key}",
31
+ }
32
+ payload: dict[str, Any] = {
33
+ "model": model_name,
34
+ "messages": [
35
+ {"role": "system", "content": system_prompt},
36
+ {"role": "user", "content": user_prompt},
37
+ ],
38
+ "temperature": temperature,
39
+ }
40
+ if max_tokens:
41
+ payload["max_tokens"] = max_tokens
42
+
43
+ req = urllib.request.Request( # noqa: S310
44
+ url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST"
45
+ )
46
+ try:
47
+ with urllib.request.urlopen(req, timeout=30.0) as resp: # nosec B310
48
+ data = json.loads(resp.read().decode("utf-8"))
49
+ choice = data["choices"][0]["message"]
50
+ usage = data.get("usage", {})
51
+ return LLMResponse(
52
+ content=str(choice["content"]),
53
+ prompt_tokens=int(usage.get("prompt_tokens", 0)),
54
+ completion_tokens=int(usage.get("completion_tokens", 0)),
55
+ )
56
+ except Exception as exc:
57
+ raise RuntimeError(f"OpenAI generate failed: {exc}") from exc
58
+
59
+ def embed(
60
+ self,
61
+ text: str,
62
+ *,
63
+ model: str | None = None,
64
+ ) -> list[float]:
65
+ model_name = model or "text-embedding-3-small"
66
+ url = "https://api.openai.com/v1/embeddings"
67
+ headers = {
68
+ "Content-Type": "application/json",
69
+ "Authorization": f"Bearer {self.api_key}",
70
+ }
71
+ payload = {"input": text, "model": model_name}
72
+ req = urllib.request.Request( # noqa: S310
73
+ url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST"
74
+ )
75
+ try:
76
+ with urllib.request.urlopen(req, timeout=30.0) as resp: # nosec B310
77
+ data = json.loads(resp.read().decode("utf-8"))
78
+ embedding = data["data"][0]["embedding"]
79
+ return [float(x) for x in embedding]
80
+ except Exception as exc:
81
+ raise RuntimeError(f"OpenAI embed failed: {exc}") from exc
82
+
83
+ def count_tokens(self, text: str) -> int:
84
+ import tiktoken
85
+
86
+ try:
87
+ encoding = tiktoken.encoding_for_model(self.default_model)
88
+ except KeyError:
89
+ encoding = tiktoken.get_encoding("cl100k_base")
90
+ return len(encoding.encode(text, disallowed_special=()))
synap_git/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561 compliance
File without changes
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import UTC, datetime
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ import tiktoken
9
+
10
+ from synap_git.provider.base import LLMProvider
11
+ from synap_git.storage.sqlite import SynapStore
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TraceElement:
16
+ stable_id: str
17
+ name: str
18
+ path: str
19
+ score: float
20
+ reason: str
21
+ tokens: int
22
+
23
+
24
+ class HybridRetrievalEngine:
25
+ """Production-grade hybrid retrieval with exact token budgeting and traces."""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ store: SynapStore,
31
+ llm_provider: LLMProvider | None,
32
+ trace_store: Any | None = None,
33
+ ) -> None:
34
+ self.store = store
35
+ self.llm_provider = llm_provider
36
+ # Use tiktoken for exact budgeting
37
+ self.tokenizer = tiktoken.get_encoding("cl100k_base")
38
+ self.max_expansion_depth = 2
39
+ self.trace_store = trace_store
40
+
41
+ def retrieve(
42
+ self,
43
+ query: str,
44
+ *,
45
+ max_tokens: int = 4000,
46
+ is_dirty: bool = False,
47
+ ) -> tuple[str, list[dict[str, Any]], dict[str, Any]]:
48
+ """Perform deterministic 4-stage retrieval: Temporal -> Structural -> Lexical -> Semantic."""
49
+ import time
50
+
51
+ t_start = time.perf_counter()
52
+ trace_id = str(uuid4())
53
+ start_time = datetime.now(UTC)
54
+
55
+ # 1. Temporal Filter: Handled by SynapStore methods which operate on current state
56
+ # (In a more advanced version, we'd filter by specific commit/branch here)
57
+
58
+ # 2. & 3. Lexical + Structural
59
+ query_words = {word.lower().strip() for word in query.split() if len(word) > 2}
60
+
61
+ # Start with Lexical matches
62
+ lexical_candidates: dict[str, dict[str, Any]] = {}
63
+ for word in query_words:
64
+ symbols = self.store.get_symbols_by_name(word)
65
+ for sym in symbols:
66
+ sid = sym["symbol_id"]
67
+ if sid not in lexical_candidates:
68
+ lexical_candidates[sid] = {**sym, "reason": f"lexical:'{word}'"}
69
+
70
+ t_lexical = time.perf_counter()
71
+
72
+ # Expand to Structural neighborhood
73
+ structural_candidates: dict[str, dict[str, Any]] = {}
74
+ structural_hops = []
75
+ for sid in list(lexical_candidates.keys()):
76
+ neighbors = self.store.get_neighborhood(sid, depth=self.max_expansion_depth)
77
+ for n in neighbors:
78
+ nid = n["symbol_id"]
79
+ dist = n.get("distance", 0)
80
+ structural_hops.append(
81
+ {
82
+ "from_symbol": lexical_candidates[sid]["name"],
83
+ "to_symbol": n["name"],
84
+ "distance": dist,
85
+ }
86
+ )
87
+ if nid not in structural_candidates:
88
+ structural_candidates[nid] = {**n, "reason": f"structural:dist={dist}"}
89
+
90
+ t_structural = time.perf_counter()
91
+
92
+ # Combine and Rank
93
+ combined = {**structural_candidates, **lexical_candidates}
94
+
95
+ # 4. Semantic Ranking (simplified for recovery)
96
+ ranked = self._rank_candidates(query, list(combined.values()))
97
+
98
+ t_ranking = time.perf_counter()
99
+
100
+ # Context Packing with exact token budgeting
101
+ packed_blocks = []
102
+ grounding_sources = []
103
+ trace_elements = []
104
+ tokens_used = 0
105
+
106
+ # Reserve buffer for system/user prompts
107
+ reserved_buffer = 600
108
+ retrieval_budget = max_tokens - reserved_buffer
109
+
110
+ for cand, score in ranked:
111
+ path = cand["source_path"]
112
+ name = cand["name"]
113
+ kind = cand["kind"]
114
+ lines = f"{cand['start_line']}-{cand['end_line']}"
115
+ reason = cand["reason"]
116
+
117
+ block = f"### File: {path}\nSymbol: {name} ({kind})\nLines: {lines}\nReason: {reason}\n"
118
+ block_tokens = len(self.tokenizer.encode(block))
119
+
120
+ if tokens_used + block_tokens > retrieval_budget:
121
+ remaining = retrieval_budget - tokens_used
122
+ trace_elements.append(
123
+ TraceElement(
124
+ stable_id=cand["symbol_id"],
125
+ name=name,
126
+ path=path,
127
+ score=score,
128
+ reason=f"truncated:over_budget. Requires {block_tokens} tokens, but only {remaining} remain.",
129
+ tokens=block_tokens,
130
+ )
131
+ )
132
+ continue
133
+
134
+ packed_blocks.append(block)
135
+ grounding_sources.append(cand)
136
+ tokens_used += block_tokens
137
+
138
+ trace_elements.append(
139
+ TraceElement(
140
+ stable_id=cand["symbol_id"],
141
+ name=name,
142
+ path=path,
143
+ score=score,
144
+ reason=reason,
145
+ tokens=block_tokens,
146
+ )
147
+ )
148
+
149
+ context_str = "\n".join(packed_blocks)
150
+
151
+ # Gating: Only approved lessons affect retrieval
152
+ approved_lessons = self.store.get_lessons("approved")
153
+ if approved_lessons:
154
+ lesson_blocks = ["# APPROVED SYSTEM MEMORY (CRITICAL: MUST ADHERE)"]
155
+ for i, lesson in enumerate(approved_lessons, 1):
156
+ lesson_blocks.append(
157
+ f"[{i}] DO NOT DO THIS: {lesson['what_failed']} - BECAUSE: {lesson['why_failed']}"
158
+ )
159
+ context_str = "\n".join(lesson_blocks) + "\n\n" + context_str
160
+
161
+ system_prompt = (
162
+ "You are an AI coding assistant with deep structural repository understanding.\n"
163
+ "Answer the user query based ONLY on the provided grounded structural context.\n"
164
+ "Cite source files and symbol names in your explanation.\n"
165
+ )
166
+ user_prompt = f"Repository Context:\n{context_str}\n\nUser Question: {query}\n"
167
+
168
+ t_packing = time.perf_counter()
169
+
170
+ if self.llm_provider:
171
+ response = self.llm_provider.generate(
172
+ system_prompt=system_prompt,
173
+ user_prompt=user_prompt,
174
+ temperature=0.1,
175
+ )
176
+ answer_content = response.content
177
+ try:
178
+ provider_name = self.llm_provider.__class__.__name__.replace("Provider", "").lower()
179
+ model_name = getattr(self.llm_provider, "default_model", "unknown")
180
+ self.store.put_llm_call(
181
+ provider=provider_name,
182
+ model=model_name,
183
+ input_tokens=response.prompt_tokens,
184
+ output_tokens=response.completion_tokens,
185
+ purpose="retrieval",
186
+ )
187
+ except Exception:
188
+ pass
189
+ else:
190
+ answer_content = (
191
+ "Mode A (Structural Only): Context retrieved, but LLM generation is disabled."
192
+ )
193
+
194
+ t_llm = time.perf_counter()
195
+
196
+ token_allocation = {
197
+ "max_tokens": max_tokens,
198
+ "reserved_buffer": reserved_buffer,
199
+ "retrieval_budget": retrieval_budget,
200
+ "tokens_used": tokens_used,
201
+ "remaining": retrieval_budget - tokens_used,
202
+ }
203
+
204
+ timeline = {
205
+ "lexical_search_ms": (t_lexical - t_start) * 1000,
206
+ "structural_expansion_ms": (t_structural - t_lexical) * 1000,
207
+ "semantic_ranking_ms": (t_ranking - t_structural) * 1000,
208
+ "context_packing_ms": (t_packing - t_ranking) * 1000,
209
+ "llm_generation_ms": (t_llm - t_packing) * 1000,
210
+ "total_ms": (t_llm - t_start) * 1000,
211
+ }
212
+
213
+ trace = {
214
+ "trace_id": trace_id,
215
+ "query": query,
216
+ "nodes_explored": len(combined),
217
+ "tokens_used": tokens_used,
218
+ "token_allocation": token_allocation,
219
+ "timeline": timeline,
220
+ "elements": [e.__dict__ for e in trace_elements[:40]],
221
+ "structural_hops": structural_hops[:20],
222
+ "dirty_tree_warning": is_dirty,
223
+ "latency_ms": (t_llm - t_start) * 1000,
224
+ }
225
+
226
+ if self.trace_store:
227
+ self.trace_store.record_trace(
228
+ trace_type="retrieval",
229
+ summary=f"Retrieval for query: {query}",
230
+ details=trace,
231
+ )
232
+
233
+ return answer_content, grounding_sources, trace
234
+
235
+ def _rank_candidates(
236
+ self, query: str, candidates: list[dict[str, Any]]
237
+ ) -> list[tuple[dict[str, Any], float]]:
238
+ """Rank based on priority: Lexical > Structural > Distance."""
239
+ ranked = []
240
+ for cand in candidates:
241
+ # Base score from reason
242
+ reason = cand["reason"]
243
+ if reason.startswith("lexical"):
244
+ score = 1.0
245
+ elif reason.startswith("structural"):
246
+ dist = int(reason.split("=")[-1])
247
+ score = 0.8**dist
248
+ else:
249
+ score = 0.5
250
+
251
+ # Name match boost
252
+ if any(w in cand["name"].lower() for w in query.lower().split()):
253
+ score += 0.2
254
+
255
+ ranked.append((cand, score))
256
+
257
+ ranked.sort(key=lambda x: x[1], reverse=True)
258
+ return ranked