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.
Files changed (54) hide show
  1. synapse/__init__.py +207 -0
  2. synapse/app.py +942 -0
  3. synapse/cli.py +88 -0
  4. synapse/config.py +103 -0
  5. synapse/connectors/__init__.py +29 -0
  6. synapse/connectors/base.py +222 -0
  7. synapse/connectors/factory.py +202 -0
  8. synapse/connectors/gmail.py +292 -0
  9. synapse/connectors/mcp_wrapper.py +153 -0
  10. synapse/connectors/notion.py +161 -0
  11. synapse/connectors/photos.py +141 -0
  12. synapse/database/__init__.py +19 -0
  13. synapse/database/base.py +63 -0
  14. synapse/database/chroma.py +175 -0
  15. synapse/database/memory.py +132 -0
  16. synapse/database/pgvector.py +99 -0
  17. synapse/database/qdrant.py +138 -0
  18. synapse/embeddings/__init__.py +21 -0
  19. synapse/embeddings/base.py +262 -0
  20. synapse/export/__init__.py +14 -0
  21. synapse/export/mcp_exporter.py +202 -0
  22. synapse/graph/__init__.py +21 -0
  23. synapse/graph/base.py +130 -0
  24. synapse/graph/memory.py +20 -0
  25. synapse/graph/neo4j_store.py +176 -0
  26. synapse/graph/networkx_store.py +221 -0
  27. synapse/inspector/__init__.py +5 -0
  28. synapse/inspector/template.py +530 -0
  29. synapse/models/__init__.py +73 -0
  30. synapse/models/base.py +95 -0
  31. synapse/models/gemini.py +328 -0
  32. synapse/models/ollama.py +322 -0
  33. synapse/models/openai.py +289 -0
  34. synapse/oauth/__init__.py +7 -0
  35. synapse/oauth/google.py +194 -0
  36. synapse/oauth/notion.py +35 -0
  37. synapse/rag/__init__.py +8 -0
  38. synapse/rag/chunker.py +219 -0
  39. synapse/rag/hybrid_rag.py +122 -0
  40. synapse/resource/__init__.py +5 -0
  41. synapse/resource/resource.py +304 -0
  42. synapse/resources/__init__.py +5 -0
  43. synapse/skills/__init__.py +5 -0
  44. synapse/skills/skill.py +321 -0
  45. synapse/storage/__init__.py +24 -0
  46. synapse/storage/graph_exporter.py +624 -0
  47. synapse/storage/graph_store.py +162 -0
  48. synapse/storage/vector_store.py +103 -0
  49. synapse_framework-0.2.4.dist-info/METADATA +415 -0
  50. synapse_framework-0.2.4.dist-info/RECORD +54 -0
  51. synapse_framework-0.2.4.dist-info/WHEEL +5 -0
  52. synapse_framework-0.2.4.dist-info/entry_points.txt +2 -0
  53. synapse_framework-0.2.4.dist-info/licenses/LICENSE +21 -0
  54. synapse_framework-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,289 @@
1
+ """OpenAI and OpenAI-compatible API model provider for Synapse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from collections.abc import AsyncGenerator
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from synapse.config import DEFAULT_OPENAI_MODEL, DEFAULT_TEMPERATURE
14
+ from synapse.models.base import BaseModelProvider
15
+
16
+
17
+ class OpenAIModel(BaseModelProvider):
18
+ """OpenAI API and compatible endpoints (Groq, OpenRouter, DeepSeek, vLLM).
19
+
20
+ Attributes:
21
+ api_key: OpenAI API key (reads from OPENAI_API_KEY if omitted).
22
+ base_url: API endpoint URL (default: 'https://api.openai.com/v1').
23
+ model_name: Model identifier (default: 'gpt-4o-mini').
24
+ organization: Optional organization header.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ api_key: str | None = None,
30
+ model: str = DEFAULT_OPENAI_MODEL,
31
+ base_url: str = "https://api.openai.com/v1",
32
+ temperature: float = DEFAULT_TEMPERATURE,
33
+ organization: str | None = None,
34
+ timeout: float = 60.0,
35
+ ) -> None:
36
+ """Initializes the OpenAI model provider.
37
+
38
+ Args:
39
+ api_key: Secret API key. If None, reads from OPENAI_API_KEY environment variable.
40
+ model: Model name string. Defaults to 'gpt-4o-mini'.
41
+ base_url: Base endpoint URL. Defaults to 'https://api.openai.com/v1'.
42
+ temperature: Sampling temperature. Defaults to 0.1.
43
+ organization: Optional OpenAI organization ID.
44
+ timeout: HTTP request timeout in seconds. Defaults to 60.0.
45
+ """
46
+ super().__init__(model_name=model, temperature=temperature)
47
+ self.api_key = api_key or os.getenv("OPENAI_API_KEY", "")
48
+ self.base_url = base_url.rstrip("/")
49
+ self.organization = organization
50
+ self.timeout = timeout
51
+
52
+ def _get_headers(self) -> dict[str, str]:
53
+ headers = {
54
+ "Authorization": f"Bearer {self.api_key}",
55
+ "Content-Type": "application/json",
56
+ }
57
+ if self.organization:
58
+ headers["OpenAI-Organization"] = self.organization
59
+ return headers
60
+
61
+ async def chat_stream(
62
+ self,
63
+ messages: list[dict[str, str]],
64
+ temperature: float | None = None,
65
+ ) -> AsyncGenerator[str, None]:
66
+ """Streams response tokens from the OpenAI chat completions endpoint."""
67
+ temp = self.temperature if temperature is None else temperature
68
+ payload: dict[str, Any] = {
69
+ "model": self.model_name,
70
+ "messages": messages,
71
+ "stream": True,
72
+ "temperature": temp,
73
+ }
74
+
75
+ try:
76
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
77
+ async with client.stream(
78
+ "POST",
79
+ f"{self.base_url}/chat/completions",
80
+ headers=self._get_headers(),
81
+ json=payload,
82
+ ) as stream:
83
+ if stream.status_code in (401, 403):
84
+ yield "OpenAI authentication failed: Please verify your OpenAI API key in Settings."
85
+ return
86
+ if stream.status_code != 200:
87
+ yield f"OpenAI API returned HTTP {stream.status_code}."
88
+ return
89
+
90
+ async for chunk in stream.aiter_lines():
91
+ if not chunk or not chunk.startswith("data: "):
92
+ continue
93
+ data_str = chunk[6:].strip()
94
+ if data_str == "[DONE]":
95
+ break
96
+ try:
97
+ data = json.loads(data_str)
98
+ choices = data.get("choices", [])
99
+ if choices:
100
+ delta = choices[0].get("delta", {})
101
+ token = delta.get("content", "")
102
+ if token:
103
+ yield token
104
+ except json.JSONDecodeError:
105
+ continue
106
+ except (httpx.ConnectError, httpx.ConnectTimeout):
107
+ yield f"Cannot reach OpenAI API at {self.base_url}. Please check your network connection."
108
+ except Exception as e:
109
+ yield f"OpenAI error: {e}"
110
+
111
+ async def generate(
112
+ self,
113
+ prompt: str,
114
+ temperature: float | None = None,
115
+ ) -> str:
116
+ """Generates a non-streaming response for a prompt string."""
117
+ temp = self.temperature if temperature is None else temperature
118
+ payload: dict[str, Any] = {
119
+ "model": self.model_name,
120
+ "messages": [{"role": "user", "content": prompt}],
121
+ "stream": False,
122
+ "temperature": temp,
123
+ }
124
+
125
+ try:
126
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
127
+ res = await client.post(
128
+ f"{self.base_url}/chat/completions",
129
+ headers=self._get_headers(),
130
+ json=payload,
131
+ )
132
+ res.raise_for_status()
133
+ data = res.json()
134
+ choices = data.get("choices", [])
135
+ if choices:
136
+ return str(
137
+ choices[0].get("message", {}).get("content", "")
138
+ ).strip()
139
+ return ""
140
+ except (httpx.ConnectError, httpx.ConnectTimeout):
141
+ return f"[Error: Cannot connect to OpenAI API at {self.base_url}. Check your internet connection.]"
142
+ except Exception as e:
143
+ return f"[Error from OpenAI: {e}]"
144
+
145
+ async def extract_triples(self, text: str) -> list[dict[str, str]]:
146
+ """Extracts relational triples using OpenAI structured prompt."""
147
+ clean_text = text.strip()
148
+ if not clean_text:
149
+ return []
150
+
151
+ prompt = (
152
+ "You are an information extraction system. Extract key relational triples from the text.\n"
153
+ "Valid types: Person, Email, Project, Tool, Topic, Default.\n"
154
+ "Return ONLY a valid JSON list of objects without explanations or markdown backticks.\n\n"
155
+ "Example format:\n"
156
+ '[{"source": "Sarah", "source_type": "Person", "relation": "DISCUSSES", "target": "Synapse", "target_type": "Project"}]\n\n'
157
+ f"TEXT:\n{clean_text}\n\n"
158
+ "JSON:"
159
+ )
160
+
161
+ try:
162
+ raw_text = await self.generate(prompt, temperature=0.0)
163
+ cleaned = re.sub(
164
+ r"^```(?:json)?\s*|\s*```$",
165
+ "",
166
+ raw_text,
167
+ flags=re.MULTILINE,
168
+ ).strip()
169
+ match = re.search(r"\[.*\]", cleaned, re.DOTALL)
170
+ if match:
171
+ cleaned = match.group(0)
172
+ parsed = json.loads(cleaned)
173
+
174
+ if isinstance(parsed, list):
175
+ valid_triples: list[dict[str, str]] = []
176
+ for item in parsed:
177
+ if (
178
+ isinstance(item, dict)
179
+ and "source" in item
180
+ and "relation" in item
181
+ and "target" in item
182
+ ):
183
+ valid_triples.append({
184
+ "source": str(item["source"]).strip(),
185
+ "relation": (
186
+ str(item["relation"])
187
+ .strip()
188
+ .upper()
189
+ .replace(" ", "_")
190
+ ),
191
+ "target": str(item["target"]).strip(),
192
+ "source_type": (
193
+ str(item.get("source_type", "Default"))
194
+ .strip()
195
+ .title()
196
+ ),
197
+ "target_type": (
198
+ str(item.get("target_type", "Default"))
199
+ .strip()
200
+ .title()
201
+ ),
202
+ })
203
+ return valid_triples
204
+ except Exception:
205
+ return []
206
+
207
+ return []
208
+
209
+ async def decide_tool_calls(
210
+ self,
211
+ user_message: str,
212
+ candidate_tools: list[dict[str, Any]],
213
+ fallback_calls: list[dict[str, Any]] | None = None,
214
+ ) -> list[dict[str, Any]]:
215
+ """Decides tool calls using OpenAI JSON reasoning."""
216
+ text = (user_message or "").strip()
217
+ if not text or not candidate_tools:
218
+ return []
219
+
220
+ catalog_lines = []
221
+ valid_keys: dict[tuple[str, str], dict[str, Any]] = {}
222
+ for idx, t in enumerate(candidate_tools, start=1):
223
+ srv = str(t.get("server", "")).strip()
224
+ name = str(t.get("name", "")).strip()
225
+ desc = str(t.get("description", "")).strip()
226
+ schema = json.dumps(t.get("inputSchema", {}).get("properties", {}))
227
+ catalog_lines.append(
228
+ f'{idx}. server="{srv}", tool="{name}" — {desc}\n arguments schema: {schema}'
229
+ )
230
+ valid_keys[(srv, name)] = t
231
+ valid_keys[("", name)] = t
232
+
233
+ catalog_text = "\n".join(catalog_lines)
234
+ tool_prompt = (
235
+ "You are a multi-tool router for a personal AI assistant.\n"
236
+ "Available Tools:\n"
237
+ f"{catalog_text}\n\n"
238
+ "Decide which tool(s) must be called to answer the user's message.\n"
239
+ "- Only call a tool if the user's message actually requires fetching data from that service.\n"
240
+ "- For Gmail unread queries, include 'is:unread' in the Gmail query (e.g. 'label:INBOX is:unread').\n"
241
+ "Return ONLY valid JSON without markdown backticks in this exact format:\n"
242
+ '{"tool_calls": [{"server": "gmail", "tool": "search_emails", "arguments": {"query": "label:INBOX is:unread", "max_results": 5}}]}\n'
243
+ "or if no tool is needed:\n"
244
+ '{"tool_calls": []}\n\n'
245
+ f"USER MESSAGE: {text}\n"
246
+ "JSON:"
247
+ )
248
+
249
+ try:
250
+ raw_text = await self.generate(tool_prompt, temperature=0.0)
251
+ cleaned = re.sub(
252
+ r"^```(?:json)?\s*|\s*```$",
253
+ "",
254
+ raw_text,
255
+ flags=re.MULTILINE,
256
+ ).strip()
257
+ match = re.search(r"\{.*\}", cleaned, re.DOTALL)
258
+ if match:
259
+ cleaned = match.group(0)
260
+ parsed = json.loads(cleaned)
261
+
262
+ if isinstance(parsed, dict):
263
+ raw_calls = parsed.get("tool_calls")
264
+ if isinstance(raw_calls, list):
265
+ validated: list[dict[str, Any]] = []
266
+ for item in raw_calls:
267
+ if not isinstance(item, dict):
268
+ continue
269
+ srv = str(item.get("server") or "").strip()
270
+ tl = str(item.get("tool") or item.get("name") or "").strip()
271
+ spec = valid_keys.get((srv, tl)) or valid_keys.get(("", tl))
272
+ if not spec:
273
+ continue
274
+ resolved_srv = str(spec.get("server") or srv)
275
+ resolved_tl = str(spec.get("name") or tl)
276
+ args = item.get("arguments")
277
+ if not isinstance(args, dict):
278
+ args = {}
279
+ validated.append({
280
+ "server": resolved_srv,
281
+ "tool": resolved_tl,
282
+ "arguments": args,
283
+ })
284
+ if validated:
285
+ return validated
286
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError, httpx.HTTPError):
287
+ pass # nosec B110
288
+
289
+ return list(fallback_calls or [])
@@ -0,0 +1,7 @@
1
+ """OAuth and token authentication services for Synapse connectors."""
2
+
3
+ from synapse.oauth.google import GoogleOAuthService
4
+ from synapse.oauth.notion import NotionAuthService
5
+
6
+ # Re-export GoogleOAuthService matching the user's requested naming
7
+ __all__ = ["GoogleOAuthService", "NotionAuthService"]
@@ -0,0 +1,194 @@
1
+ """Reusable Google OAuth 2.0 lifecycle and API service manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from google.auth.transport.requests import Request
11
+ from google.oauth2.credentials import Credentials
12
+ from google_auth_oauthlib.flow import InstalledAppFlow
13
+ from googleapiclient.discovery import Resource, build
14
+
15
+
16
+ class GoogleOAuthService:
17
+ """Manages Google OAuth 2.0 credentials, token refresh, and client discovery.
18
+
19
+ Reusable across all Google-backed connectors (Gmail, Google Photos,
20
+ Google Calendar, Google Drive).
21
+
22
+ Attributes:
23
+ credentials_path: Path to the Google Cloud OAuth 2.0 Client Secrets JSON.
24
+ token_path: Path where authorized user tokens are stored.
25
+ scopes: List of OAuth scope URIs required by the consumer connector(s).
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ credentials_path: str | Path = "credentials.json",
31
+ token_path: str | Path = "token.json", # nosec B107
32
+ scopes: list[str] | None = None,
33
+ ) -> None:
34
+ """Initializes the GoogleOAuthService.
35
+
36
+ Args:
37
+ credentials_path: Path to credentials.json. Defaults to 'credentials.json'.
38
+ token_path: Path to token.json. Defaults to 'token.json'.
39
+ scopes: Optional initial list of OAuth scope URIs.
40
+ """
41
+ self.credentials_path = Path(credentials_path).resolve()
42
+ self.token_path = Path(token_path).resolve()
43
+ self.scopes: list[str] = list(scopes or [])
44
+ self._credentials: Credentials | None = None
45
+
46
+ def add_scopes(self, scopes: list[str]) -> None:
47
+ """Adds additional OAuth scopes to this service."""
48
+ for s in scopes:
49
+ if s not in self.scopes:
50
+ self.scopes.append(s)
51
+
52
+ def has_credentials(self) -> bool:
53
+ """Checks if the client secrets credentials.json exists on disk."""
54
+ return self.credentials_path.exists()
55
+
56
+ def is_authenticated(self) -> bool:
57
+ """Checks if a valid, non-expired token exists on disk or in memory."""
58
+ if not self.token_path.exists():
59
+ return False
60
+ try:
61
+ creds = Credentials.from_authorized_user_file(
62
+ str(self.token_path), self.scopes or None
63
+ )
64
+ return bool(creds and (creds.valid or creds.refresh_token))
65
+ except Exception:
66
+ return False
67
+
68
+ def authenticate(
69
+ self, scopes: list[str] | None = None
70
+ ) -> Credentials:
71
+ """Loads, refreshes, or initiates a browser flow to acquire valid Credentials.
72
+
73
+ Args:
74
+ scopes: Optional override or extension of required OAuth scopes.
75
+
76
+ Returns:
77
+ A valid, active `google.oauth2.credentials.Credentials` instance.
78
+
79
+ Raises:
80
+ FileNotFoundError: If credentials_path is missing when authorization is needed.
81
+ """
82
+ active_scopes = list(scopes or self.scopes)
83
+
84
+ if self.token_path.exists():
85
+ try:
86
+ self._credentials = Credentials.from_authorized_user_file(
87
+ str(self.token_path), active_scopes
88
+ )
89
+ except Exception:
90
+ self._credentials = None
91
+
92
+ if not self._credentials or not self._credentials.valid:
93
+ if (
94
+ self._credentials
95
+ and self._credentials.expired
96
+ and self._credentials.refresh_token
97
+ ):
98
+ self._credentials.refresh(Request())
99
+ else:
100
+ if not self.credentials_path.exists():
101
+ raise FileNotFoundError(
102
+ f"Missing OAuth Client Secrets file at: '{self.credentials_path}'. "
103
+ "Please provide a valid credentials.json from Google Cloud Console."
104
+ )
105
+ flow = InstalledAppFlow.from_client_secrets_file(
106
+ str(self.credentials_path), active_scopes
107
+ )
108
+ self._credentials = flow.run_local_server(port=0)
109
+
110
+ self.token_path.parent.mkdir(parents=True, exist_ok=True)
111
+ with open(self.token_path, "w", encoding="utf-8") as f:
112
+ f.write(self._credentials.to_json())
113
+
114
+ return self._credentials
115
+
116
+ def get_service(
117
+ self,
118
+ api_name: str,
119
+ version: str,
120
+ scopes: list[str] | None = None,
121
+ ) -> Resource:
122
+ """Builds an authenticated Google API Discovery Resource.
123
+
124
+ Args:
125
+ api_name: Google API name (e.g. 'gmail', 'photoslibrary').
126
+ version: API version (e.g. 'v1').
127
+ scopes: Required OAuth scopes.
128
+
129
+ Returns:
130
+ An authenticated Resource instance.
131
+ """
132
+ creds = self.authenticate(scopes)
133
+ return build(api_name, version, credentials=creds)
134
+
135
+ def save_credentials(self, content: str | bytes | dict[str, Any]) -> Path:
136
+ """Validates and persists uploaded OAuth Client Secrets content to disk.
137
+
138
+ Args:
139
+ content: Raw JSON bytes, string, or parsed dictionary.
140
+
141
+ Returns:
142
+ Resolved Path where credentials were saved.
143
+ """
144
+ if isinstance(content, bytes):
145
+ parsed = json.loads(content.decode("utf-8"))
146
+ elif isinstance(content, str):
147
+ parsed = json.loads(content)
148
+ else:
149
+ parsed = content
150
+
151
+ if "installed" not in parsed and "web" not in parsed:
152
+ raise ValueError(
153
+ "Invalid Google OAuth Client Secrets JSON. Expected 'installed' or 'web' root key."
154
+ )
155
+
156
+ self.credentials_path.parent.mkdir(parents=True, exist_ok=True)
157
+ with open(self.credentials_path, "w", encoding="utf-8") as f:
158
+ json.dump(parsed, f, indent=2)
159
+
160
+ return self.credentials_path
161
+
162
+ def disconnect(self) -> bool:
163
+ """Deletes the stored token.json from disk.
164
+
165
+ Returns:
166
+ True if the token was removed, False if it did not exist.
167
+ """
168
+ self._credentials = None
169
+ if self.token_path.exists():
170
+ self.token_path.unlink()
171
+ return True
172
+ return False
173
+
174
+ @staticmethod
175
+ def decode_mime_body(payload: dict[str, Any]) -> str:
176
+ """Decodes plain-text body from a Gmail MIME payload dictionary."""
177
+ if "parts" in payload:
178
+ for part in payload["parts"]:
179
+ if (
180
+ part.get("mimeType") == "text/plain"
181
+ and "data" in part.get("body", {})
182
+ ):
183
+ raw_data = part["body"]["data"]
184
+ return base64.urlsafe_b64decode(raw_data).decode(
185
+ "utf-8", errors="ignore"
186
+ )
187
+
188
+ if "body" in payload and "data" in payload["body"]:
189
+ raw_data = payload["body"]["data"]
190
+ return base64.urlsafe_b64decode(raw_data).decode(
191
+ "utf-8", errors="ignore"
192
+ )
193
+
194
+ return ""
@@ -0,0 +1,35 @@
1
+ """Notion API authentication and header manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ class NotionAuthService:
9
+ """Manages Notion REST API tokens and request headers.
10
+
11
+ Attributes:
12
+ api_key: Notion internal integration secret token.
13
+ base_url: Base URL of the Notion v1 REST API.
14
+ """
15
+
16
+ def __init__(self, api_key: str | None = None) -> None:
17
+ """Initializes the Notion authentication service.
18
+
19
+ Args:
20
+ api_key: Optional Notion API token (reads from NOTION_API_KEY env if omitted).
21
+ """
22
+ self.api_key = api_key or os.getenv("NOTION_API_KEY", "")
23
+ self.base_url = "https://api.notion.com/v1"
24
+
25
+ def is_authenticated(self) -> bool:
26
+ """Checks if a non-empty API token is configured."""
27
+ return bool(self.api_key and self.api_key.strip())
28
+
29
+ def get_headers(self) -> dict[str, str]:
30
+ """Constructs request headers for Notion API invocations."""
31
+ return {
32
+ "Authorization": f"Bearer {self.api_key}",
33
+ "Notion-Version": "2022-06-28",
34
+ "Content-Type": "application/json",
35
+ }
@@ -0,0 +1,8 @@
1
+ """Retrieval-Augmented Generation (RAG) and chunking primitives."""
2
+
3
+ from synapse.rag.chunker import DocumentChunker
4
+ from synapse.rag.hybrid_rag import HybridRAGEngine
5
+
6
+ HybridRAG = HybridRAGEngine
7
+
8
+ __all__ = ["DocumentChunker", "HybridRAG", "HybridRAGEngine"]