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
synapse/app.py ADDED
@@ -0,0 +1,942 @@
1
+ """Core application orchestrator and framework runner for Synapse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import AsyncGenerator, Sequence
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import uvicorn
12
+ from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import HTMLResponse, StreamingResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+ from pydantic import BaseModel, Field
17
+
18
+ from synapse.config import DEFAULT_DATA_DIR
19
+ from synapse.connectors.base import BaseConnector
20
+ from synapse.connectors.gmail import GmailConnector
21
+ from synapse.database.base import BaseVectorDB
22
+ from synapse.database.chroma import chromadb
23
+ from synapse.export.mcp_exporter import MCPExporter
24
+ from synapse.graph.base import BaseGraphStore
25
+ from synapse.graph.networkx_store import networkx
26
+ from synapse.inspector.template import render_inspector_html
27
+ from synapse.models.base import BaseModelProvider
28
+ from synapse.models.ollama import OllamaModel
29
+ from synapse.rag.hybrid_rag import HybridRAGEngine
30
+ from synapse.resource.resource import Resource
31
+ from synapse.skills.skill import Skill, makeskills
32
+ from synapse.storage.graph_exporter import GraphExporter
33
+ from synapse.storage.vector_store import VectorStorage
34
+
35
+
36
+ class ChatMessagePayload(BaseModel):
37
+ """Schema for individual conversation turns."""
38
+
39
+ role: str = Field(..., description="Role: 'user', 'assistant', or 'system'.")
40
+ content: str = Field(..., description="Message text content.")
41
+
42
+
43
+ class ChatRequestPayload(BaseModel):
44
+ """Schema for POST /chat request bodies."""
45
+
46
+ message: str = Field(..., description="User prompt string.")
47
+ history: list[ChatMessagePayload] | None = Field(
48
+ default_factory=list, description="Conversation history."
49
+ )
50
+ use_rag: bool = Field(
51
+ default=True, description="Enable Hybrid RAG and Multi-MCP tool calls."
52
+ )
53
+
54
+
55
+ class SyncRequestPayload(BaseModel):
56
+ """Schema for manual connector synchronization."""
57
+
58
+ query: str | None = Field(default=None, description="Optional search query filter.")
59
+ max_results: int = Field(default=5, description="Maximum items to index.")
60
+ extract_graph: bool = Field(default=True, description="Extract semantic knowledge graph triples.")
61
+ tool_name: str | None = Field(default=None, description="Optional explicit tool name.")
62
+ arguments: dict[str, Any] | None = Field(default=None, description="Optional tool arguments.")
63
+
64
+
65
+ class BaseApp:
66
+ """The primary Synapse Application class.
67
+
68
+ Configures models, connectors, local vector storage, knowledge graphs,
69
+ and mounts the interactive inspector web dashboard.
70
+
71
+ Example:
72
+ ```python
73
+ from synapse import BaseApp
74
+ from synapse.models import OllamaModel
75
+ from synapse.connectors import GmailConnector, NotionConnector
76
+
77
+ app = BaseApp(
78
+ model=OllamaModel(),
79
+ connectors=[
80
+ GmailConnector(credentials_path="credentials.json"),
81
+ NotionConnector(),
82
+ ],
83
+ )
84
+
85
+ if __name__ == "__main__":
86
+ app.run()
87
+ ```
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ title: str = "Synapse AI",
93
+ description: str = "Multi-MCP & Hybrid RAG Framework",
94
+ version: str = "0.2.4",
95
+ model: BaseModelProvider | None = None,
96
+ connectors: list[BaseConnector] | None = None,
97
+ skills: Skill | Sequence[Skill] | str | Path | None = None,
98
+ resource: Resource | Sequence[Resource] | str | Path | Any | None = None,
99
+ resources: Sequence[Resource] | None = None,
100
+ persistent_storage: VectorStorage | BaseVectorDB | bool | None = None,
101
+ Visualize_graph: BaseGraphStore | bool | None = None,
102
+ vector_store: VectorStorage | BaseVectorDB | bool | None = None,
103
+ graph_store: BaseGraphStore | bool | None = None,
104
+ visualize_graph: BaseGraphStore | bool | None = None,
105
+ data_dir: str | Path | None = None,
106
+ ) -> None:
107
+ """Initializes the BaseApp instance.
108
+
109
+ Args:
110
+ title: Application title displayed in API docs and inspector.
111
+ description: Application summary string.
112
+ version: Semantic version string.
113
+ model: BaseModelProvider instance (defaults to OllamaModel).
114
+ connectors: List of BaseConnector instances (e.g. GmailConnector, NotionConnector).
115
+ skills: Skill, list of Skills, or path(s) to markdown skill files.
116
+ resource: Resource instance, document(s), or file path(s) to index.
117
+ resources: Optional list of Resource instances.
118
+ persistent_storage: Optional vector storage instance (e.g. vectorStorage(database=chromadb(...)))
119
+ or True for default ChromaDB. Defaults to None (storage disabled, 0 files created).
120
+ Visualize_graph: Optional knowledge graph store (e.g. networkx(...) or neo4j(...))
121
+ or True for default NetworkX graph. Defaults to None (graph disabled, 0 files created).
122
+ vector_store: Alias for persistent_storage.
123
+ graph_store: Alias for Visualize_graph.
124
+ visualize_graph: Lowercase alias for Visualize_graph.
125
+ data_dir: Optional custom storage directory.
126
+ """
127
+ self.title = title
128
+ self.description = description
129
+ self.version = version
130
+
131
+ resolved_data_dir = Path(data_dir or DEFAULT_DATA_DIR)
132
+ self.data_dir = resolved_data_dir
133
+
134
+ self.model = model or OllamaModel()
135
+
136
+ # Resolve vector store (supports persistent_storage or vector_store)
137
+ storage_arg = persistent_storage if persistent_storage is not None else vector_store
138
+ if isinstance(storage_arg, VectorStorage):
139
+ self.vector_store: VectorStorage | None = storage_arg
140
+ elif isinstance(storage_arg, BaseVectorDB):
141
+ self.vector_store = VectorStorage(database=storage_arg)
142
+ elif storage_arg is True:
143
+ resolved_data_dir.mkdir(parents=True, exist_ok=True)
144
+ self.vector_store = VectorStorage(
145
+ database=chromadb(path=self.data_dir / "chroma_db")
146
+ )
147
+ else:
148
+ self.vector_store = None
149
+
150
+ # Resolve graph store (supports Visualize_graph, visualize_graph, or graph_store)
151
+ graph_arg = (
152
+ Visualize_graph
153
+ if Visualize_graph is not None
154
+ else (visualize_graph if visualize_graph is not None else graph_store)
155
+ )
156
+ if isinstance(graph_arg, BaseGraphStore):
157
+ self.graph_store: BaseGraphStore | None = graph_arg
158
+ elif graph_arg is True:
159
+ resolved_data_dir.mkdir(parents=True, exist_ok=True)
160
+ self.graph_store = networkx(filepath=self.data_dir / "graph.json")
161
+ else:
162
+ self.graph_store = None
163
+
164
+ if data_dir is not None:
165
+ resolved_data_dir.mkdir(parents=True, exist_ok=True)
166
+
167
+ self.rag_engine = HybridRAGEngine(
168
+ vector_store=self.vector_store,
169
+ graph_store=self.graph_store,
170
+ )
171
+
172
+ self._connectors: dict[str, BaseConnector] = {}
173
+ for conn in connectors or []:
174
+ self.add_connector(conn)
175
+
176
+ self.sync_state: dict[str, Any] = {
177
+ "is_syncing": False,
178
+ "source": None,
179
+ "progress": "",
180
+ "items_synced": 0,
181
+ "last_synced_at": None,
182
+ "error": None,
183
+ }
184
+
185
+ self._skills: list[Skill] = []
186
+ if skills is not None:
187
+ if isinstance(skills, (list, tuple)):
188
+ for s in skills:
189
+ self.add_skill(s)
190
+ else:
191
+ self.add_skill(skills)
192
+
193
+ self._resources: list[Resource] = []
194
+ if resource is not None:
195
+ if isinstance(resource, (list, tuple)):
196
+ for r in resource:
197
+ self.add_resource(r)
198
+ else:
199
+ self.add_resource(resource)
200
+
201
+ if resources is not None:
202
+ for r in resources:
203
+ self.add_resource(r)
204
+
205
+ self._fastapi_app: FastAPI | None = None
206
+
207
+ def add_connector(self, connector: BaseConnector) -> None:
208
+ """Registers a connector into this application."""
209
+ self._connectors[connector.name] = connector
210
+
211
+ def get_connector(self, name: str) -> BaseConnector | None:
212
+ """Retrieves a registered connector by name."""
213
+ return self._connectors.get(name)
214
+
215
+ def list_connectors(self) -> list[BaseConnector]:
216
+ """Returns all registered connector instances."""
217
+ return list(self._connectors.values())
218
+
219
+ @property
220
+ def connectors(self) -> list[BaseConnector]:
221
+ """Returns all registered connector instances."""
222
+ return self.list_connectors()
223
+
224
+ def add_skill(self, skill: Skill | str | Path) -> Skill:
225
+ """Registers a Skill into this application."""
226
+ sk = skill if isinstance(skill, Skill) else makeskills(skill)
227
+ self._skills.append(sk)
228
+ return sk
229
+
230
+ def list_skills(self) -> list[Skill]:
231
+ """Returns all registered skills."""
232
+ return list(self._skills)
233
+
234
+ @property
235
+ def skills(self) -> list[Skill]:
236
+ """Returns all registered skills."""
237
+ return self.list_skills()
238
+
239
+ def get_skill(self, name: str) -> Skill | None:
240
+ """Retrieves a registered skill by name."""
241
+ for s in self._skills:
242
+ if s.name.lower() == name.lower():
243
+ return s
244
+ return None
245
+
246
+ def add_resource(self, resource: Resource | str | Path | Any) -> Resource:
247
+ """Registers and indexes a Resource into vector storage and knowledge graph."""
248
+ res = resource if isinstance(resource, Resource) else Resource(documents=resource)
249
+ self._resources.append(res)
250
+ res.index_into(self.vector_store, self.graph_store)
251
+ if self.vector_store is not None:
252
+ try:
253
+ self.sync_state["items_synced"] = max(
254
+ self.sync_state.get("items_synced", 0), self.vector_store.count()
255
+ )
256
+ except Exception:
257
+ pass # nosec B110
258
+ return res
259
+
260
+ def list_resources(self) -> list[Resource]:
261
+ """Returns all registered resource collections."""
262
+ return list(self._resources)
263
+
264
+ @property
265
+ def resources(self) -> list[Resource]:
266
+ """Returns all registered resource collections."""
267
+ return self.list_resources()
268
+
269
+
270
+ def get_resource(self, name: str) -> Resource | None:
271
+ """Retrieves a resource collection by name."""
272
+ for r in self._resources:
273
+ if r.name.lower() == name.lower():
274
+ return r
275
+ return None
276
+
277
+ async def execute_tool_and_index(
278
+ self,
279
+ connector_name: str,
280
+ tool_name: str,
281
+ arguments: dict[str, Any] | None = None,
282
+ extract_graph: bool = False,
283
+ ) -> dict[str, Any]:
284
+ """Executes a connector tool and indexes its results into vector and graph stores if enabled."""
285
+ conn = self.get_connector(connector_name)
286
+ if not conn:
287
+ raise KeyError(f"Connector '{connector_name}' is not registered.")
288
+
289
+ args = arguments or {}
290
+ raw_result = await conn.call_tool(tool_name, args)
291
+
292
+ records: list[dict[str, Any]] = []
293
+ if isinstance(raw_result, list):
294
+ records = [r for r in raw_result if isinstance(r, dict)]
295
+ elif isinstance(raw_result, dict):
296
+ records = [raw_result]
297
+
298
+ formatted_items: list[str] = []
299
+ live_chunks: list[dict[str, Any]] = []
300
+ live_triples: list[str] = []
301
+
302
+ for idx, rec in enumerate(records, start=1):
303
+ # 1. Chunk and upsert to vector store
304
+ if self.vector_store is not None:
305
+ docs = conn.chunk_record(tool_name, rec)
306
+ if docs:
307
+ self.vector_store.add_documents(docs)
308
+
309
+ # 2. Format context and graph triple
310
+ summary_line, chunk_meta, triple = conn.format_record(tool_name, rec, index=idx)
311
+ formatted_items.append(summary_line)
312
+ live_chunks.append(chunk_meta)
313
+ live_triples.append(triple)
314
+
315
+ # 3. Add triple to knowledge graph
316
+ if self.graph_store is not None:
317
+ parts = [p.strip() for p in triple.split("->")]
318
+ if len(parts) == 3:
319
+ src, rel, tgt = parts
320
+ self.graph_store.add_relation(
321
+ source=src,
322
+ relation=rel,
323
+ target=tgt,
324
+ metadata={"source": connector_name},
325
+ )
326
+
327
+ # 4. Optional deep LLM triple extraction
328
+ if extract_graph and self.graph_store is not None:
329
+ body = str(rec.get("body") or rec.get("content") or "")
330
+ if body:
331
+ deep_triples = await self.model.extract_triples(body[:1200])
332
+ for t in deep_triples:
333
+ self.graph_store.add_relation(
334
+ source=t["source"],
335
+ relation=t["relation"],
336
+ target=t["target"],
337
+ source_type=t.get("source_type", "Default"),
338
+ target_type=t.get("target_type", "Default"),
339
+ )
340
+
341
+ try:
342
+ if self.vector_store is not None:
343
+ self.sync_state["items_synced"] = max(
344
+ self.sync_state.get("items_synced", 0), self.vector_store.count()
345
+ )
346
+ else:
347
+ self.sync_state["items_synced"] = self.sync_state.get("items_synced", 0) + len(records)
348
+ except Exception:
349
+ self.sync_state["items_synced"] = self.sync_state.get("items_synced", 0) + len(records)
350
+
351
+ arg_str = ", ".join(f"{k}='{v}'" for k, v in args.items())
352
+ if formatted_items:
353
+ section_text = (
354
+ f"### LIVE {connector_name.upper()} TOOL RESULTS "
355
+ f"({tool_name}, {arg_str}, {len(records)} item(s)):\n"
356
+ + "\n\n".join(formatted_items)
357
+ )
358
+ else:
359
+ section_text = (
360
+ f"### LIVE {connector_name.upper()} TOOL RESULTS "
361
+ f"({tool_name}, {arg_str}):\n0 matching items found."
362
+ )
363
+
364
+ return {
365
+ "connector": connector_name,
366
+ "tool": tool_name,
367
+ "arguments": args,
368
+ "records": records,
369
+ "section_text": section_text,
370
+ "live_chunks": live_chunks,
371
+ "live_triples": live_triples,
372
+ }
373
+
374
+ async def route_and_execute(self, user_message: str) -> dict[str, Any]:
375
+ """Routes a prompt to appropriate connector tools and executes them."""
376
+ clean_msg = (user_message or "").strip().lower()
377
+ conversational_greetings = {
378
+ "hi", "hello", "hey", "hola", "greetings", "good morning",
379
+ "good afternoon", "good evening", "how are you", "who are you",
380
+ "what can you do", "which model is this", "which model is getting used",
381
+ "what model is this", "what model is being used", "help", "thanks",
382
+ "thank you", "bye", "goodbye", "test", "ping",
383
+ }
384
+ if clean_msg in conversational_greetings:
385
+ return {
386
+ "tool_calls": [],
387
+ "context_sections": [],
388
+ "live_chunks": [],
389
+ "live_triples": [],
390
+ }
391
+
392
+ candidate_tools: list[dict[str, Any]] = []
393
+ fallback_calls: list[dict[str, Any]] = []
394
+ unauthenticated_connectors: list[str] = []
395
+
396
+ for conn in self._connectors.values():
397
+ if not conn.matches_intent(user_message):
398
+ continue
399
+ if not conn.is_authenticated():
400
+ unauthenticated_connectors.append(conn.name)
401
+ continue
402
+
403
+ tools = await conn.list_tools()
404
+ for t in tools:
405
+ candidate_tools.append({
406
+ "server": conn.name,
407
+ "name": t.get("name", conn.default_tool),
408
+ "description": t.get("description", conn.description),
409
+ "inputSchema": t.get("inputSchema", {}),
410
+ })
411
+
412
+ if conn.default_tool and conn.intent_regex:
413
+ fallback_calls.append({
414
+ "server": conn.name,
415
+ "tool": conn.default_tool,
416
+ "arguments": conn.default_args_builder(user_message),
417
+ })
418
+
419
+ unauth_notes: list[str] = []
420
+ for c_name in unauthenticated_connectors:
421
+ unauth_notes.append(
422
+ f"### CONNECTOR STATUS NOTE ({c_name.upper()}):\n"
423
+ f"The user's query relates to '{c_name}', but this connector is not currently authenticated. "
424
+ f"Instruct the user to log in or configure credentials in the application Settings."
425
+ )
426
+
427
+ if not candidate_tools:
428
+ return {
429
+ "tool_calls": [],
430
+ "context_sections": unauth_notes,
431
+ "live_chunks": [],
432
+ "live_triples": [],
433
+ }
434
+
435
+ decided = await self.model.decide_tool_calls(
436
+ user_message=user_message,
437
+ candidate_tools=candidate_tools,
438
+ fallback_calls=fallback_calls,
439
+ )
440
+
441
+ context_sections: list[str] = list(unauth_notes)
442
+ live_chunks: list[dict[str, Any]] = []
443
+ live_triples: list[str] = []
444
+ executed: list[dict[str, Any]] = []
445
+
446
+ for call in decided:
447
+ srv = call.get("server", "")
448
+ tl = call.get("tool", "")
449
+ args = dict(call.get("arguments") or {})
450
+
451
+ conn = self.get_connector(srv)
452
+ if conn:
453
+ defaults = conn.default_args_builder(user_message)
454
+ for k, v in defaults.items():
455
+ if k not in args or args[k] in (None, ""):
456
+ args[k] = v
457
+
458
+ try:
459
+ res = await self.execute_tool_and_index(
460
+ connector_name=srv,
461
+ tool_name=tl,
462
+ arguments=args,
463
+ extract_graph=False,
464
+ )
465
+ context_sections.append(res["section_text"])
466
+ live_chunks.extend(res["live_chunks"])
467
+ live_triples.extend(res["live_triples"])
468
+ executed.append({
469
+ "server": srv,
470
+ "tool": tl,
471
+ "arguments": args,
472
+ "items_fetched": len(res["records"]),
473
+ })
474
+ except Exception as err:
475
+ context_sections.append(
476
+ f"### {srv.upper()} NOTE:\nLive fetch ({tl}) error: {err}."
477
+ )
478
+
479
+ return {
480
+ "tool_calls": executed,
481
+ "context_sections": context_sections,
482
+ "live_chunks": live_chunks,
483
+ "live_triples": live_triples,
484
+ }
485
+
486
+ async def chat_stream(
487
+ self,
488
+ message: str,
489
+ history: list[ChatMessagePayload] | None = None,
490
+ use_rag: bool = True,
491
+ ) -> AsyncGenerator[dict[str, Any], None]:
492
+ """Streams a chat response token-by-token with live RAG and tool events."""
493
+ context_sections: list[str] = []
494
+ live_tool_chunks: list[dict[str, Any]] = []
495
+ live_tool_triples: list[str] = []
496
+ executed_tools: list[dict[str, Any]] = []
497
+
498
+ # 1. On-demand tool calling
499
+ try:
500
+ batch = await self.route_and_execute(message)
501
+ executed_tools = batch.get("tool_calls", [])
502
+ context_sections.extend(batch.get("context_sections", []))
503
+ live_tool_chunks.extend(batch.get("live_chunks", []))
504
+ live_tool_triples.extend(batch.get("live_triples", []))
505
+ except Exception as e:
506
+ context_sections.append(f"### TOOL ROUTER NOTE:\nError: {e}")
507
+
508
+ # 2. Local Hybrid RAG retrieval
509
+ if use_rag or live_tool_chunks:
510
+ retrieval = self.rag_engine.retrieve_context(message)
511
+ rag_str = retrieval.get("formatted_context", "")
512
+ if rag_str and rag_str != "No relevant past context or graph facts found.":
513
+ context_sections.append(rag_str)
514
+
515
+ merged_chunks = list(live_tool_chunks)
516
+ for c in retrieval.get("chunks", []):
517
+ merged_chunks.append(c)
518
+
519
+ merged_triples = list(
520
+ dict.fromkeys(live_tool_triples + retrieval.get("graph_triples", []))
521
+ )
522
+
523
+ first_call = executed_tools[0] if executed_tools else None
524
+ context_data = {
525
+ "chunks": merged_chunks[:6],
526
+ "graph_triples": merged_triples[:8],
527
+ "entities_detected": retrieval.get("entities_detected", []),
528
+ "tool_called": first_call.get("tool") if first_call else None,
529
+ "tool_calls": executed_tools,
530
+ }
531
+ yield {"type": "context", "data": context_data}
532
+
533
+ context_str = (
534
+ "\n\n".join(context_sections)
535
+ if context_sections
536
+ else "No external context required."
537
+ )
538
+
539
+ skills_section = ""
540
+ if self._skills:
541
+ skills_block = "\n\n".join(s.format_prompt() for s in self._skills)
542
+ skills_section = (
543
+ f"\n\n=== SPECIALIZED SKILLS & GUIDELINES ===\n"
544
+ f"{skills_block}\n"
545
+ f"========================================"
546
+ )
547
+
548
+ system_prompt = (
549
+ f"You are {self.title}, an AI assistant with access to connected tools and knowledge.\n"
550
+ "Use the live tool results, specialized skills, and context below to answer accurately and concisely. "
551
+ "If tools were executed in the context above, confirm their execution and report the results to the user.\n\n"
552
+ f"=== CONTEXT ===\n{context_str}\n==============="
553
+ f"{skills_section}"
554
+ )
555
+
556
+ messages = [{"role": "system", "content": system_prompt}]
557
+ for turn in history or []:
558
+ messages.append({"role": turn.role, "content": turn.content})
559
+ messages.append({"role": "user", "content": message})
560
+
561
+ async for token in self.model.chat_stream(messages):
562
+ yield {"type": "token", "data": token}
563
+
564
+ yield {"type": "done", "data": {}}
565
+
566
+ async def chat(
567
+ self,
568
+ message: str,
569
+ history: list[ChatMessagePayload] | None = None,
570
+ use_rag: bool = True,
571
+ ) -> str:
572
+ """Executes a complete non-streaming chat turn and returns the response string."""
573
+ buf = []
574
+ async for event in self.chat_stream(message, history, use_rag):
575
+ if event["type"] == "token":
576
+ buf.append(event["data"])
577
+ return "".join(buf)
578
+
579
+ @property
580
+ def fastapi_app(self) -> FastAPI:
581
+ """Builds and returns the configured FastAPI server."""
582
+ if self._fastapi_app is not None:
583
+ return self._fastapi_app
584
+
585
+ api = FastAPI(
586
+ title=self.title,
587
+ description=self.description,
588
+ version=self.version,
589
+ )
590
+
591
+ api.add_middleware(
592
+ CORSMiddleware,
593
+ allow_origins=["*"],
594
+ allow_credentials=True,
595
+ allow_methods=["*"],
596
+ allow_headers=["*"],
597
+ )
598
+
599
+ app_ref = self
600
+
601
+ # ---------------- Chat Route ----------------
602
+ @api.post("/chat", tags=["Chat"])
603
+ async def chat_route(req: ChatRequestPayload):
604
+ async def event_generator():
605
+ try:
606
+ async for ev in app_ref.chat_stream(
607
+ req.message, req.history, req.use_rag
608
+ ):
609
+ if ev["type"] == "context":
610
+ yield f"event: context\ndata: {json.dumps(ev['data'])}\n\n"
611
+ elif ev["type"] == "token":
612
+ yield f"event: delta\ndata: {json.dumps({'token': ev['data']})}\n\n"
613
+ elif ev["type"] == "done":
614
+ yield "event: done\ndata: {}\n\n"
615
+ except Exception as err:
616
+ yield f"event: error\ndata: {json.dumps({'stage': 'chat', 'detail': str(err)})}\n\n"
617
+
618
+ return StreamingResponse(
619
+ event_generator(),
620
+ media_type="text/event-stream",
621
+ headers={
622
+ "Cache-Control": "no-cache",
623
+ "Connection": "keep-alive",
624
+ "X-Accel-Buffering": "no",
625
+ },
626
+ )
627
+
628
+ # ---------------- Connectors & Sync Routes ----------------
629
+ @api.get("/sync/servers", tags=["Sync"])
630
+ @api.get("/connectors", tags=["Sync"])
631
+ def list_servers():
632
+ return {
633
+ "servers": [
634
+ {
635
+ "name": c.name,
636
+ "description": c.description,
637
+ "is_ready": c.is_authenticated(),
638
+ "default_tool": c.default_tool,
639
+ }
640
+ for c in app_ref.list_connectors()
641
+ ]
642
+ }
643
+
644
+ @api.get("/sync/status", tags=["Sync"])
645
+ def sync_status():
646
+ return app_ref.sync_state
647
+
648
+ @api.post("/sync/{connector_name}", tags=["Sync"])
649
+ async def sync_endpoint(
650
+ connector_name: str,
651
+ req: SyncRequestPayload,
652
+ background_tasks: BackgroundTasks,
653
+ ):
654
+ conn = app_ref.get_connector(connector_name)
655
+ if not conn:
656
+ raise HTTPException(status_code=404, detail=f"Connector '{connector_name}' not found.")
657
+
658
+ tool = req.tool_name or conn.default_tool
659
+ if req.arguments:
660
+ args = req.arguments
661
+ elif req.query:
662
+ args = {"query": req.query, "max_results": req.max_results}
663
+ elif connector_name == "gmail":
664
+ args = {"query": "label:INBOX", "max_results": req.max_results}
665
+ else:
666
+ args = {"max_results": req.max_results}
667
+
668
+ async def _bg_task():
669
+ app_ref.sync_state["is_syncing"] = True
670
+ app_ref.sync_state["source"] = connector_name
671
+ app_ref.sync_state["progress"] = f"Syncing {connector_name}..."
672
+ try:
673
+ await app_ref.execute_tool_and_index(
674
+ connector_name=connector_name,
675
+ tool_name=tool,
676
+ arguments=args,
677
+ extract_graph=req.extract_graph,
678
+ )
679
+ app_ref.sync_state["progress"] = "Sync completed successfully."
680
+ app_ref.sync_state["last_synced_at"] = datetime.now(timezone.utc).isoformat()
681
+ except Exception as err:
682
+ app_ref.sync_state["error"] = str(err)
683
+ app_ref.sync_state["progress"] = "Sync failed."
684
+ finally:
685
+ app_ref.sync_state["is_syncing"] = False
686
+
687
+ background_tasks.add_task(_bg_task)
688
+ return {"status": "started", "message": f"Background sync initiated for '{connector_name}'."}
689
+
690
+ # ---------------- Auth Routes ----------------
691
+ @api.get("/auth/status", tags=["Auth"])
692
+ def auth_status():
693
+ out = {}
694
+ for c in app_ref.list_connectors():
695
+ has_creds = False
696
+ if hasattr(c, "oauth") and hasattr(c.oauth, "has_credentials"):
697
+ has_creds = c.oauth.has_credentials()
698
+ elif hasattr(c, "has_credentials"):
699
+ has_creds = c.has_credentials() if callable(c.has_credentials) else bool(c.has_credentials)
700
+ else:
701
+ has_creds = c.is_authenticated()
702
+
703
+ is_auth = c.is_authenticated()
704
+ if is_auth:
705
+ status_str = "connected"
706
+ elif has_creds:
707
+ status_str = "needs_login"
708
+ else:
709
+ status_str = "unconfigured"
710
+
711
+ out[c.name] = {
712
+ "status": status_str,
713
+ "has_credentials": bool(has_creds),
714
+ "is_authenticated": bool(is_auth),
715
+ }
716
+ return out
717
+
718
+ @api.post("/auth/gmail/upload-credentials", tags=["Auth"])
719
+ async def upload_creds(file: UploadFile = File(...)):
720
+ content = await file.read()
721
+ gmail_conn = app_ref.get_connector("gmail")
722
+ if isinstance(gmail_conn, GmailConnector):
723
+ saved_path = gmail_conn.oauth.save_credentials(content)
724
+ return {"status": "saved", "path": str(saved_path)}
725
+ return {"status": "error", "message": "Gmail connector not registered."}
726
+
727
+ @api.post("/auth/gmail/login", tags=["Auth"])
728
+ def login_gmail_route():
729
+ gmail_conn = app_ref.get_connector("gmail")
730
+ if isinstance(gmail_conn, GmailConnector):
731
+ creds = gmail_conn.oauth.authenticate()
732
+ return {"status": "success", "authenticated": creds.valid}
733
+ return {"status": "error", "message": "Gmail connector not registered."}
734
+
735
+ @api.post("/auth/gmail/disconnect", tags=["Auth"])
736
+ def disconnect_gmail_route():
737
+ gmail_conn = app_ref.get_connector("gmail")
738
+ if isinstance(gmail_conn, GmailConnector):
739
+ gmail_conn.oauth.disconnect()
740
+ return {"status": "disconnected"}
741
+
742
+ # ---------------- Graph Routes ----------------
743
+ @api.get("/graph/data", tags=["Graph"])
744
+ def graph_data_route():
745
+ palette = {
746
+ "Person": "#a1a1aa",
747
+ "Email": "#e4e4e7",
748
+ "Topic": "#71717a",
749
+ "Organization": "#d4d4d8",
750
+ "Date": "#52525b",
751
+ "Default": "#71717a",
752
+ }
753
+ if app_ref.graph_store is None:
754
+ return {
755
+ "nodes": [],
756
+ "links": [],
757
+ "stats": {"total_nodes": 0, "total_edges": 0},
758
+ "tokens": {
759
+ "canvas": "#09090b",
760
+ "palette": palette,
761
+ },
762
+ }
763
+
764
+ if hasattr(app_ref.graph_store, "load"):
765
+ app_ref.graph_store.load()
766
+ payload = app_ref.graph_store.get_3d_payload()
767
+ stats = (
768
+ app_ref.graph_store.get_stats()
769
+ if hasattr(app_ref.graph_store, "get_stats")
770
+ else {
771
+ "total_nodes": len(payload.get("nodes", [])),
772
+ "total_edges": len(payload.get("links", [])),
773
+ }
774
+ )
775
+ return {
776
+ "nodes": payload["nodes"],
777
+ "links": payload["links"],
778
+ "stats": stats,
779
+ "tokens": {
780
+ "canvas": "#09090b",
781
+ "palette": palette,
782
+ },
783
+ }
784
+
785
+ @api.get("/graph/export", response_class=HTMLResponse, tags=["Graph"])
786
+ def graph_export_route():
787
+ if app_ref.graph_store is None:
788
+ return HTMLResponse(
789
+ content=(
790
+ "<html><body style='background:#09090b;color:#e4e4e7;font-family:sans-serif;padding:40px;'>"
791
+ "<h2>Knowledge Graph Not Configured</h2>"
792
+ "<p>Provide <code>Visualize_graph=networkx(...)</code> or <code>in_memory_graph()</code> to BaseApp to enable graph visualization.</p>"
793
+ "</body></html>"
794
+ )
795
+ )
796
+ html_content = GraphExporter.render_html(app_ref.graph_store)
797
+ try:
798
+ if app_ref.data_dir.exists():
799
+ export_path = app_ref.data_dir / "graph_preview.html"
800
+ export_path.write_text(html_content, encoding="utf-8")
801
+ except Exception:
802
+ pass # nosec B110
803
+ return HTMLResponse(content=html_content)
804
+
805
+ # ---------------- MCP Export Route ----------------
806
+ @api.get("/mcp/export", tags=["MCP"])
807
+ def mcp_export_route():
808
+ return {
809
+ "mcpServers": MCPExporter.to_claude_desktop_config(app_ref)["mcpServers"],
810
+ "tools": MCPExporter.to_tools_list(app_ref),
811
+ }
812
+
813
+ # ---------------- Inspector Dashboard Route ----------------
814
+ @api.get("/info", tags=["Inspector"])
815
+ def app_info():
816
+ provider = type(app_ref.model).__name__.replace("Model", "")
817
+ m_name = getattr(app_ref.model, "model_name", "Unknown")
818
+ return {
819
+ "title": app_ref.title,
820
+ "version": app_ref.version,
821
+ "model_provider": provider,
822
+ "model_name": m_name,
823
+ "model_display": f"{provider} ({m_name})",
824
+ "connectors_count": len(app_ref._connectors),
825
+ "skills_count": len(app_ref._skills),
826
+ "resources_count": len(app_ref._resources),
827
+ }
828
+
829
+ @api.get("/", response_class=HTMLResponse, tags=["Inspector"])
830
+ @api.get("/inspector", response_class=HTMLResponse, tags=["Inspector"])
831
+ def inspector_dashboard():
832
+ provider = type(app_ref.model).__name__.replace("Model", "")
833
+ m_name = getattr(app_ref.model, "model_name", "Unknown")
834
+ model_display = f"{provider} ({m_name})"
835
+ return HTMLResponse(content=render_inspector_html(model_display=model_display))
836
+
837
+ # ---------------- Skills & Resources Routes ----------------
838
+ @api.get("/skills", tags=["Skills"])
839
+ def list_skills_route():
840
+ return {"skills": [s.to_dict() for s in app_ref.list_skills()]}
841
+
842
+ @api.get("/resources", tags=["Resources"])
843
+ def list_resources_route():
844
+ return {"resources": [r.to_dict() for r in app_ref.list_resources()]}
845
+
846
+ # ---------------- Static Previews ----------------
847
+ if self.data_dir.exists():
848
+ api.mount(
849
+ "/preview",
850
+ StaticFiles(directory=str(self.data_dir), html=True),
851
+ name="preview",
852
+ )
853
+
854
+ @api.get("/health", tags=["System"])
855
+ async def health_probe():
856
+ ollama_ready = False
857
+ base_url = getattr(app_ref.model, "base_url", "http://localhost:11434")
858
+ try:
859
+ import httpx
860
+ async with httpx.AsyncClient(timeout=1.0) as client:
861
+ res = await client.get(f"{base_url}/api/tags")
862
+ ollama_ready = res.status_code == 200
863
+ except Exception:
864
+ ollama_ready = False
865
+
866
+ gmail_conn = app_ref.get_connector("gmail")
867
+ gmail_ready = gmail_conn.is_authenticated() if gmail_conn else False
868
+ chroma_path = app_ref.data_dir / "chroma_db"
869
+
870
+ v_count = 0
871
+ if app_ref.vector_store is not None:
872
+ try:
873
+ v_count = app_ref.vector_store.count()
874
+ except Exception:
875
+ v_count = 0
876
+
877
+ g_count = 0
878
+ if app_ref.graph_store is not None:
879
+ try:
880
+ if hasattr(app_ref.graph_store, "graph"):
881
+ g_count = app_ref.graph_store.graph.number_of_nodes()
882
+ elif hasattr(app_ref.graph_store, "count_nodes"):
883
+ g_count = app_ref.graph_store.count_nodes()
884
+ else:
885
+ g_count = len(app_ref.graph_store.get_3d_payload().get("nodes", []))
886
+ except Exception:
887
+ g_count = 0
888
+
889
+ storage_status = "disabled"
890
+ if app_ref.vector_store is not None:
891
+ storage_status = "ready" if (chroma_path.exists() or hasattr(app_ref.vector_store, "database")) else "initialized"
892
+
893
+ return {
894
+ "status": "online",
895
+ "app": app_ref.title,
896
+ "version": app_ref.version,
897
+ "model": getattr(app_ref.model, "model_name", "unknown"),
898
+ "services": {
899
+ "ollama": "ready" if ollama_ready else "unreachable",
900
+ "chroma_db": storage_status,
901
+ "gmail_connector": "connected" if gmail_ready else "disconnected",
902
+ },
903
+ "connectors": [c.name for c in app_ref.list_connectors()],
904
+ "skills": [s.name for s in app_ref.list_skills()],
905
+ "resources": [r.name for r in app_ref.list_resources()],
906
+ "resource_items": sum(len(r.documents) for r in app_ref.list_resources()),
907
+ "mcp_servers": [
908
+ {"name": c.name, "is_ready": c.is_authenticated()}
909
+ for c in app_ref.list_connectors()
910
+ ],
911
+ "vector_items": v_count,
912
+ "graph_nodes": g_count,
913
+ }
914
+
915
+ self._fastapi_app = api
916
+ return self._fastapi_app
917
+
918
+ def to_mcp_server(self, name: str | None = None) -> Any:
919
+ """Converts all registered connectors into an official MCP Server instance."""
920
+ from synapse.export.mcp_exporter import MCPExporter
921
+ return MCPExporter.create_mcp_server(self, name=name or self.title)
922
+
923
+ def run_mcp(self, transport: str = "stdio") -> None:
924
+ """Runs all app connectors as an official MCP server over stdio or SSE."""
925
+ server = self.to_mcp_server()
926
+ server.run(transport=transport)
927
+
928
+ def run(
929
+ self,
930
+ host: str = "127.0.0.1",
931
+ port: int = 8000,
932
+ reload: bool = False,
933
+ ) -> None:
934
+ """Launches the Synapse server and interactive web inspector."""
935
+ print(f"\nšŸš€ Synapse [{self.title}] online at http://{host}:{port}")
936
+ print(f"šŸ“Š Interactive Inspector: http://{host}:{port}/inspector")
937
+ print(f"šŸ“– OpenAPI Docs: http://{host}:{port}/docs\n")
938
+ uvicorn.run(self.fastapi_app, host=host, port=port, reload=reload)
939
+
940
+
941
+ # Developer-friendly alias
942
+ SynapseApp = BaseApp