synapse-framework 0.2.4__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.
- synapse/__init__.py +207 -0
- synapse/app.py +942 -0
- synapse/cli.py +88 -0
- synapse/config.py +103 -0
- synapse/connectors/__init__.py +29 -0
- synapse/connectors/base.py +222 -0
- synapse/connectors/factory.py +202 -0
- synapse/connectors/gmail.py +292 -0
- synapse/connectors/mcp_wrapper.py +153 -0
- synapse/connectors/notion.py +161 -0
- synapse/connectors/photos.py +141 -0
- synapse/database/__init__.py +19 -0
- synapse/database/base.py +63 -0
- synapse/database/chroma.py +175 -0
- synapse/database/memory.py +132 -0
- synapse/database/pgvector.py +99 -0
- synapse/database/qdrant.py +138 -0
- synapse/embeddings/__init__.py +21 -0
- synapse/embeddings/base.py +262 -0
- synapse/export/__init__.py +14 -0
- synapse/export/mcp_exporter.py +202 -0
- synapse/graph/__init__.py +21 -0
- synapse/graph/base.py +130 -0
- synapse/graph/memory.py +20 -0
- synapse/graph/neo4j_store.py +176 -0
- synapse/graph/networkx_store.py +221 -0
- synapse/inspector/__init__.py +5 -0
- synapse/inspector/template.py +530 -0
- synapse/models/__init__.py +73 -0
- synapse/models/base.py +95 -0
- synapse/models/gemini.py +328 -0
- synapse/models/ollama.py +322 -0
- synapse/models/openai.py +289 -0
- synapse/oauth/__init__.py +7 -0
- synapse/oauth/google.py +194 -0
- synapse/oauth/notion.py +35 -0
- synapse/rag/__init__.py +8 -0
- synapse/rag/chunker.py +219 -0
- synapse/rag/hybrid_rag.py +122 -0
- synapse/resource/__init__.py +5 -0
- synapse/resource/resource.py +304 -0
- synapse/resources/__init__.py +5 -0
- synapse/skills/__init__.py +5 -0
- synapse/skills/skill.py +321 -0
- synapse/storage/__init__.py +24 -0
- synapse/storage/graph_exporter.py +624 -0
- synapse/storage/graph_store.py +162 -0
- synapse/storage/vector_store.py +103 -0
- synapse_framework-0.2.4.dist-info/METADATA +415 -0
- synapse_framework-0.2.4.dist-info/RECORD +54 -0
- synapse_framework-0.2.4.dist-info/WHEEL +5 -0
- synapse_framework-0.2.4.dist-info/entry_points.txt +2 -0
- synapse_framework-0.2.4.dist-info/licenses/LICENSE +21 -0
- synapse_framework-0.2.4.dist-info/top_level.txt +1 -0
synapse/models/gemini.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Google Gemini API model provider for Synapse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import AsyncGenerator
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from synapse.config import DEFAULT_GEMINI_MODEL, DEFAULT_TEMPERATURE
|
|
15
|
+
from synapse.models.base import BaseModelProvider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GeminiModel(BaseModelProvider):
|
|
19
|
+
"""Google Gemini language model provider (e.g. gemini-3.5-flash, gemini-flash-latest).
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
api_key: Google AI Studio API key (reads from GEMINI_API_KEY if omitted).
|
|
23
|
+
model_name: Model identifier (default: 'gemini-3.5-flash').
|
|
24
|
+
temperature: Default sampling temperature.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: str | None = None,
|
|
30
|
+
model: str = DEFAULT_GEMINI_MODEL,
|
|
31
|
+
temperature: float = DEFAULT_TEMPERATURE,
|
|
32
|
+
timeout: float = 60.0,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Initializes the Gemini model provider.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
api_key: Google Gemini API key. If None, reads from GEMINI_API_KEY.
|
|
38
|
+
model: Gemini model identifier. Defaults to DEFAULT_GEMINI_MODEL ('gemini-3.5-flash').
|
|
39
|
+
temperature: Sampling temperature. Defaults to 0.1.
|
|
40
|
+
timeout: HTTP request timeout in seconds. Defaults to 60.0.
|
|
41
|
+
"""
|
|
42
|
+
super().__init__(model_name=model, temperature=temperature)
|
|
43
|
+
self.api_key = api_key or os.getenv("GEMINI_API_KEY", "")
|
|
44
|
+
self.base_url = "https://generativelanguage.googleapis.com/v1beta/models"
|
|
45
|
+
self.timeout = timeout
|
|
46
|
+
|
|
47
|
+
def _convert_messages(
|
|
48
|
+
self, messages: list[dict[str, str]]
|
|
49
|
+
) -> tuple[str | None, list[dict[str, Any]]]:
|
|
50
|
+
system_instruction: str | None = None
|
|
51
|
+
contents: list[dict[str, Any]] = []
|
|
52
|
+
|
|
53
|
+
for msg in messages:
|
|
54
|
+
role = msg.get("role", "user")
|
|
55
|
+
content = msg.get("content", "")
|
|
56
|
+
if role == "system":
|
|
57
|
+
system_instruction = content
|
|
58
|
+
else:
|
|
59
|
+
gemini_role = "model" if role == "assistant" else "user"
|
|
60
|
+
contents.append({
|
|
61
|
+
"role": gemini_role,
|
|
62
|
+
"parts": [{"text": content}],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return system_instruction, contents
|
|
66
|
+
|
|
67
|
+
async def chat_stream(
|
|
68
|
+
self,
|
|
69
|
+
messages: list[dict[str, str]],
|
|
70
|
+
temperature: float | None = None,
|
|
71
|
+
) -> AsyncGenerator[str, None]:
|
|
72
|
+
"""Streams response tokens from Google Gemini via streamGenerateContent."""
|
|
73
|
+
temp = self.temperature if temperature is None else temperature
|
|
74
|
+
sys_inst, contents = self._convert_messages(messages)
|
|
75
|
+
|
|
76
|
+
payload: dict[str, Any] = {
|
|
77
|
+
"contents": contents,
|
|
78
|
+
"generationConfig": {"temperature": temp},
|
|
79
|
+
}
|
|
80
|
+
if sys_inst:
|
|
81
|
+
payload["systemInstruction"] = {"parts": [{"text": sys_inst}]}
|
|
82
|
+
|
|
83
|
+
url = f"{self.base_url}/{self.model_name}:streamGenerateContent?key={self.api_key}&alt=sse"
|
|
84
|
+
|
|
85
|
+
for attempt in range(4):
|
|
86
|
+
try:
|
|
87
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
88
|
+
async with client.stream("POST", url, json=payload) as stream:
|
|
89
|
+
if stream.status_code in (400, 401, 403):
|
|
90
|
+
yield "Gemini authentication failed: Please verify your Google Gemini API key."
|
|
91
|
+
return
|
|
92
|
+
if stream.status_code in (503, 429) and attempt < 3:
|
|
93
|
+
await asyncio.sleep(1.0 * (attempt + 1))
|
|
94
|
+
continue
|
|
95
|
+
if stream.status_code != 200:
|
|
96
|
+
err_body = await stream.aread()
|
|
97
|
+
err_msg = ""
|
|
98
|
+
try:
|
|
99
|
+
err_data = json.loads(err_body.decode("utf-8", errors="replace"))
|
|
100
|
+
err_msg = err_data.get("error", {}).get("message", "")
|
|
101
|
+
except Exception:
|
|
102
|
+
pass # nosec B110
|
|
103
|
+
if stream.status_code == 429:
|
|
104
|
+
yield "⚠️ Google Gemini Rate Limit (HTTP 429): Free tier limit of 5 requests/minute reached. Please wait ~30 seconds for the quota window to reset, or check your quota at https://ai.google.dev."
|
|
105
|
+
return
|
|
106
|
+
yield f"Gemini API returned HTTP {stream.status_code}: {err_msg or 'Request failed'}."
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
async for line in stream.aiter_lines():
|
|
110
|
+
if not line or not line.startswith("data: "):
|
|
111
|
+
continue
|
|
112
|
+
data_str = line[6:].strip()
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(data_str)
|
|
115
|
+
candidates = data.get("candidates", [])
|
|
116
|
+
if candidates:
|
|
117
|
+
parts = (
|
|
118
|
+
candidates[0]
|
|
119
|
+
.get("content", {})
|
|
120
|
+
.get("parts", [])
|
|
121
|
+
)
|
|
122
|
+
for part in parts:
|
|
123
|
+
text = part.get("text", "")
|
|
124
|
+
if text:
|
|
125
|
+
yield text
|
|
126
|
+
except json.JSONDecodeError:
|
|
127
|
+
continue
|
|
128
|
+
return
|
|
129
|
+
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
130
|
+
if attempt < 2:
|
|
131
|
+
await asyncio.sleep(0.6)
|
|
132
|
+
continue
|
|
133
|
+
yield "Cannot connect to Gemini API endpoint. Please check your internet connection."
|
|
134
|
+
return
|
|
135
|
+
except Exception as e:
|
|
136
|
+
if attempt < 2:
|
|
137
|
+
await asyncio.sleep(0.6)
|
|
138
|
+
continue
|
|
139
|
+
yield f"Gemini error: {e}"
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
async def generate(
|
|
143
|
+
self,
|
|
144
|
+
prompt: str,
|
|
145
|
+
temperature: float | None = None,
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Generates a non-streaming response for a prompt string."""
|
|
148
|
+
temp = self.temperature if temperature is None else temperature
|
|
149
|
+
payload: dict[str, Any] = {
|
|
150
|
+
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
|
151
|
+
"generationConfig": {"temperature": temp},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
url = f"{self.base_url}/{self.model_name}:generateContent?key={self.api_key}"
|
|
155
|
+
|
|
156
|
+
for attempt in range(4):
|
|
157
|
+
try:
|
|
158
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
159
|
+
res = await client.post(url, json=payload)
|
|
160
|
+
if res.status_code in (503, 429) and attempt < 3:
|
|
161
|
+
await asyncio.sleep(1.0 * (attempt + 1))
|
|
162
|
+
continue
|
|
163
|
+
res.raise_for_status()
|
|
164
|
+
data = res.json()
|
|
165
|
+
candidates = data.get("candidates", [])
|
|
166
|
+
if candidates:
|
|
167
|
+
parts = candidates[0].get("content", {}).get("parts", [])
|
|
168
|
+
return "".join(p.get("text", "") for p in parts).strip()
|
|
169
|
+
return ""
|
|
170
|
+
except Exception as e:
|
|
171
|
+
if attempt < 3:
|
|
172
|
+
await asyncio.sleep(1.0)
|
|
173
|
+
continue
|
|
174
|
+
return f"[Error from Gemini: {e}]"
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def extract_triples(self, text: str) -> list[dict[str, str]]:
|
|
179
|
+
"""Extracts relational triples using Gemini structured prompting."""
|
|
180
|
+
clean_text = text.strip()
|
|
181
|
+
if not clean_text:
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
prompt = (
|
|
185
|
+
"You are an information extraction system. Extract key relational triples from the text.\n"
|
|
186
|
+
"Valid types: Person, Email, Project, Tool, Topic, Default.\n"
|
|
187
|
+
"Return ONLY a valid JSON list of objects without explanations or markdown backticks.\n\n"
|
|
188
|
+
"Example format:\n"
|
|
189
|
+
'[{"source": "Sarah", "source_type": "Person", "relation": "DISCUSSES", "target": "Synapse", "target_type": "Project"}]\n\n'
|
|
190
|
+
f"TEXT:\n{clean_text}\n\n"
|
|
191
|
+
"JSON:"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
raw_text = await self.generate(prompt, temperature=0.0)
|
|
196
|
+
cleaned = re.sub(
|
|
197
|
+
r"^```(?:json)?\s*|\s*```$",
|
|
198
|
+
"",
|
|
199
|
+
raw_text,
|
|
200
|
+
flags=re.MULTILINE,
|
|
201
|
+
).strip()
|
|
202
|
+
match = re.search(r"\[.*\]", cleaned, re.DOTALL)
|
|
203
|
+
if match:
|
|
204
|
+
cleaned = match.group(0)
|
|
205
|
+
parsed = json.loads(cleaned)
|
|
206
|
+
|
|
207
|
+
if isinstance(parsed, list):
|
|
208
|
+
valid_triples: list[dict[str, str]] = []
|
|
209
|
+
for item in parsed:
|
|
210
|
+
if (
|
|
211
|
+
isinstance(item, dict)
|
|
212
|
+
and "source" in item
|
|
213
|
+
and "relation" in item
|
|
214
|
+
and "target" in item
|
|
215
|
+
):
|
|
216
|
+
valid_triples.append({
|
|
217
|
+
"source": str(item["source"]).strip(),
|
|
218
|
+
"relation": (
|
|
219
|
+
str(item["relation"])
|
|
220
|
+
.strip()
|
|
221
|
+
.upper()
|
|
222
|
+
.replace(" ", "_")
|
|
223
|
+
),
|
|
224
|
+
"target": str(item["target"]).strip(),
|
|
225
|
+
"source_type": (
|
|
226
|
+
str(item.get("source_type", "Default"))
|
|
227
|
+
.strip()
|
|
228
|
+
.title()
|
|
229
|
+
),
|
|
230
|
+
"target_type": (
|
|
231
|
+
str(item.get("target_type", "Default"))
|
|
232
|
+
.strip()
|
|
233
|
+
.title()
|
|
234
|
+
),
|
|
235
|
+
})
|
|
236
|
+
return valid_triples
|
|
237
|
+
except Exception:
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
async def decide_tool_calls(
|
|
243
|
+
self,
|
|
244
|
+
user_message: str,
|
|
245
|
+
candidate_tools: list[dict[str, Any]],
|
|
246
|
+
fallback_calls: list[dict[str, Any]] | None = None,
|
|
247
|
+
) -> list[dict[str, Any]]:
|
|
248
|
+
"""Decides multi-tool routing using Gemini reasoning."""
|
|
249
|
+
text = (user_message or "").strip()
|
|
250
|
+
if not text or not candidate_tools:
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
catalog_lines = []
|
|
254
|
+
valid_keys: dict[tuple[str, str], dict[str, Any]] = {}
|
|
255
|
+
for idx, t in enumerate(candidate_tools, start=1):
|
|
256
|
+
srv = str(t.get("server", "")).strip()
|
|
257
|
+
name = str(t.get("name", "")).strip()
|
|
258
|
+
desc = str(t.get("description", "")).strip()
|
|
259
|
+
schema = json.dumps(t.get("inputSchema", {}).get("properties", {}))
|
|
260
|
+
catalog_lines.append(
|
|
261
|
+
f'{idx}. server="{srv}", tool="{name}" — {desc}\n arguments schema: {schema}'
|
|
262
|
+
)
|
|
263
|
+
valid_keys[(srv, name)] = t
|
|
264
|
+
valid_keys[("", name)] = t
|
|
265
|
+
|
|
266
|
+
catalog_text = "\n".join(catalog_lines)
|
|
267
|
+
tool_prompt = (
|
|
268
|
+
"You are a multi-tool router for a personal AI assistant.\n"
|
|
269
|
+
"Available Tools:\n"
|
|
270
|
+
f"{catalog_text}\n\n"
|
|
271
|
+
"Decide which tool(s) must be called to answer the user's message.\n"
|
|
272
|
+
"- Only call a tool if the user's message actually requires fetching data from that service.\n"
|
|
273
|
+
"- For Gmail unread queries, include 'is:unread' in the Gmail query (e.g. 'label:INBOX is:unread').\n"
|
|
274
|
+
"Return ONLY valid JSON without markdown backticks in this exact format:\n"
|
|
275
|
+
'{"tool_calls": [{"server": "gmail", "tool": "search_emails", "arguments": {"query": "label:INBOX is:unread", "max_results": 5}}]}\n'
|
|
276
|
+
"or if no tool is needed:\n"
|
|
277
|
+
'{"tool_calls": []}\n\n'
|
|
278
|
+
f"USER MESSAGE: {text}\n"
|
|
279
|
+
"JSON:"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
raw_text = await self.generate(tool_prompt, temperature=0.0)
|
|
284
|
+
if not raw_text or raw_text.startswith("[Error from Gemini:"):
|
|
285
|
+
return []
|
|
286
|
+
|
|
287
|
+
cleaned = re.sub(
|
|
288
|
+
r"^```(?:json)?\s*|\s*```$",
|
|
289
|
+
"",
|
|
290
|
+
raw_text,
|
|
291
|
+
flags=re.MULTILINE,
|
|
292
|
+
).strip()
|
|
293
|
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
|
294
|
+
if match:
|
|
295
|
+
cleaned = match.group(0)
|
|
296
|
+
parsed = json.loads(cleaned)
|
|
297
|
+
|
|
298
|
+
if isinstance(parsed, dict):
|
|
299
|
+
if parsed.get("call_tool") is False:
|
|
300
|
+
return []
|
|
301
|
+
raw_calls = parsed.get("tool_calls")
|
|
302
|
+
if isinstance(raw_calls, list):
|
|
303
|
+
if len(raw_calls) == 0:
|
|
304
|
+
return []
|
|
305
|
+
validated: list[dict[str, Any]] = []
|
|
306
|
+
for item in raw_calls:
|
|
307
|
+
if not isinstance(item, dict):
|
|
308
|
+
continue
|
|
309
|
+
srv = str(item.get("server") or "").strip()
|
|
310
|
+
tl = str(item.get("tool") or item.get("name") or "").strip()
|
|
311
|
+
spec = valid_keys.get((srv, tl)) or valid_keys.get(("", tl))
|
|
312
|
+
if not spec:
|
|
313
|
+
continue
|
|
314
|
+
resolved_srv = str(spec.get("server") or srv)
|
|
315
|
+
resolved_tl = str(spec.get("name") or tl)
|
|
316
|
+
args = item.get("arguments")
|
|
317
|
+
if not isinstance(args, dict):
|
|
318
|
+
args = {}
|
|
319
|
+
validated.append({
|
|
320
|
+
"server": resolved_srv,
|
|
321
|
+
"tool": resolved_tl,
|
|
322
|
+
"arguments": args,
|
|
323
|
+
})
|
|
324
|
+
return validated
|
|
325
|
+
except (json.JSONDecodeError, KeyError, TypeError, ValueError, httpx.HTTPError):
|
|
326
|
+
pass # nosec B110
|
|
327
|
+
|
|
328
|
+
return list(fallback_calls or [])
|
synapse/models/ollama.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Local Ollama model provider for Synapse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import AsyncGenerator
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from synapse.config import (
|
|
13
|
+
DEFAULT_OLLAMA_BASE_URL,
|
|
14
|
+
DEFAULT_OLLAMA_MODEL,
|
|
15
|
+
DEFAULT_TEMPERATURE,
|
|
16
|
+
)
|
|
17
|
+
from synapse.models.base import BaseModelProvider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OllamaModel(BaseModelProvider):
|
|
21
|
+
"""Local Ollama LLM provider (e.g. Qwen 2.5, Llama 3, Mistral).
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
base_url: Base HTTP address of the running Ollama daemon.
|
|
25
|
+
model_name: Target model tag (default: 'qwen2.5:0.5b').
|
|
26
|
+
temperature: Default sampling temperature.
|
|
27
|
+
num_ctx: Context window size configured for Ollama requests.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
model: str = DEFAULT_OLLAMA_MODEL,
|
|
33
|
+
base_url: str = DEFAULT_OLLAMA_BASE_URL,
|
|
34
|
+
temperature: float = DEFAULT_TEMPERATURE,
|
|
35
|
+
num_ctx: int = 4096,
|
|
36
|
+
timeout: float = 60.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initializes the Ollama model provider.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
model: Model tag registered in Ollama. Defaults to 'qwen2.5:0.5b'.
|
|
42
|
+
base_url: Base URL of the Ollama server. Defaults to 'http://localhost:11434'.
|
|
43
|
+
temperature: Sampling temperature. Defaults to 0.1.
|
|
44
|
+
num_ctx: Context window size. Defaults to 4096.
|
|
45
|
+
timeout: HTTP request timeout in seconds. Defaults to 60.0.
|
|
46
|
+
"""
|
|
47
|
+
super().__init__(model_name=model, temperature=temperature)
|
|
48
|
+
self.base_url = base_url.rstrip("/")
|
|
49
|
+
self.num_ctx = num_ctx
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
|
|
52
|
+
async def chat_stream(
|
|
53
|
+
self,
|
|
54
|
+
messages: list[dict[str, str]],
|
|
55
|
+
temperature: float | None = None,
|
|
56
|
+
) -> AsyncGenerator[str, None]:
|
|
57
|
+
"""Streams response tokens from the local Ollama instance.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
messages: List of chat messages with 'role' and 'content'.
|
|
61
|
+
temperature: Optional temperature override.
|
|
62
|
+
|
|
63
|
+
Yields:
|
|
64
|
+
Token strings as they are generated.
|
|
65
|
+
"""
|
|
66
|
+
temp = self.temperature if temperature is None else temperature
|
|
67
|
+
payload: dict[str, Any] = {
|
|
68
|
+
"model": self.model_name,
|
|
69
|
+
"messages": messages,
|
|
70
|
+
"stream": True,
|
|
71
|
+
"options": {
|
|
72
|
+
"temperature": temp,
|
|
73
|
+
"num_ctx": self.num_ctx,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
79
|
+
async with client.stream(
|
|
80
|
+
"POST",
|
|
81
|
+
f"{self.base_url}/api/chat",
|
|
82
|
+
json=payload,
|
|
83
|
+
) as stream:
|
|
84
|
+
if stream.status_code != 200:
|
|
85
|
+
yield (
|
|
86
|
+
f"Ollama returned HTTP {stream.status_code}. "
|
|
87
|
+
f"Please verify that model '{self.model_name}' is downloaded (`ollama pull {self.model_name}`)."
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
async for chunk in stream.aiter_lines():
|
|
92
|
+
if not chunk:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(chunk)
|
|
96
|
+
token = data.get("message", {}).get("content", "")
|
|
97
|
+
if token:
|
|
98
|
+
yield token
|
|
99
|
+
except (json.JSONDecodeError, KeyError):
|
|
100
|
+
continue
|
|
101
|
+
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
102
|
+
yield (
|
|
103
|
+
f"Cannot reach Ollama at {self.base_url}. "
|
|
104
|
+
f"Please ensure the Ollama daemon is running (`ollama serve`), "
|
|
105
|
+
f"or switch to an alternative model provider in Settings."
|
|
106
|
+
)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
yield f"Ollama generation error: {e}"
|
|
109
|
+
|
|
110
|
+
async def generate(
|
|
111
|
+
self,
|
|
112
|
+
prompt: str,
|
|
113
|
+
temperature: float | None = None,
|
|
114
|
+
format: str | None = None,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""Generates a complete non-streaming response from Ollama.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
prompt: Text prompt string.
|
|
120
|
+
temperature: Optional temperature override.
|
|
121
|
+
format: Optional output format (e.g. 'json').
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The complete response text string.
|
|
125
|
+
"""
|
|
126
|
+
temp = self.temperature if temperature is None else temperature
|
|
127
|
+
payload: dict[str, Any] = {
|
|
128
|
+
"model": self.model_name,
|
|
129
|
+
"prompt": prompt,
|
|
130
|
+
"stream": False,
|
|
131
|
+
"options": {
|
|
132
|
+
"temperature": temp,
|
|
133
|
+
"num_ctx": self.num_ctx,
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
if format:
|
|
137
|
+
payload["format"] = format
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
141
|
+
res = await client.post(
|
|
142
|
+
f"{self.base_url}/api/generate",
|
|
143
|
+
json=payload,
|
|
144
|
+
)
|
|
145
|
+
res.raise_for_status()
|
|
146
|
+
data = res.json()
|
|
147
|
+
return str(data.get("response", "")).strip()
|
|
148
|
+
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
149
|
+
return f"[Error: Cannot reach Ollama at {self.base_url}. Ensure Ollama is running.]"
|
|
150
|
+
except Exception as e:
|
|
151
|
+
return f"[Error from Ollama: {e}]"
|
|
152
|
+
|
|
153
|
+
async def extract_triples(self, text: str) -> list[dict[str, str]]:
|
|
154
|
+
"""Extracts typed knowledge graph triples from text using Ollama.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
text: Unstructured text content.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
A list of dictionary triples.
|
|
161
|
+
"""
|
|
162
|
+
clean_text = text.strip()
|
|
163
|
+
if not clean_text:
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
prompt = (
|
|
167
|
+
"You are an information extraction system. Extract key relational triples from the text.\n"
|
|
168
|
+
"Valid types: Person, Email, Project, Tool, Topic, Default.\n"
|
|
169
|
+
"Return ONLY a valid JSON list of objects without explanations or markdown backticks.\n\n"
|
|
170
|
+
"Example format:\n"
|
|
171
|
+
'[{"source": "Sarah", "source_type": "Person", "relation": "DISCUSSES", "target": "Synapse", "target_type": "Project"}]\n\n'
|
|
172
|
+
f"TEXT:\n{clean_text}\n\n"
|
|
173
|
+
"JSON:"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
raw_text = await self.generate(prompt, temperature=0.0, format="json")
|
|
178
|
+
if not raw_text or raw_text.startswith("[Error:"):
|
|
179
|
+
return []
|
|
180
|
+
cleaned = re.sub(
|
|
181
|
+
r"^```(?:json)?\s*|\s*```$",
|
|
182
|
+
"",
|
|
183
|
+
raw_text,
|
|
184
|
+
flags=re.MULTILINE,
|
|
185
|
+
).strip()
|
|
186
|
+
match = re.search(r"(\[.*\]|\{.*\})", cleaned, re.DOTALL)
|
|
187
|
+
if match:
|
|
188
|
+
cleaned = match.group(0)
|
|
189
|
+
parsed = json.loads(cleaned)
|
|
190
|
+
if isinstance(parsed, dict):
|
|
191
|
+
parsed = parsed.get("triples") or parsed.get("data") or (list(parsed.values())[0] if parsed.values() and isinstance(list(parsed.values())[0], list) else [])
|
|
192
|
+
|
|
193
|
+
if isinstance(parsed, list):
|
|
194
|
+
valid_triples: list[dict[str, str]] = []
|
|
195
|
+
for item in parsed:
|
|
196
|
+
if (
|
|
197
|
+
isinstance(item, dict)
|
|
198
|
+
and "source" in item
|
|
199
|
+
and "relation" in item
|
|
200
|
+
and "target" in item
|
|
201
|
+
):
|
|
202
|
+
valid_triples.append({
|
|
203
|
+
"source": str(item["source"]).strip(),
|
|
204
|
+
"relation": (
|
|
205
|
+
str(item["relation"])
|
|
206
|
+
.strip()
|
|
207
|
+
.upper()
|
|
208
|
+
.replace(" ", "_")
|
|
209
|
+
),
|
|
210
|
+
"target": str(item["target"]).strip(),
|
|
211
|
+
"source_type": (
|
|
212
|
+
str(item.get("source_type", "Default"))
|
|
213
|
+
.strip()
|
|
214
|
+
.title()
|
|
215
|
+
),
|
|
216
|
+
"target_type": (
|
|
217
|
+
str(item.get("target_type", "Default"))
|
|
218
|
+
.strip()
|
|
219
|
+
.title()
|
|
220
|
+
),
|
|
221
|
+
})
|
|
222
|
+
return valid_triples
|
|
223
|
+
except Exception:
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
async def decide_tool_calls(
|
|
229
|
+
self,
|
|
230
|
+
user_message: str,
|
|
231
|
+
candidate_tools: list[dict[str, Any]],
|
|
232
|
+
fallback_calls: list[dict[str, Any]] | None = None,
|
|
233
|
+
) -> list[dict[str, Any]]:
|
|
234
|
+
"""Determines which candidate MCP tool(s) to execute using Ollama.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
user_message: Natural-language message from the user.
|
|
238
|
+
candidate_tools: List of tool schema descriptors across connectors.
|
|
239
|
+
fallback_calls: Fallback calls used if model outputs invalid JSON.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A list of validated tool call dictionaries.
|
|
243
|
+
"""
|
|
244
|
+
text = (user_message or "").strip()
|
|
245
|
+
if not text or not candidate_tools:
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
catalog_lines: list[str] = []
|
|
249
|
+
valid_keys: dict[tuple[str, str], dict[str, Any]] = {}
|
|
250
|
+
for idx, t in enumerate(candidate_tools, start=1):
|
|
251
|
+
srv = str(t.get("server", "")).strip()
|
|
252
|
+
name = str(t.get("name", "")).strip()
|
|
253
|
+
desc = str(t.get("description", "")).strip()
|
|
254
|
+
schema = json.dumps(t.get("inputSchema", {}).get("properties", {}))
|
|
255
|
+
catalog_lines.append(
|
|
256
|
+
f'{idx}. server="{srv}", tool="{name}" — {desc}\n arguments schema: {schema}'
|
|
257
|
+
)
|
|
258
|
+
valid_keys[(srv, name)] = t
|
|
259
|
+
valid_keys[("", name)] = t
|
|
260
|
+
|
|
261
|
+
catalog_text = "\n".join(catalog_lines)
|
|
262
|
+
tool_prompt = (
|
|
263
|
+
"You are a multi-tool router for a personal AI assistant.\n"
|
|
264
|
+
"Available Tools:\n"
|
|
265
|
+
f"{catalog_text}\n\n"
|
|
266
|
+
"Decide which tool(s) must be called to answer the user's message.\n"
|
|
267
|
+
"- Only call a tool if the user's message actually requires fetching data from that service.\n"
|
|
268
|
+
"- For Gmail unread queries, include 'is:unread' in the Gmail query (e.g. 'label:INBOX is:unread').\n"
|
|
269
|
+
"Return ONLY valid JSON without markdown backticks in this exact format:\n"
|
|
270
|
+
'{"tool_calls": [{"server": "gmail", "tool": "search_emails", "arguments": {"query": "label:INBOX is:unread", "max_results": 5}}]}\n'
|
|
271
|
+
"or if no tool is needed:\n"
|
|
272
|
+
'{"tool_calls": []}\n\n'
|
|
273
|
+
f"USER MESSAGE: {text}\n"
|
|
274
|
+
"JSON:"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
raw_text = await self.generate(tool_prompt, temperature=0.0, format="json")
|
|
279
|
+
if not raw_text or raw_text.startswith("[Error:"):
|
|
280
|
+
return []
|
|
281
|
+
cleaned = re.sub(
|
|
282
|
+
r"^```(?:json)?\s*|\s*```$",
|
|
283
|
+
"",
|
|
284
|
+
raw_text,
|
|
285
|
+
flags=re.MULTILINE,
|
|
286
|
+
).strip()
|
|
287
|
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
|
288
|
+
if match:
|
|
289
|
+
cleaned = match.group(0)
|
|
290
|
+
parsed = json.loads(cleaned)
|
|
291
|
+
|
|
292
|
+
if isinstance(parsed, dict):
|
|
293
|
+
if parsed.get("call_tool") is False:
|
|
294
|
+
return []
|
|
295
|
+
raw_calls = parsed.get("tool_calls")
|
|
296
|
+
if isinstance(raw_calls, list):
|
|
297
|
+
if len(raw_calls) == 0:
|
|
298
|
+
return []
|
|
299
|
+
validated: list[dict[str, Any]] = []
|
|
300
|
+
for item in raw_calls:
|
|
301
|
+
if not isinstance(item, dict):
|
|
302
|
+
continue
|
|
303
|
+
srv = str(item.get("server") or "").strip()
|
|
304
|
+
tl = str(item.get("tool") or item.get("name") or "").strip()
|
|
305
|
+
spec = valid_keys.get((srv, tl)) or valid_keys.get(("", tl))
|
|
306
|
+
if not spec:
|
|
307
|
+
continue
|
|
308
|
+
resolved_srv = str(spec.get("server") or srv)
|
|
309
|
+
resolved_tl = str(spec.get("name") or tl)
|
|
310
|
+
args = item.get("arguments")
|
|
311
|
+
if not isinstance(args, dict):
|
|
312
|
+
args = {}
|
|
313
|
+
validated.append({
|
|
314
|
+
"server": resolved_srv,
|
|
315
|
+
"tool": resolved_tl,
|
|
316
|
+
"arguments": args,
|
|
317
|
+
})
|
|
318
|
+
return validated
|
|
319
|
+
except (json.JSONDecodeError, KeyError, TypeError, ValueError, httpx.HTTPError):
|
|
320
|
+
pass # nosec B110
|
|
321
|
+
|
|
322
|
+
return list(fallback_calls or [])
|