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/openai.py
ADDED
|
@@ -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"]
|
synapse/oauth/google.py
ADDED
|
@@ -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 ""
|
synapse/oauth/notion.py
ADDED
|
@@ -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
|
+
}
|
synapse/rag/__init__.py
ADDED
|
@@ -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"]
|