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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Gmail connector for the Synapse framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from langchain_core.documents import Document
|
|
10
|
+
|
|
11
|
+
from synapse.connectors.base import BaseConnector
|
|
12
|
+
from synapse.oauth.google import GoogleOAuthService
|
|
13
|
+
|
|
14
|
+
GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GmailConnector(BaseConnector):
|
|
18
|
+
"""Google Gmail connector for fetching, searching, and indexing emails.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
oauth: Reusable GoogleOAuthService managing OAuth tokens and Discovery client.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
credentials_path: str | Path = "credentials.json",
|
|
27
|
+
token_path: str | Path = "token.json", # nosec B107
|
|
28
|
+
oauth_service: GoogleOAuthService | None = None,
|
|
29
|
+
scopes: list[str] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Initializes the GmailConnector.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
credentials_path: Path to OAuth client secrets JSON.
|
|
35
|
+
token_path: Path to cached token JSON.
|
|
36
|
+
oauth_service: Optional pre-configured GoogleOAuthService instance.
|
|
37
|
+
scopes: Optional list of OAuth scopes.
|
|
38
|
+
"""
|
|
39
|
+
super().__init__(
|
|
40
|
+
name="gmail",
|
|
41
|
+
description="Gmail inbox, unread/read emails, senders, and message threads",
|
|
42
|
+
intent_regex=(
|
|
43
|
+
r"\b(email|emails|e-mail|inbox|unread|mail|mails|message|messages|"
|
|
44
|
+
r"gmail|sender|sent\s+me|from\s+[a-z0-9_.-]+|check\s+my\s+mail|"
|
|
45
|
+
r"check\s+my\s+inbox|recent\s+threads)\b"
|
|
46
|
+
),
|
|
47
|
+
default_tool="search_emails",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
active_scopes = list(scopes or [GMAIL_READONLY_SCOPE])
|
|
51
|
+
if oauth_service:
|
|
52
|
+
self.oauth = oauth_service
|
|
53
|
+
self.oauth.add_scopes(active_scopes)
|
|
54
|
+
else:
|
|
55
|
+
self.oauth = GoogleOAuthService(
|
|
56
|
+
credentials_path=credentials_path,
|
|
57
|
+
token_path=token_path,
|
|
58
|
+
scopes=active_scopes,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def is_authenticated(self) -> bool:
|
|
62
|
+
"""Returns True if a valid Gmail OAuth token exists."""
|
|
63
|
+
return self.oauth.is_authenticated()
|
|
64
|
+
|
|
65
|
+
async def list_tools(self) -> list[dict[str, Any]]:
|
|
66
|
+
"""Returns the Gmail tool schemas."""
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
"server": self.name,
|
|
70
|
+
"name": "search_emails",
|
|
71
|
+
"description": (
|
|
72
|
+
"Search Gmail messages using Gmail query syntax "
|
|
73
|
+
"(e.g. 'label:INBOX', 'label:INBOX is:unread', 'from:Sender')."
|
|
74
|
+
),
|
|
75
|
+
"inputSchema": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"query": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"default": "label:INBOX",
|
|
81
|
+
"description": "Gmail search query string.",
|
|
82
|
+
},
|
|
83
|
+
"max_results": {
|
|
84
|
+
"type": "integer",
|
|
85
|
+
"default": 5,
|
|
86
|
+
"description": "Maximum number of messages to return.",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
def default_args_builder(self, user_message: str) -> dict[str, Any]:
|
|
94
|
+
"""Constructs smart Gmail query arguments from user natural language prompt."""
|
|
95
|
+
text = (user_message or "").strip()
|
|
96
|
+
q_lower = text.lower()
|
|
97
|
+
wants_unread = bool(
|
|
98
|
+
re.search(
|
|
99
|
+
r"\b(unread|unseen|not\s+read|new\s+email|new\s+emails|new\s+message|new\s+messages)\b",
|
|
100
|
+
q_lower,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
query = "label:INBOX is:unread" if wants_unread else "label:INBOX"
|
|
104
|
+
|
|
105
|
+
from_match = re.search(
|
|
106
|
+
r"\bfrom\s+([a-zA-Z0-9_.@-]+)", text, re.IGNORECASE
|
|
107
|
+
)
|
|
108
|
+
if from_match:
|
|
109
|
+
sender_term = from_match.group(1).strip("?.!,")
|
|
110
|
+
if sender_term.lower() not in {
|
|
111
|
+
"my",
|
|
112
|
+
"the",
|
|
113
|
+
"recent",
|
|
114
|
+
"inbox",
|
|
115
|
+
"gmail",
|
|
116
|
+
"email",
|
|
117
|
+
"emails",
|
|
118
|
+
}:
|
|
119
|
+
query = (
|
|
120
|
+
f"from:{sender_term} is:unread"
|
|
121
|
+
if wants_unread
|
|
122
|
+
else f"from:{sender_term}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return {"query": query, "max_results": 5}
|
|
126
|
+
|
|
127
|
+
async def call_tool(
|
|
128
|
+
self,
|
|
129
|
+
tool_name: str,
|
|
130
|
+
arguments: dict[str, Any] | None = None,
|
|
131
|
+
) -> Any:
|
|
132
|
+
"""Executes a Gmail tool via Google API."""
|
|
133
|
+
args = arguments or {}
|
|
134
|
+
if tool_name == "search_emails":
|
|
135
|
+
query = str(args.get("query", "label:INBOX"))
|
|
136
|
+
max_results = int(args.get("max_results", 5))
|
|
137
|
+
return self.search_emails(query=query, max_results=max_results)
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Unknown tool '{tool_name}' for connector '{self.name}'"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def search_emails(
|
|
143
|
+
self,
|
|
144
|
+
query: str = "label:INBOX",
|
|
145
|
+
max_results: int = 5,
|
|
146
|
+
) -> list[dict[str, Any]]:
|
|
147
|
+
"""Searches Gmail using Google API Discovery client."""
|
|
148
|
+
if not self.is_authenticated():
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
service = self.oauth.get_service(
|
|
152
|
+
api_name="gmail", version="v1", scopes=[GMAIL_READONLY_SCOPE]
|
|
153
|
+
)
|
|
154
|
+
response = (
|
|
155
|
+
service.users()
|
|
156
|
+
.messages()
|
|
157
|
+
.list(userId="me", q=query, maxResults=max_results)
|
|
158
|
+
.execute()
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
messages_meta = response.get("messages", [])
|
|
162
|
+
if not messages_meta:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
results: list[dict[str, Any]] = []
|
|
166
|
+
for meta in messages_meta:
|
|
167
|
+
msg = (
|
|
168
|
+
service.users()
|
|
169
|
+
.messages()
|
|
170
|
+
.get(userId="me", id=meta["id"], format="full")
|
|
171
|
+
.execute()
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
payload = msg.get("payload", {})
|
|
175
|
+
headers = payload.get("headers", [])
|
|
176
|
+
label_ids = msg.get("labelIds", []) or []
|
|
177
|
+
is_unread = "UNREAD" in label_ids
|
|
178
|
+
|
|
179
|
+
subject = next(
|
|
180
|
+
(h["value"] for h in headers if h["name"].lower() == "subject"),
|
|
181
|
+
"No Subject",
|
|
182
|
+
)
|
|
183
|
+
sender = next(
|
|
184
|
+
(h["value"] for h in headers if h["name"].lower() == "from"),
|
|
185
|
+
"Unknown Sender",
|
|
186
|
+
)
|
|
187
|
+
recipient = next(
|
|
188
|
+
(h["value"] for h in headers if h["name"].lower() == "to"), "Me"
|
|
189
|
+
)
|
|
190
|
+
date_str = next(
|
|
191
|
+
(h["value"] for h in headers if h["name"].lower() == "date"), ""
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
body_text = GoogleOAuthService.decode_mime_body(payload)
|
|
195
|
+
snippet = msg.get("snippet", "")
|
|
196
|
+
|
|
197
|
+
results.append({
|
|
198
|
+
"id": msg["id"],
|
|
199
|
+
"subject": subject,
|
|
200
|
+
"sender": sender,
|
|
201
|
+
"to": recipient,
|
|
202
|
+
"date": date_str,
|
|
203
|
+
"is_unread": is_unread,
|
|
204
|
+
"labels": label_ids,
|
|
205
|
+
"snippet": snippet,
|
|
206
|
+
"body": (body_text.strip() or snippet.strip()),
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
return results
|
|
210
|
+
|
|
211
|
+
def chunk_record(
|
|
212
|
+
self,
|
|
213
|
+
tool_name: str,
|
|
214
|
+
record: dict[str, Any],
|
|
215
|
+
) -> list[Document]:
|
|
216
|
+
"""Chunks an email record with rich unread metadata."""
|
|
217
|
+
email_id = str(record.get("id", ""))
|
|
218
|
+
subject = str(record.get("subject", "No Subject")).strip()
|
|
219
|
+
sender = str(
|
|
220
|
+
record.get("from") or record.get("sender") or "Unknown Sender"
|
|
221
|
+
).strip()
|
|
222
|
+
recipients = str(record.get("to", "Me")).strip()
|
|
223
|
+
date_str = str(record.get("date", "")).strip()
|
|
224
|
+
body = str(record.get("body", "")).strip()
|
|
225
|
+
is_unread = bool(record.get("is_unread", False))
|
|
226
|
+
read_tag = "UNREAD" if is_unread else "READ"
|
|
227
|
+
|
|
228
|
+
if not body and not subject:
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
header = f"[EMAIL | {read_tag}] Subject: {subject} | From: {sender} | To: {recipients}"
|
|
232
|
+
full_text = f"{header}\nDate: {date_str}\n\n{body}"
|
|
233
|
+
|
|
234
|
+
base_meta = {
|
|
235
|
+
"source": "gmail",
|
|
236
|
+
"email_id": email_id,
|
|
237
|
+
"subject": subject,
|
|
238
|
+
"sender": sender,
|
|
239
|
+
"recipients": recipients,
|
|
240
|
+
"date": date_str,
|
|
241
|
+
"is_unread": is_unread,
|
|
242
|
+
"type": "Email",
|
|
243
|
+
"chunk_id": f"gmail:{email_id}:0",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return [Document(page_content=full_text, metadata=base_meta)]
|
|
247
|
+
|
|
248
|
+
def format_record(
|
|
249
|
+
self,
|
|
250
|
+
tool_name: str,
|
|
251
|
+
record: dict[str, Any],
|
|
252
|
+
index: int = 1,
|
|
253
|
+
) -> tuple[str, dict[str, Any], str]:
|
|
254
|
+
"""Formats email into context string, UI chunk, and knowledge graph triple."""
|
|
255
|
+
is_unread = bool(record.get("is_unread", False))
|
|
256
|
+
unread_badge = "UNREAD" if is_unread else "READ"
|
|
257
|
+
subject = record.get("subject", "No Subject")
|
|
258
|
+
sender = record.get("sender", "Unknown Sender")
|
|
259
|
+
date_str = record.get("date", "")
|
|
260
|
+
body_excerpt = (
|
|
261
|
+
record.get("body") or record.get("snippet") or ""
|
|
262
|
+
)[:600].strip()
|
|
263
|
+
|
|
264
|
+
summary_line = (
|
|
265
|
+
f"{index}. [{unread_badge}] Subject: {subject}\n"
|
|
266
|
+
f" From: {sender} | Date: {date_str}\n"
|
|
267
|
+
f" Content: {body_excerpt}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
live_chunk = {
|
|
271
|
+
"page_content": (
|
|
272
|
+
f"[EMAIL | {unread_badge}] Subject: {subject} | From: {sender}\n"
|
|
273
|
+
f"Date: {date_str}\n\n{body_excerpt}"
|
|
274
|
+
),
|
|
275
|
+
"metadata": {
|
|
276
|
+
"source": "gmail_live",
|
|
277
|
+
"subject": f"[{unread_badge}] {subject}",
|
|
278
|
+
"sender": sender,
|
|
279
|
+
"date": date_str,
|
|
280
|
+
"is_unread": is_unread,
|
|
281
|
+
},
|
|
282
|
+
"source": "gmail_live",
|
|
283
|
+
"subject": f"[{unread_badge}] {subject}",
|
|
284
|
+
"chunk_id": f"live_gmail_{record.get('id', index)}",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
triple = f"{sender} -> SENT -> {subject}"
|
|
288
|
+
return summary_line, live_chunk, triple
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# Developer-friendly alias matching user request
|
|
292
|
+
GmailConnectors = GmailConnector
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Generic FastMCP / stdio MCP connector wrapper for the Synapse framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mcp import ClientSession, StdioServerParameters
|
|
13
|
+
from mcp.client.stdio import stdio_client
|
|
14
|
+
|
|
15
|
+
from synapse.connectors.base import BaseConnector
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MCPConnector(BaseConnector):
|
|
19
|
+
"""Wraps an external FastMCP or stdio MCP server into a Synapse BaseConnector.
|
|
20
|
+
|
|
21
|
+
Enables adding any MCP server (e.g. GitHub, Slack, Google Calendar, Postgres)
|
|
22
|
+
with a single class instantiation.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
server_params: StdioServerParameters used to run the MCP subprocess.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
name: str,
|
|
31
|
+
script_path: str | Path | None = None,
|
|
32
|
+
command: str | None = None,
|
|
33
|
+
args: list[str] | None = None,
|
|
34
|
+
env: dict[str, str] | None = None,
|
|
35
|
+
description: str = "",
|
|
36
|
+
intent_regex: str | None = None,
|
|
37
|
+
default_tool: str = "",
|
|
38
|
+
is_ready_fn: Callable[[], bool] | None = None,
|
|
39
|
+
static_tools: list[dict[str, Any]] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Initializes the generic MCP connector.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
name: Connector identifier.
|
|
45
|
+
script_path: Path to a Python MCP server script.
|
|
46
|
+
command: Executable command override (e.g. 'uvx', 'npx').
|
|
47
|
+
args: CLI arguments when using an external command.
|
|
48
|
+
env: Extra environment variables.
|
|
49
|
+
description: Domain description.
|
|
50
|
+
intent_regex: Regex for matching relevant user prompts.
|
|
51
|
+
default_tool: Default tool name.
|
|
52
|
+
is_ready_fn: Callable returning True when authentication is ready.
|
|
53
|
+
static_tools: Pre-declared tool schemas.
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(
|
|
56
|
+
name=name,
|
|
57
|
+
description=description or f"{name} MCP connector",
|
|
58
|
+
intent_regex=intent_regex,
|
|
59
|
+
default_tool=default_tool,
|
|
60
|
+
)
|
|
61
|
+
self.is_ready_fn = is_ready_fn or (lambda: True)
|
|
62
|
+
self.static_tools = static_tools or []
|
|
63
|
+
self._tools_cache: list[dict[str, Any]] | None = (
|
|
64
|
+
static_tools if static_tools else None
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
merged_env = os.environ.copy()
|
|
68
|
+
if script_path:
|
|
69
|
+
script_abs = Path(script_path).resolve()
|
|
70
|
+
python_path = f"{script_abs.parent}:{merged_env.get('PYTHONPATH', '')}"
|
|
71
|
+
merged_env["PYTHONPATH"] = python_path
|
|
72
|
+
exec_cmd = command or sys.executable
|
|
73
|
+
exec_args = args if args is not None else [str(script_abs)]
|
|
74
|
+
else:
|
|
75
|
+
exec_cmd = command or sys.executable
|
|
76
|
+
exec_args = args or []
|
|
77
|
+
|
|
78
|
+
if env:
|
|
79
|
+
merged_env.update(env)
|
|
80
|
+
|
|
81
|
+
self.server_params = StdioServerParameters(
|
|
82
|
+
command=exec_cmd,
|
|
83
|
+
args=exec_args,
|
|
84
|
+
env=merged_env,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def is_authenticated(self) -> bool:
|
|
88
|
+
"""Checks if the wrapped MCP server is configured and ready."""
|
|
89
|
+
try:
|
|
90
|
+
return bool(self.is_ready_fn())
|
|
91
|
+
except Exception:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
async def list_tools(self) -> list[dict[str, Any]]:
|
|
95
|
+
"""Discovers tools exposed by the MCP server."""
|
|
96
|
+
if self._tools_cache:
|
|
97
|
+
return self._tools_cache
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
async with stdio_client(self.server_params) as (
|
|
101
|
+
read_stream,
|
|
102
|
+
write_stream,
|
|
103
|
+
), ClientSession(read_stream, write_stream) as session:
|
|
104
|
+
await session.initialize()
|
|
105
|
+
tools_result = await session.list_tools()
|
|
106
|
+
discovered = [
|
|
107
|
+
{
|
|
108
|
+
"server": self.name,
|
|
109
|
+
"name": tool.name,
|
|
110
|
+
"description": tool.description,
|
|
111
|
+
"inputSchema": getattr(
|
|
112
|
+
tool,
|
|
113
|
+
"inputSchema",
|
|
114
|
+
getattr(tool, "input_schema", {}),
|
|
115
|
+
),
|
|
116
|
+
}
|
|
117
|
+
for tool in tools_result.tools
|
|
118
|
+
]
|
|
119
|
+
self._tools_cache = discovered
|
|
120
|
+
return discovered
|
|
121
|
+
except Exception:
|
|
122
|
+
return self.static_tools
|
|
123
|
+
|
|
124
|
+
async def call_tool(
|
|
125
|
+
self,
|
|
126
|
+
tool_name: str,
|
|
127
|
+
arguments: dict[str, Any] | None = None,
|
|
128
|
+
) -> Any:
|
|
129
|
+
"""Invokes a tool on the MCP server over stdio."""
|
|
130
|
+
async with stdio_client(self.server_params) as (
|
|
131
|
+
read_stream,
|
|
132
|
+
write_stream,
|
|
133
|
+
), ClientSession(read_stream, write_stream) as session:
|
|
134
|
+
await session.initialize()
|
|
135
|
+
result = await session.call_tool(
|
|
136
|
+
name=tool_name,
|
|
137
|
+
arguments=arguments or {},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
extracted_data: list[Any] = []
|
|
141
|
+
for content in result.content:
|
|
142
|
+
if hasattr(content, "text"):
|
|
143
|
+
try:
|
|
144
|
+
parsed = json.loads(content.text)
|
|
145
|
+
extracted_data.append(parsed)
|
|
146
|
+
except (json.JSONDecodeError, TypeError):
|
|
147
|
+
extracted_data.append(content.text)
|
|
148
|
+
else:
|
|
149
|
+
extracted_data.append(content)
|
|
150
|
+
|
|
151
|
+
if len(extracted_data) == 1:
|
|
152
|
+
return extracted_data[0]
|
|
153
|
+
return extracted_data
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Notion connector for searching and indexing workspace documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from synapse.connectors.base import BaseConnector
|
|
11
|
+
from synapse.oauth.notion import NotionAuthService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotionConnector(BaseConnector):
|
|
15
|
+
"""Notion workspace connector for querying pages, databases, and notes.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
auth: NotionAuthService managing API authentication headers.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
auth_service: NotionAuthService | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Initializes the NotionConnector.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
api_key: Optional Notion API integration token.
|
|
30
|
+
auth_service: Optional pre-configured NotionAuthService instance.
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(
|
|
33
|
+
name="notion",
|
|
34
|
+
description="Notion workspace pages, notes, wiki docs, and databases",
|
|
35
|
+
intent_regex=(
|
|
36
|
+
r"\b(notion|wiki|workspace\s+page|workspace\s+pages|notion\s+page|"
|
|
37
|
+
r"notion\s+pages|notion\s+note|notion\s+notes|notion\s+database)\b"
|
|
38
|
+
),
|
|
39
|
+
default_tool="search_notion_pages",
|
|
40
|
+
)
|
|
41
|
+
self.auth = auth_service or NotionAuthService(api_key=api_key)
|
|
42
|
+
|
|
43
|
+
def is_authenticated(self) -> bool:
|
|
44
|
+
"""Checks if a Notion API token is available."""
|
|
45
|
+
return self.auth.is_authenticated()
|
|
46
|
+
|
|
47
|
+
async def list_tools(self) -> list[dict[str, Any]]:
|
|
48
|
+
"""Returns the Notion tool schemas."""
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
"server": self.name,
|
|
52
|
+
"name": "search_notion_pages",
|
|
53
|
+
"description": "Search Notion workspace pages and databases matching a query.",
|
|
54
|
+
"inputSchema": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"query": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"default": "",
|
|
60
|
+
"description": "Title or keyword substring to search.",
|
|
61
|
+
},
|
|
62
|
+
"max_results": {
|
|
63
|
+
"type": "integer",
|
|
64
|
+
"default": 5,
|
|
65
|
+
"description": "Maximum number of Notion items to return.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def default_args_builder(self, user_message: str) -> dict[str, Any]:
|
|
73
|
+
"""Strips conversational noise from user prompt for Notion search."""
|
|
74
|
+
text = (user_message or "").strip()
|
|
75
|
+
cleaned = re.sub(
|
|
76
|
+
r"\b(check|find|search|show|my|in|on|from|notion|pages|page|notes|note|database|workspace|docs)\b",
|
|
77
|
+
" ",
|
|
78
|
+
text,
|
|
79
|
+
flags=re.IGNORECASE,
|
|
80
|
+
)
|
|
81
|
+
cleaned = " ".join(cleaned.split()).strip("?.!,")
|
|
82
|
+
return {"query": cleaned[:80], "max_results": 5}
|
|
83
|
+
|
|
84
|
+
async def call_tool(
|
|
85
|
+
self,
|
|
86
|
+
tool_name: str,
|
|
87
|
+
arguments: dict[str, Any] | None = None,
|
|
88
|
+
) -> Any:
|
|
89
|
+
"""Executes a Notion tool."""
|
|
90
|
+
args = arguments or {}
|
|
91
|
+
if tool_name == "search_notion_pages":
|
|
92
|
+
query = str(args.get("query", ""))
|
|
93
|
+
max_results = int(args.get("max_results", 5))
|
|
94
|
+
return await self.search_notion_pages(
|
|
95
|
+
query=query, max_results=max_results
|
|
96
|
+
)
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Unknown tool '{tool_name}' for connector '{self.name}'"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def search_notion_pages(
|
|
102
|
+
self,
|
|
103
|
+
query: str = "",
|
|
104
|
+
max_results: int = 5,
|
|
105
|
+
) -> list[dict[str, Any]]:
|
|
106
|
+
"""Executes a live search against the Notion v1 REST API."""
|
|
107
|
+
if not self.is_authenticated():
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
payload: dict[str, Any] = {"page_size": max(1, min(max_results, 20))}
|
|
111
|
+
if query.strip():
|
|
112
|
+
payload["query"] = query.strip()
|
|
113
|
+
|
|
114
|
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
115
|
+
res = await client.post(
|
|
116
|
+
f"{self.auth.base_url}/search",
|
|
117
|
+
headers=self.auth.get_headers(),
|
|
118
|
+
json=payload,
|
|
119
|
+
)
|
|
120
|
+
res.raise_for_status()
|
|
121
|
+
data = res.json()
|
|
122
|
+
|
|
123
|
+
results: list[dict[str, Any]] = []
|
|
124
|
+
for item in data.get("results", []):
|
|
125
|
+
item_id = item.get("id", "")
|
|
126
|
+
obj_type = item.get("object", "page")
|
|
127
|
+
url = item.get("url", "")
|
|
128
|
+
last_edited = item.get("last_edited_time", "")
|
|
129
|
+
|
|
130
|
+
title = "Untitled Notion Page"
|
|
131
|
+
props = item.get("properties", {})
|
|
132
|
+
for prop_val in props.values():
|
|
133
|
+
if (
|
|
134
|
+
isinstance(prop_val, dict)
|
|
135
|
+
and prop_val.get("type") == "title"
|
|
136
|
+
):
|
|
137
|
+
title_arr = prop_val.get("title", [])
|
|
138
|
+
if title_arr:
|
|
139
|
+
title = (
|
|
140
|
+
"".join(
|
|
141
|
+
t.get("plain_text", "") for t in title_arr
|
|
142
|
+
).strip()
|
|
143
|
+
or title
|
|
144
|
+
)
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
results.append({
|
|
148
|
+
"id": item_id,
|
|
149
|
+
"title": title,
|
|
150
|
+
"type": obj_type,
|
|
151
|
+
"url": url,
|
|
152
|
+
"date": last_edited,
|
|
153
|
+
"content": f"Notion {obj_type}: {title} (URL: {url})",
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Developer-friendly aliases matching different casing
|
|
160
|
+
NotionConnectors = NotionConnector
|
|
161
|
+
NotionsConnector = NotionConnector
|