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.
- synap_git/__init__.py +5 -0
- synap_git/api/__init__.py +5 -0
- synap_git/api/app.py +111 -0
- synap_git/api/static/index.html +615 -0
- synap_git/cli/__init__.py +3 -0
- synap_git/cli/__main__.py +4 -0
- synap_git/cli/main.py +1174 -0
- synap_git/config.py +169 -0
- synap_git/diagnostics/__init__.py +0 -0
- synap_git/diagnostics/logger.py +82 -0
- synap_git/diagnostics/tracing.py +71 -0
- synap_git/embeddings/__init__.py +0 -0
- synap_git/git/__init__.py +11 -0
- synap_git/git/state.py +139 -0
- synap_git/indexer/__init__.py +0 -0
- synap_git/indexer/daemon.py +143 -0
- synap_git/indexer/engine.py +327 -0
- synap_git/indexer/scanner.py +463 -0
- synap_git/indexer/wiki.py +78 -0
- synap_git/mcp/__init__.py +5 -0
- synap_git/mcp/server.py +232 -0
- synap_git/parser/__init__.py +0 -0
- synap_git/parser/registry.py +209 -0
- synap_git/provider/anthropic.py +86 -0
- synap_git/provider/base.py +44 -0
- synap_git/provider/factory.py +101 -0
- synap_git/provider/gemini.py +98 -0
- synap_git/provider/mock.py +66 -0
- synap_git/provider/ollama.py +98 -0
- synap_git/provider/openai.py +90 -0
- synap_git/py.typed +1 -0
- synap_git/retrieval/__init__.py +0 -0
- synap_git/retrieval/engine.py +258 -0
- synap_git/retrieval/memory.py +123 -0
- synap_git/storage/__init__.py +3 -0
- synap_git/storage/sqlite.py +575 -0
- synap_git/utils/__init__.py +0 -0
- synap_git/utils/serialization.py +68 -0
- synap_git-0.2.0.dist-info/METADATA +251 -0
- synap_git-0.2.0.dist-info/RECORD +43 -0
- synap_git-0.2.0.dist-info/WHEEL +4 -0
- synap_git-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|