agentgraph-server 0.5.0__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.
- agentgraph/__init__.py +1 -0
- agentgraph/auth/__init__.py +0 -0
- agentgraph/auth/credentials.py +224 -0
- agentgraph/backends/__init__.py +50 -0
- agentgraph/backends/sqlite/__init__.py +1 -0
- agentgraph/backends/sqlite/backend.py +1471 -0
- agentgraph/backends/sqlite/vector.py +142 -0
- agentgraph/cli.py +721 -0
- agentgraph/cli_query.py +519 -0
- agentgraph/config.py +90 -0
- agentgraph/connectors/__init__.py +0 -0
- agentgraph/connectors/base.py +455 -0
- agentgraph/connectors/registry.py +78 -0
- agentgraph/connectors/status.py +244 -0
- agentgraph/core/__init__.py +0 -0
- agentgraph/core/context.py +26 -0
- agentgraph/core/runtime.py +36 -0
- agentgraph/core/storage.py +240 -0
- agentgraph/graph/__init__.py +1 -0
- agentgraph/graph/bookmark.py +87 -0
- agentgraph/graph/delete.py +17 -0
- agentgraph/graph/download.py +35 -0
- agentgraph/graph/embeddings.py +58 -0
- agentgraph/graph/fetch.py +53 -0
- agentgraph/graph/gc.py +26 -0
- agentgraph/graph/link.py +63 -0
- agentgraph/graph/person.py +40 -0
- agentgraph/graph/query.py +244 -0
- agentgraph/graph/upsert.py +49 -0
- agentgraph/logging.py +78 -0
- agentgraph/mcp/__init__.py +0 -0
- agentgraph/mcp/server.py +811 -0
- agentgraph/perf.py +43 -0
- agentgraph/server/__init__.py +0 -0
- agentgraph/server/app.py +133 -0
- agentgraph/server/cli_api.py +708 -0
- agentgraph/server/dwell.py +79 -0
- agentgraph/server/graph_api.py +46 -0
- agentgraph/server/router.py +47 -0
- agentgraph/server/sync.py +247 -0
- agentgraph/skills.py +93 -0
- agentgraph_server-0.5.0.data/data/.agents/skills/graph/SKILL.md +159 -0
- agentgraph_server-0.5.0.data/data/.agents/skills/slack-auth/SKILL.md +92 -0
- agentgraph_server-0.5.0.dist-info/METADATA +286 -0
- agentgraph_server-0.5.0.dist-info/RECORD +49 -0
- agentgraph_server-0.5.0.dist-info/WHEEL +5 -0
- agentgraph_server-0.5.0.dist-info/entry_points.txt +2 -0
- agentgraph_server-0.5.0.dist-info/licenses/LICENSE +21 -0
- agentgraph_server-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""CLI API router — raw entity/edge data endpoints for the agentgraph CLI."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
|
|
4
|
+
# pyright: reportUnknownArgumentType=false
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import re
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/api/cli", tags=["cli"])
|
|
16
|
+
|
|
17
|
+
_WHITESPACE_RE = re.compile(r"\s+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def search_entities(
|
|
21
|
+
query: str,
|
|
22
|
+
entity_types: list[str] | None = None,
|
|
23
|
+
limit: int = 10,
|
|
24
|
+
min_score: float = 0.03,
|
|
25
|
+
platform: str | None = None,
|
|
26
|
+
) -> list[dict[str, Any]]:
|
|
27
|
+
from agentgraph.graph.query import search_entities as impl
|
|
28
|
+
|
|
29
|
+
return await impl(query, entity_types, limit, min_score, platform)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def get_entity(entity_id: str) -> dict[str, Any] | None:
|
|
33
|
+
from agentgraph.graph.query import get_entity as impl
|
|
34
|
+
|
|
35
|
+
return await impl(entity_id)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def get_entity_by_url(url: str) -> dict[str, Any] | None:
|
|
39
|
+
from agentgraph.graph.query import get_entity_by_url as impl
|
|
40
|
+
|
|
41
|
+
return await impl(url)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def get_edges(
|
|
45
|
+
entity_id: str,
|
|
46
|
+
edge_type: str | None = None,
|
|
47
|
+
direction: str = "both",
|
|
48
|
+
) -> list[dict[str, Any]]:
|
|
49
|
+
from agentgraph.graph.query import get_edges as impl
|
|
50
|
+
|
|
51
|
+
return await impl(entity_id, edge_type=edge_type, direction=direction)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def traverse_graph(entity_id: str, max_depth: int = 2) -> dict[str, Any]:
|
|
55
|
+
from agentgraph.graph.query import traverse_graph as impl
|
|
56
|
+
|
|
57
|
+
return await impl(entity_id, max_depth=max_depth)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def get_edges_for_entities(entity_ids: list[str]) -> list[dict[str, Any]]:
|
|
61
|
+
from agentgraph.graph.query import get_edges_for_entities as impl
|
|
62
|
+
|
|
63
|
+
return await impl(entity_ids)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def get_entities_by_ids(entity_ids: list[str]) -> list[dict[str, Any]]:
|
|
67
|
+
from agentgraph.graph.query import get_entities_by_ids as impl
|
|
68
|
+
|
|
69
|
+
return await impl(entity_ids)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def list_entities(
|
|
73
|
+
entity_types: list[str] | None = None,
|
|
74
|
+
platform: str | None = None,
|
|
75
|
+
since: str | None = None,
|
|
76
|
+
limit: int = 50,
|
|
77
|
+
) -> list[dict[str, Any]]:
|
|
78
|
+
from agentgraph.graph.query import list_entities as impl
|
|
79
|
+
|
|
80
|
+
return await impl(entity_types, platform, since, limit)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def list_entities_page(
|
|
84
|
+
entity_types: list[str] | None = None,
|
|
85
|
+
platform: str | None = None,
|
|
86
|
+
since: str | None = None,
|
|
87
|
+
limit: int = 50,
|
|
88
|
+
offset: int = 0,
|
|
89
|
+
order_by: str | None = "last_accessed",
|
|
90
|
+
order_dir: str = "desc",
|
|
91
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
92
|
+
from agentgraph.graph.query import list_entities_page as impl
|
|
93
|
+
|
|
94
|
+
return await impl(entity_types, platform, since, limit, offset, order_by, order_dir)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def query_by_filter(
|
|
98
|
+
entity_type: str,
|
|
99
|
+
filters: dict[str, str],
|
|
100
|
+
limit: int = 50,
|
|
101
|
+
order_by: str = "last_accessed",
|
|
102
|
+
since: str | None = None,
|
|
103
|
+
authored_by_me: bool = False,
|
|
104
|
+
has_attachments: bool = False,
|
|
105
|
+
) -> list[dict[str, Any]]:
|
|
106
|
+
from agentgraph.graph.query import query_by_filter as impl
|
|
107
|
+
|
|
108
|
+
return await impl(
|
|
109
|
+
entity_type,
|
|
110
|
+
filters=filters,
|
|
111
|
+
limit=limit,
|
|
112
|
+
order_by=order_by,
|
|
113
|
+
since=since,
|
|
114
|
+
authored_by_me=authored_by_me,
|
|
115
|
+
has_attachments=has_attachments,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_since(since: str) -> datetime:
|
|
120
|
+
from agentgraph.graph.query import parse_since as impl
|
|
121
|
+
|
|
122
|
+
return impl(since)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _normalise_display_text(value: object) -> str | None:
|
|
126
|
+
if not isinstance(value, str):
|
|
127
|
+
return None
|
|
128
|
+
text = _WHITESPACE_RE.sub(" ", value).strip()
|
|
129
|
+
return text or None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _entity_display_name(entity: dict[str, Any]) -> str:
|
|
133
|
+
metadata = entity.get("metadata")
|
|
134
|
+
metadata_dict = metadata if isinstance(metadata, dict) else {}
|
|
135
|
+
candidates = (
|
|
136
|
+
entity.get("title"),
|
|
137
|
+
metadata_dict.get("display_name"),
|
|
138
|
+
metadata_dict.get("canonical_email"),
|
|
139
|
+
entity.get("content"),
|
|
140
|
+
entity.get("platform_entity_id"),
|
|
141
|
+
entity.get("id"),
|
|
142
|
+
)
|
|
143
|
+
for candidate in candidates:
|
|
144
|
+
text = _normalise_display_text(candidate)
|
|
145
|
+
if text:
|
|
146
|
+
return text
|
|
147
|
+
return "Untitled"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _truncate_text(value: str, limit: int) -> str:
|
|
151
|
+
if len(value) <= limit:
|
|
152
|
+
return value
|
|
153
|
+
return value[: max(limit - 1, 0)].rstrip() + "…"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _entity_viewer_label(entity: dict[str, Any]) -> str:
|
|
157
|
+
display_name = _entity_display_name(entity)
|
|
158
|
+
if entity.get("entity_type") == "Message":
|
|
159
|
+
return _truncate_text(display_name, 80)
|
|
160
|
+
return display_name
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _with_display_name(entity: dict[str, Any]) -> dict[str, Any]:
|
|
164
|
+
enriched = dict(entity)
|
|
165
|
+
enriched["display_name"] = _entity_display_name(entity)
|
|
166
|
+
enriched["viewer_label"] = _entity_viewer_label(entity)
|
|
167
|
+
return enriched
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _with_display_names(entities: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
171
|
+
return [_with_display_name(entity) for entity in entities]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _summarize_entity(entity: dict[str, Any], *, content_limit: int = 500) -> dict[str, Any]:
|
|
175
|
+
summarized = dict(entity)
|
|
176
|
+
content = summarized.get("content")
|
|
177
|
+
if isinstance(content, str) and len(content) > content_limit:
|
|
178
|
+
summarized["content"] = _truncate_text(content, content_limit)
|
|
179
|
+
summarized["content_truncated"] = True
|
|
180
|
+
else:
|
|
181
|
+
summarized["content_truncated"] = False
|
|
182
|
+
return summarized
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _summarize_entities(
|
|
186
|
+
entities: list[dict[str, Any]],
|
|
187
|
+
*,
|
|
188
|
+
content_limit: int = 500,
|
|
189
|
+
) -> list[dict[str, Any]]:
|
|
190
|
+
return [_summarize_entity(entity, content_limit=content_limit) for entity in entities]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.get("/meta")
|
|
194
|
+
async def cli_meta(include_dynamic_url_patterns: bool = True) -> dict[str, Any]:
|
|
195
|
+
"""Return registered connector sources, URL patterns, and known entity types."""
|
|
196
|
+
from agentgraph.config import get_settings
|
|
197
|
+
from agentgraph.connectors.base import ENTITY_TYPES
|
|
198
|
+
from agentgraph.connectors.registry import get_all_connectors
|
|
199
|
+
|
|
200
|
+
connectors = get_all_connectors()
|
|
201
|
+
seen_patterns: list[str] = []
|
|
202
|
+
seen_set: set[str] = set()
|
|
203
|
+
for c in connectors:
|
|
204
|
+
patterns = (
|
|
205
|
+
await c.observation_url_patterns()
|
|
206
|
+
if include_dynamic_url_patterns
|
|
207
|
+
else c.url_patterns
|
|
208
|
+
)
|
|
209
|
+
for p in patterns:
|
|
210
|
+
if p not in seen_set:
|
|
211
|
+
seen_patterns.append(p)
|
|
212
|
+
seen_set.add(p)
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
"entity_types": list(ENTITY_TYPES),
|
|
216
|
+
"platforms": sorted({c.source for c in connectors}),
|
|
217
|
+
"url_patterns": seen_patterns,
|
|
218
|
+
"dwell_threshold_ms": get_settings().dwell_threshold_seconds * 1000,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@router.get("/search")
|
|
223
|
+
async def cli_search(
|
|
224
|
+
q: str,
|
|
225
|
+
entity_type: list[str] = Query(default=[]),
|
|
226
|
+
limit: int = Query(default=10, ge=1, le=200),
|
|
227
|
+
min_score: float = Query(default=0.03, ge=0.0, le=1.0),
|
|
228
|
+
platform: str | None = Query(default=None),
|
|
229
|
+
) -> list[dict[str, Any]]:
|
|
230
|
+
results = await search_entities(
|
|
231
|
+
q, entity_types=entity_type or None, limit=limit, min_score=min_score, platform=platform
|
|
232
|
+
)
|
|
233
|
+
return _summarize_entities(results)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@router.get("/entity/{entity_id:path}")
|
|
237
|
+
async def cli_get_entity(entity_id: str) -> dict[str, Any]:
|
|
238
|
+
entity = await get_entity(entity_id)
|
|
239
|
+
if entity is None:
|
|
240
|
+
raise HTTPException(status_code=404, detail="Entity not found")
|
|
241
|
+
return _with_display_name(entity)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@router.get("/entity-by-url")
|
|
245
|
+
async def cli_get_entity_by_url(url: str = Query(...)) -> dict[str, Any]:
|
|
246
|
+
entity = await get_entity_by_url(url)
|
|
247
|
+
if entity is None:
|
|
248
|
+
raise HTTPException(status_code=404, detail="Entity not found")
|
|
249
|
+
return _with_display_name(entity)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@router.get("/edges/{entity_id:path}")
|
|
253
|
+
async def cli_get_edges(
|
|
254
|
+
entity_id: str,
|
|
255
|
+
edge_type: str | None = Query(default=None),
|
|
256
|
+
direction: str = Query(default="both"),
|
|
257
|
+
) -> list[dict[str, Any]]:
|
|
258
|
+
entity = await get_entity(entity_id)
|
|
259
|
+
if entity is None:
|
|
260
|
+
raise HTTPException(status_code=404, detail="Entity not found")
|
|
261
|
+
return await get_edges(entity["id"], edge_type=edge_type, direction=direction)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@router.get("/traverse/{entity_id:path}")
|
|
265
|
+
async def cli_traverse(
|
|
266
|
+
entity_id: str,
|
|
267
|
+
depth: int = Query(default=2, ge=0, le=4),
|
|
268
|
+
) -> dict[str, Any]:
|
|
269
|
+
entity = await get_entity(entity_id)
|
|
270
|
+
if entity is None:
|
|
271
|
+
raise HTTPException(status_code=404, detail="Entity not found")
|
|
272
|
+
return await traverse_graph(entity["id"], max_depth=depth)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
_VIEWER_ORDER_FIELDS = {
|
|
276
|
+
"created_at",
|
|
277
|
+
"display_name",
|
|
278
|
+
"entity_type",
|
|
279
|
+
"platform",
|
|
280
|
+
"updated_at",
|
|
281
|
+
"last_accessed",
|
|
282
|
+
"synced_at",
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _viewer_sort_value(node: dict[str, Any], order_by: str) -> str:
|
|
287
|
+
if order_by == "display_name":
|
|
288
|
+
return _entity_display_name(node).casefold()
|
|
289
|
+
value = node.get(order_by)
|
|
290
|
+
return value.casefold() if isinstance(value, str) else ""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _viewer_updated_at_on_or_after(node: dict[str, Any], cutoff: datetime) -> bool:
|
|
294
|
+
updated_at = node.get("updated_at")
|
|
295
|
+
if not isinstance(updated_at, str):
|
|
296
|
+
return False
|
|
297
|
+
try:
|
|
298
|
+
updated_at_dt = datetime.fromisoformat(updated_at)
|
|
299
|
+
except ValueError:
|
|
300
|
+
return False
|
|
301
|
+
if updated_at_dt.tzinfo is None:
|
|
302
|
+
updated_at_dt = updated_at_dt.replace(tzinfo=UTC)
|
|
303
|
+
if cutoff.tzinfo is None:
|
|
304
|
+
cutoff = cutoff.replace(tzinfo=UTC)
|
|
305
|
+
return updated_at_dt >= cutoff
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _page_entities(
|
|
309
|
+
nodes: list[dict[str, Any]],
|
|
310
|
+
page: int,
|
|
311
|
+
page_size: int,
|
|
312
|
+
order_by: str | None,
|
|
313
|
+
order_dir: str,
|
|
314
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
315
|
+
reverse = order_dir.lower() != "asc"
|
|
316
|
+
if order_by in _VIEWER_ORDER_FIELDS:
|
|
317
|
+
nodes = sorted(nodes, key=lambda node: _viewer_sort_value(node, order_by), reverse=reverse)
|
|
318
|
+
total = len(nodes)
|
|
319
|
+
start = (page - 1) * page_size
|
|
320
|
+
return nodes[start : start + page_size], total
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def _resolve_viewer_node_set(
|
|
324
|
+
search: str | None,
|
|
325
|
+
entity_type: list[str],
|
|
326
|
+
platform: str | None,
|
|
327
|
+
since: str | None,
|
|
328
|
+
node_id: str | None,
|
|
329
|
+
depth: int,
|
|
330
|
+
limit: int,
|
|
331
|
+
*,
|
|
332
|
+
page: int | None = None,
|
|
333
|
+
page_size: int | None = None,
|
|
334
|
+
order_by: str = "last_accessed",
|
|
335
|
+
order_dir: str = "desc",
|
|
336
|
+
ordered: bool = True,
|
|
337
|
+
) -> tuple[list[dict[str, Any]], int, bool]:
|
|
338
|
+
"""Resolve the filtered viewer node set; traversal edges are only used for pruning."""
|
|
339
|
+
# --- Phase 1: neighbourhood (only when node_id given) ---
|
|
340
|
+
focal: dict[str, Any] | None = None
|
|
341
|
+
neighbourhood_ids: set[str] | None = None
|
|
342
|
+
traverse_edges: list[dict[str, Any]] = []
|
|
343
|
+
|
|
344
|
+
if node_id is not None:
|
|
345
|
+
focal = await get_entity(node_id)
|
|
346
|
+
if focal is None:
|
|
347
|
+
raise HTTPException(status_code=404, detail="Entity not found")
|
|
348
|
+
tresult = await traverse_graph(focal["id"], max_depth=depth)
|
|
349
|
+
neighbourhood_ids = {n["id"] for n in tresult["nodes"]}
|
|
350
|
+
traverse_edges = tresult["edges"]
|
|
351
|
+
|
|
352
|
+
# --- Phase 2: candidate nodes ---
|
|
353
|
+
if search:
|
|
354
|
+
search_limit = limit + 1 if neighbourhood_ids is None else max(limit + 1, 500)
|
|
355
|
+
# The viewer needs every lexical match up to its active limit so it can
|
|
356
|
+
# reliably expose the More control. Hybrid RRF scores naturally fall
|
|
357
|
+
# below the default cutoff for lower-ranked exact text matches.
|
|
358
|
+
nodes = await search_entities(
|
|
359
|
+
search,
|
|
360
|
+
entity_types=entity_type or None,
|
|
361
|
+
limit=search_limit,
|
|
362
|
+
min_score=0.0,
|
|
363
|
+
)
|
|
364
|
+
if neighbourhood_ids is not None:
|
|
365
|
+
nodes = [n for n in nodes if n["id"] in neighbourhood_ids]
|
|
366
|
+
elif neighbourhood_ids is not None:
|
|
367
|
+
nodes = [n for n in tresult["nodes"]] # type: ignore[possibly-undefined]
|
|
368
|
+
if entity_type:
|
|
369
|
+
allowed_types = set(entity_type)
|
|
370
|
+
nodes = [n for n in nodes if n["entity_type"] in allowed_types]
|
|
371
|
+
elif page is not None and page_size is not None:
|
|
372
|
+
offset = (page - 1) * page_size
|
|
373
|
+
nodes, total = await list_entities_page(
|
|
374
|
+
entity_types=entity_type or None,
|
|
375
|
+
platform=platform,
|
|
376
|
+
since=since,
|
|
377
|
+
limit=min(page_size, max(limit - offset, 0)),
|
|
378
|
+
offset=offset,
|
|
379
|
+
order_by=order_by if ordered else None,
|
|
380
|
+
order_dir=order_dir,
|
|
381
|
+
)
|
|
382
|
+
return nodes, min(total, limit), total > limit
|
|
383
|
+
else:
|
|
384
|
+
nodes = await list_entities(
|
|
385
|
+
entity_types=entity_type or None,
|
|
386
|
+
platform=platform,
|
|
387
|
+
since=since,
|
|
388
|
+
limit=limit + 1,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Apply platform / since on search and neighbourhood paths (list_entities handles them natively)
|
|
392
|
+
if platform and (search or neighbourhood_ids is not None):
|
|
393
|
+
nodes = [n for n in nodes if n.get("platform") == platform]
|
|
394
|
+
if since and (search or neighbourhood_ids is not None):
|
|
395
|
+
cutoff = parse_since(since)
|
|
396
|
+
nodes = [n for n in nodes if _viewer_updated_at_on_or_after(n, cutoff)]
|
|
397
|
+
|
|
398
|
+
has_more = len(nodes) > limit
|
|
399
|
+
nodes = nodes[:limit]
|
|
400
|
+
|
|
401
|
+
# Focal node is always shown, even when it doesn't match the active filters
|
|
402
|
+
if focal is not None and focal["id"] not in {n["id"] for n in nodes}:
|
|
403
|
+
nodes = [focal] + nodes
|
|
404
|
+
|
|
405
|
+
visible_ids = {n["id"] for n in nodes}
|
|
406
|
+
|
|
407
|
+
# --- Phase 3: edges + prune / adjacent expansion ---
|
|
408
|
+
if neighbourhood_ids is not None:
|
|
409
|
+
edges = [
|
|
410
|
+
e for e in traverse_edges
|
|
411
|
+
if e["source_entity_id"] in visible_ids and e["target_entity_id"] in visible_ids
|
|
412
|
+
]
|
|
413
|
+
# BFS from focal through filtered edges; removes nodes only reachable via hidden types
|
|
414
|
+
reachable: set[str] = set()
|
|
415
|
+
adjacency: dict[str, set[str]] = {}
|
|
416
|
+
for e in edges:
|
|
417
|
+
adjacency.setdefault(e["source_entity_id"], set()).add(e["target_entity_id"])
|
|
418
|
+
adjacency.setdefault(e["target_entity_id"], set()).add(e["source_entity_id"])
|
|
419
|
+
queue = [focal["id"]] # type: ignore[possibly-undefined]
|
|
420
|
+
while queue:
|
|
421
|
+
nid = queue.pop()
|
|
422
|
+
if nid in reachable:
|
|
423
|
+
continue
|
|
424
|
+
reachable.add(nid)
|
|
425
|
+
queue.extend(adjacency.get(nid, set()) - reachable)
|
|
426
|
+
nodes = [n for n in nodes if n["id"] in reachable]
|
|
427
|
+
visible_ids = {n["id"] for n in nodes}
|
|
428
|
+
edges = [
|
|
429
|
+
e for e in edges
|
|
430
|
+
if e["source_entity_id"] in visible_ids and e["target_entity_id"] in visible_ids
|
|
431
|
+
]
|
|
432
|
+
else:
|
|
433
|
+
edges = await get_edges_for_entities(list(visible_ids))
|
|
434
|
+
# When search + entity_type active, pull in adjacent nodes of those types so that
|
|
435
|
+
# e.g. searching for a Thread also surfaces the Persons connected to it.
|
|
436
|
+
if search and entity_type:
|
|
437
|
+
allowed = set(entity_type)
|
|
438
|
+
neighbour_ids = {
|
|
439
|
+
eid
|
|
440
|
+
for e in edges
|
|
441
|
+
for eid in (e["source_entity_id"], e["target_entity_id"])
|
|
442
|
+
if eid not in visible_ids
|
|
443
|
+
}
|
|
444
|
+
if neighbour_ids:
|
|
445
|
+
neighbours = await get_entities_by_ids(list(neighbour_ids))
|
|
446
|
+
neighbours = [n for n in neighbours if n["entity_type"] in allowed]
|
|
447
|
+
has_more = has_more or len(nodes) + len(neighbours) > limit
|
|
448
|
+
nodes = (nodes + neighbours)[:limit]
|
|
449
|
+
|
|
450
|
+
if page is not None and page_size is not None:
|
|
451
|
+
nodes, total = _page_entities(
|
|
452
|
+
nodes,
|
|
453
|
+
page,
|
|
454
|
+
page_size,
|
|
455
|
+
order_by if ordered else None,
|
|
456
|
+
order_dir,
|
|
457
|
+
)
|
|
458
|
+
else:
|
|
459
|
+
total = len(nodes)
|
|
460
|
+
return nodes, total, has_more
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def _viewer_edges(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
464
|
+
visible_ids = {node["id"] for node in nodes}
|
|
465
|
+
if not visible_ids:
|
|
466
|
+
return []
|
|
467
|
+
edges = await get_edges_for_entities(list(visible_ids))
|
|
468
|
+
return [
|
|
469
|
+
edge for edge in edges
|
|
470
|
+
if edge["source_entity_id"] in visible_ids and edge["target_entity_id"] in visible_ids
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@router.get("/browse/nodes")
|
|
475
|
+
async def cli_browse_nodes(
|
|
476
|
+
search: str | None = Query(default=None),
|
|
477
|
+
entity_type: list[str] = Query(default=[]),
|
|
478
|
+
platform: str | None = Query(default=None),
|
|
479
|
+
since: str | None = Query(default=None),
|
|
480
|
+
node_id: str | None = Query(default=None),
|
|
481
|
+
depth: int = Query(default=2, ge=0, le=4),
|
|
482
|
+
limit: int = Query(default=50, ge=1, le=1000),
|
|
483
|
+
page: int = Query(default=1, ge=1),
|
|
484
|
+
size: int = Query(default=50, ge=1, le=1000),
|
|
485
|
+
sort: str = Query(default="last_accessed"),
|
|
486
|
+
sort_dir: str = Query(default="desc"),
|
|
487
|
+
ordered: bool = Query(default=True),
|
|
488
|
+
) -> dict[str, Any]:
|
|
489
|
+
"""Return one viewer node page, optionally omitting ordering for graph layout."""
|
|
490
|
+
nodes, total, has_more = await _resolve_viewer_node_set(
|
|
491
|
+
search, entity_type, platform, since, node_id, depth, limit,
|
|
492
|
+
page=page,
|
|
493
|
+
page_size=size,
|
|
494
|
+
order_by=sort,
|
|
495
|
+
order_dir=sort_dir,
|
|
496
|
+
ordered=ordered,
|
|
497
|
+
)
|
|
498
|
+
return {
|
|
499
|
+
"data": _with_display_names(_summarize_entities(nodes, content_limit=300)),
|
|
500
|
+
"last_page": max(1, (total + size - 1) // size),
|
|
501
|
+
"total": total,
|
|
502
|
+
"has_more": has_more,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@router.get("/browse/edges")
|
|
507
|
+
async def cli_browse_edges(node_ids: str = Query(default="")) -> dict[str, Any]:
|
|
508
|
+
"""Return edges whose endpoints are both in comma-separated node_ids."""
|
|
509
|
+
ids = list(dict.fromkeys(node_id.strip() for node_id in node_ids.split(",") if node_id.strip()))
|
|
510
|
+
if len(ids) > 1000:
|
|
511
|
+
raise HTTPException(status_code=422, detail="At most 1000 node_ids are allowed")
|
|
512
|
+
edges = await get_edges_for_entities(ids)
|
|
513
|
+
visible_ids = set(ids)
|
|
514
|
+
return {
|
|
515
|
+
"edges": [
|
|
516
|
+
edge for edge in edges
|
|
517
|
+
if edge["source_entity_id"] in visible_ids and edge["target_entity_id"] in visible_ids
|
|
518
|
+
]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@router.get("/browse")
|
|
523
|
+
async def cli_browse(
|
|
524
|
+
search: str | None = Query(default=None),
|
|
525
|
+
entity_type: list[str] = Query(default=[]),
|
|
526
|
+
platform: str | None = Query(default=None),
|
|
527
|
+
since: str | None = Query(default=None),
|
|
528
|
+
node_id: str | None = Query(default=None),
|
|
529
|
+
depth: int = Query(default=2, ge=0, le=4),
|
|
530
|
+
limit: int = Query(default=50, ge=1, le=1000),
|
|
531
|
+
) -> dict[str, Any]:
|
|
532
|
+
"""Compatibility graph response, composed from the shared node-set resolver."""
|
|
533
|
+
nodes, _, _ = await _resolve_viewer_node_set(
|
|
534
|
+
search, entity_type, platform, since, node_id, depth, limit
|
|
535
|
+
)
|
|
536
|
+
return {
|
|
537
|
+
"nodes": _with_display_names(_summarize_entities(nodes, content_limit=300)),
|
|
538
|
+
"edges": await _viewer_edges(nodes),
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@router.get("/query")
|
|
543
|
+
async def cli_query(
|
|
544
|
+
entity_type: str,
|
|
545
|
+
limit: int = Query(default=50, ge=1, le=500),
|
|
546
|
+
order_by: str = Query(default="last_accessed"),
|
|
547
|
+
since: str | None = Query(default=None),
|
|
548
|
+
mine: bool = Query(default=False),
|
|
549
|
+
has_attachments: bool = Query(default=False),
|
|
550
|
+
filter: list[str] = Query(default=[]),
|
|
551
|
+
) -> list[dict[str, Any]]:
|
|
552
|
+
"""Query entities by type with optional filters (key=value pairs)."""
|
|
553
|
+
filters: dict[str, str] = {}
|
|
554
|
+
for f in filter:
|
|
555
|
+
if "=" in f:
|
|
556
|
+
k, _, v = f.partition("=")
|
|
557
|
+
filters[k.strip()] = v.strip()
|
|
558
|
+
results = await query_by_filter(
|
|
559
|
+
entity_type,
|
|
560
|
+
filters=filters,
|
|
561
|
+
limit=limit,
|
|
562
|
+
order_by=order_by,
|
|
563
|
+
since=since,
|
|
564
|
+
authored_by_me=mine,
|
|
565
|
+
has_attachments=has_attachments,
|
|
566
|
+
)
|
|
567
|
+
return _summarize_entities(results)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@router.post("/fetch")
|
|
571
|
+
async def cli_fetch(
|
|
572
|
+
platform: str = Query(...),
|
|
573
|
+
resource_id: str = Query(...),
|
|
574
|
+
) -> dict[str, Any]:
|
|
575
|
+
"""Trigger a connector fetch for a platform entity."""
|
|
576
|
+
from agentgraph.graph.fetch import fetch_entity
|
|
577
|
+
|
|
578
|
+
try:
|
|
579
|
+
return await fetch_entity(platform, resource_id)
|
|
580
|
+
except ValueError as exc:
|
|
581
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
582
|
+
except RuntimeError as exc:
|
|
583
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@router.post("/download")
|
|
587
|
+
async def cli_download(
|
|
588
|
+
entity_id: str = Query(...),
|
|
589
|
+
output_path: str | None = Query(default=None),
|
|
590
|
+
) -> dict[str, Any]:
|
|
591
|
+
"""Download an entity's source file using connector auth."""
|
|
592
|
+
from agentgraph.graph.download import download_entity
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
return await download_entity(entity_id, output_path)
|
|
596
|
+
except ValueError as exc:
|
|
597
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@router.post("/bookmark")
|
|
601
|
+
async def cli_bookmark(
|
|
602
|
+
target: str | None = Query(default=None),
|
|
603
|
+
entity_id: str | None = Query(default=None),
|
|
604
|
+
bookmarked: bool = Query(default=True),
|
|
605
|
+
) -> dict[str, Any]:
|
|
606
|
+
"""Set bookmark state for an entity or URL."""
|
|
607
|
+
from agentgraph.graph.bookmark import bookmark_target, set_entity_bookmark
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
bookmark_target_value = target or entity_id
|
|
611
|
+
if bookmark_target_value is None:
|
|
612
|
+
raise ValueError("Missing bookmark target")
|
|
613
|
+
if not bookmarked:
|
|
614
|
+
return await set_entity_bookmark(bookmark_target_value, False)
|
|
615
|
+
return await bookmark_target(bookmark_target_value)
|
|
616
|
+
except ValueError as exc:
|
|
617
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@router.post("/delete")
|
|
621
|
+
async def cli_delete(
|
|
622
|
+
target: str = Query(...),
|
|
623
|
+
) -> dict[str, Any]:
|
|
624
|
+
"""Delete an entity from the graph."""
|
|
625
|
+
from agentgraph.graph.delete import delete_entity
|
|
626
|
+
|
|
627
|
+
try:
|
|
628
|
+
return await delete_entity(target)
|
|
629
|
+
except ValueError as exc:
|
|
630
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@router.post("/unify-persons")
|
|
634
|
+
async def cli_unify_persons(
|
|
635
|
+
primary: str = Query(...),
|
|
636
|
+
duplicate: list[str] = Query(...),
|
|
637
|
+
) -> dict[str, Any]:
|
|
638
|
+
"""Merge duplicate Person entities into a chosen primary Person."""
|
|
639
|
+
from agentgraph.graph.person import unify_persons
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
return await unify_persons(primary, duplicate)
|
|
643
|
+
except ValueError as exc:
|
|
644
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@router.post("/poll")
|
|
648
|
+
async def cli_poll(
|
|
649
|
+
source: str | None = Query(default=None),
|
|
650
|
+
) -> dict[str, Any]:
|
|
651
|
+
"""Trigger a background poll for one connector (by source) or all connectors."""
|
|
652
|
+
from agentgraph.connectors.registry import get_all_connectors, get_connector
|
|
653
|
+
from agentgraph.server.sync import schedule_poll_connector
|
|
654
|
+
|
|
655
|
+
if source is not None:
|
|
656
|
+
connector = get_connector(source)
|
|
657
|
+
if connector is None:
|
|
658
|
+
raise HTTPException(status_code=404, detail=f"No connector registered for source '{source}'")
|
|
659
|
+
connectors = [connector]
|
|
660
|
+
else:
|
|
661
|
+
connectors = get_all_connectors()
|
|
662
|
+
|
|
663
|
+
polled: list[str] = []
|
|
664
|
+
already_running: list[str] = []
|
|
665
|
+
skipped: list[dict[str, str | None]] = []
|
|
666
|
+
for connector in connectors:
|
|
667
|
+
if connector.poll_interval is None:
|
|
668
|
+
continue
|
|
669
|
+
result = await schedule_poll_connector(connector)
|
|
670
|
+
if result["status"] == "queued":
|
|
671
|
+
polled.append(connector.source)
|
|
672
|
+
elif result["status"] == "already_running":
|
|
673
|
+
already_running.append(connector.source)
|
|
674
|
+
else:
|
|
675
|
+
skipped.append({"source": connector.source, "reason": result["reason"]})
|
|
676
|
+
|
|
677
|
+
return {"polled": polled, "already_running": already_running, "skipped": skipped}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@router.post("/ingest")
|
|
681
|
+
async def cli_ingest(
|
|
682
|
+
source: str = Query(...),
|
|
683
|
+
) -> dict[str, Any]:
|
|
684
|
+
"""Kick off a background bulk ingest for a connector and return immediately."""
|
|
685
|
+
from agentgraph.connectors.registry import get_connector
|
|
686
|
+
from agentgraph.server.sync import run_ingest
|
|
687
|
+
|
|
688
|
+
connector = get_connector(source)
|
|
689
|
+
if connector is None:
|
|
690
|
+
raise HTTPException(status_code=404, detail=f"No connector registered for source '{source}'")
|
|
691
|
+
|
|
692
|
+
asyncio.create_task(run_ingest(connector))
|
|
693
|
+
return {"source": source, "status": "started"}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@router.post("/fetch-entity")
|
|
697
|
+
async def cli_fetch_entity(
|
|
698
|
+
entity_id: str = Query(...),
|
|
699
|
+
) -> dict[str, Any]:
|
|
700
|
+
"""Trigger a connector fetch for an entity by its internal UUID."""
|
|
701
|
+
from agentgraph.graph.fetch import fetch_entity_by_id
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
return await fetch_entity_by_id(entity_id)
|
|
705
|
+
except ValueError as exc:
|
|
706
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
707
|
+
except RuntimeError as exc:
|
|
708
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|