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/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
|