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,58 @@
|
|
|
1
|
+
"""Embedding model: load once, encode on demand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from threading import Lock
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from fastembed import TextEmbedding
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
from agentgraph.config import get_settings
|
|
15
|
+
from agentgraph.perf import timed
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# FastEmbed shares one ONNX session across the process. Concurrent inference on
|
|
20
|
+
# that session can stall, so all callers use the same process-wide lock.
|
|
21
|
+
_model_lock = Lock()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=1)
|
|
25
|
+
def _get_model() -> TextEmbedding:
|
|
26
|
+
settings = get_settings()
|
|
27
|
+
logger.info("Loading embedding model: %s", settings.embedding_model)
|
|
28
|
+
return TextEmbedding(model_name=settings.embedding_model)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalise(vec: NDArray[Any]) -> list[float]:
|
|
32
|
+
norm = float(np.linalg.norm(vec))
|
|
33
|
+
if norm > 0:
|
|
34
|
+
vec = vec / norm
|
|
35
|
+
return [float(value) for value in vec.tolist()]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def encode_passage(text: str) -> list[float]:
|
|
39
|
+
"""Return a normalized passage embedding vector for indexed content."""
|
|
40
|
+
with timed("embedding.passage", characters=len(text)):
|
|
41
|
+
with _model_lock:
|
|
42
|
+
model = _get_model()
|
|
43
|
+
vec = cast(NDArray[Any], next(iter(model.passage_embed([text]))))
|
|
44
|
+
return _normalise(vec)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def encode_query(text: str) -> list[float]:
|
|
48
|
+
"""Return a normalized query embedding vector for search text."""
|
|
49
|
+
with timed("embedding.query", characters=len(text)):
|
|
50
|
+
with _model_lock:
|
|
51
|
+
model = _get_model()
|
|
52
|
+
vec = cast(NDArray[Any], next(iter(model.query_embed(text))))
|
|
53
|
+
return _normalise(vec)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def encode(text: str) -> list[float]:
|
|
57
|
+
"""Return a normalized passage embedding vector for backwards compatibility."""
|
|
58
|
+
return encode_passage(text)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Connector fetch trigger — shared by CLI, MCP, and server API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from agentgraph.core.context import get_backend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def fetch_entity(platform: str, resource_id: str) -> dict[str, Any]:
|
|
11
|
+
"""Trigger a connector fetch for a known platform entity.
|
|
12
|
+
|
|
13
|
+
Resolves the entity type from the backend (falling back to Document), resets
|
|
14
|
+
synced_at so the connector treats the entity as stale, and runs a targeted
|
|
15
|
+
fetch. Returns counts of ingested entities/persons/edges.
|
|
16
|
+
"""
|
|
17
|
+
from agentgraph.connectors.registry import get_connector
|
|
18
|
+
from agentgraph.graph.upsert import upsert_batch
|
|
19
|
+
|
|
20
|
+
connector = get_connector(platform)
|
|
21
|
+
if connector is None:
|
|
22
|
+
raise ValueError(f"No connector registered for platform '{platform}'")
|
|
23
|
+
|
|
24
|
+
backend = get_backend()
|
|
25
|
+
entity = await backend.get_entity_by_platform(platform, resource_id)
|
|
26
|
+
raw_entity_type = (entity or {}).get("entity_type") or "Document"
|
|
27
|
+
entity_type = str(raw_entity_type)
|
|
28
|
+
entity_meta = (entity or {}).get("metadata")
|
|
29
|
+
meta = cast(dict[str, Any], entity_meta) if isinstance(entity_meta, dict) else None
|
|
30
|
+
|
|
31
|
+
resource_id, resource_type = connector.normalise_fetch_id(resource_id, entity_type)
|
|
32
|
+
|
|
33
|
+
await backend.reset_synced_at(platform, resource_id)
|
|
34
|
+
|
|
35
|
+
batch = await connector.fetch(resource_type=resource_type, resource_id=resource_id, meta=meta)
|
|
36
|
+
await upsert_batch(batch)
|
|
37
|
+
return {
|
|
38
|
+
"entities": len(batch.entities),
|
|
39
|
+
"persons": len(batch.persons),
|
|
40
|
+
"edges": len(batch.edges),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def fetch_entity_by_id(entity_id: str) -> dict[str, Any]:
|
|
45
|
+
"""Trigger a connector fetch for an entity identified by its internal UUID.
|
|
46
|
+
|
|
47
|
+
Looks up platform and platform_entity_id from the backend, then delegates to
|
|
48
|
+
fetch_entity. Raises ValueError if the entity is not found.
|
|
49
|
+
"""
|
|
50
|
+
ref = await get_backend().get_entity_platform_ref(entity_id)
|
|
51
|
+
if ref is None:
|
|
52
|
+
raise ValueError(f"Entity not found: {entity_id}")
|
|
53
|
+
return await fetch_entity(platform=ref[0], resource_id=ref[1])
|
agentgraph/graph/gc.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Garbage collection: remove entities not accessed within retention window."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from agentgraph.config import get_settings
|
|
8
|
+
from agentgraph.core.context import get_backend
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def run_gc() -> int:
|
|
14
|
+
"""
|
|
15
|
+
Delete entities where last_accessed < now() - retention_days.
|
|
16
|
+
Edges are removed via ON DELETE CASCADE.
|
|
17
|
+
Returns the total number of rows deleted.
|
|
18
|
+
"""
|
|
19
|
+
settings = get_settings()
|
|
20
|
+
total = await get_backend().gc_entities(settings.retention_days)
|
|
21
|
+
logger.info(
|
|
22
|
+
"GC complete: removed %d entities (retention=%d days)",
|
|
23
|
+
total,
|
|
24
|
+
settings.retention_days,
|
|
25
|
+
)
|
|
26
|
+
return total
|
agentgraph/graph/link.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Cross-platform URL reference linking.
|
|
2
|
+
|
|
3
|
+
Creates 'references' edges from a newly ingested entity to any known entities
|
|
4
|
+
it links to via URL in its content.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from agentgraph.connectors.base import RESOURCE_TYPE_TO_ENTITY_TYPE, SourceReference
|
|
13
|
+
from agentgraph.core.context import get_backend
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_URL_RE = re.compile(r"https?://\S+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def link_entity_to_urls(platform_entity_id: str, platform: str, content: str) -> int:
|
|
21
|
+
"""Create 'references' edges from an entity to any known entities it links to.
|
|
22
|
+
|
|
23
|
+
Extracts all URLs from content, classifies each via the router, and creates
|
|
24
|
+
an edge to any matching entity already in the graph.
|
|
25
|
+
Returns the number of edges created.
|
|
26
|
+
"""
|
|
27
|
+
from agentgraph.server.router import classify_url
|
|
28
|
+
|
|
29
|
+
refs: list[SourceReference] = []
|
|
30
|
+
for url in _URL_RE.findall(content):
|
|
31
|
+
ref = classify_url(url.rstrip(".,)>\"'"))
|
|
32
|
+
if ref:
|
|
33
|
+
refs.append(ref)
|
|
34
|
+
|
|
35
|
+
if not refs:
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
backend = get_backend()
|
|
39
|
+
src_id = await backend.find_entity_id(platform, platform_entity_id)
|
|
40
|
+
if not src_id:
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
count = 0
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
for ref in refs:
|
|
46
|
+
if ref.resource_id in seen:
|
|
47
|
+
continue
|
|
48
|
+
seen.add(ref.resource_id)
|
|
49
|
+
|
|
50
|
+
tgt_id = await backend.find_entity_id(ref.source, ref.resource_id)
|
|
51
|
+
if not tgt_id:
|
|
52
|
+
entity_type = RESOURCE_TYPE_TO_ENTITY_TYPE[ref.resource_type]
|
|
53
|
+
tgt_id = await backend.upsert_stub_entity(entity_type, ref.source, ref.resource_id)
|
|
54
|
+
logger.debug("Created stub entity %s/%s", ref.source, ref.resource_id)
|
|
55
|
+
|
|
56
|
+
await backend.insert_references_edge(src_id, tgt_id)
|
|
57
|
+
logger.debug(
|
|
58
|
+
"Linked %s/%s → %s/%s",
|
|
59
|
+
platform, platform_entity_id, ref.source, ref.resource_id,
|
|
60
|
+
)
|
|
61
|
+
count += 1
|
|
62
|
+
|
|
63
|
+
return count
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Person identity operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agentgraph.core.context import get_backend
|
|
8
|
+
from agentgraph.core.storage import EntityResult
|
|
9
|
+
from agentgraph.graph.query import get_entity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def unify_persons(
|
|
13
|
+
primary_entity_id: str,
|
|
14
|
+
duplicate_entity_ids: list[str],
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
"""Merge duplicate Person entities into one canonical Person entity."""
|
|
17
|
+
primary = await _resolve_person(primary_entity_id)
|
|
18
|
+
duplicate_entities = [
|
|
19
|
+
await _resolve_person(entity_id) for entity_id in duplicate_entity_ids
|
|
20
|
+
]
|
|
21
|
+
duplicate_ids = [
|
|
22
|
+
entity["id"]
|
|
23
|
+
for entity in duplicate_entities
|
|
24
|
+
if entity["id"] != primary["id"]
|
|
25
|
+
]
|
|
26
|
+
updated = await get_backend().merge_person_entities(primary["id"], duplicate_ids)
|
|
27
|
+
return {
|
|
28
|
+
"primary": updated,
|
|
29
|
+
"merged_ids": duplicate_ids,
|
|
30
|
+
"merged_count": len(duplicate_ids),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _resolve_person(entity_id: str) -> EntityResult:
|
|
35
|
+
entity = await get_entity(entity_id)
|
|
36
|
+
if entity is None:
|
|
37
|
+
raise ValueError(f"Entity {entity_id!r} not found")
|
|
38
|
+
if entity["entity_type"] != "Person":
|
|
39
|
+
raise ValueError(f"Entity {entity_id!r} is not a Person")
|
|
40
|
+
return entity
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Shared graph query layer used by both MCP tools and CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from agentgraph.core.context import get_backend
|
|
13
|
+
from agentgraph.core.storage import EdgeResult, EntityResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@lru_cache(maxsize=256)
|
|
17
|
+
def _cached_query_embedding(query: str) -> tuple[float, ...]:
|
|
18
|
+
from agentgraph.graph.embeddings import encode_query
|
|
19
|
+
|
|
20
|
+
return tuple(encode_query(query))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def clear_query_embedding_cache() -> None:
|
|
24
|
+
_cached_query_embedding.cache_clear()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _enrich_web_url(entities: list[EntityResult]) -> None:
|
|
28
|
+
"""Populate metadata.web_url from the connector for entities that don't store it."""
|
|
29
|
+
from agentgraph.connectors.registry import get_connector
|
|
30
|
+
|
|
31
|
+
connectors: dict[str, Any] = {}
|
|
32
|
+
for entity in entities:
|
|
33
|
+
meta = entity.get("metadata")
|
|
34
|
+
metadata = cast(dict[str, Any], meta) if isinstance(meta, dict) else None
|
|
35
|
+
if metadata is not None and metadata.get("web_url"):
|
|
36
|
+
continue
|
|
37
|
+
platform = entity.get("platform")
|
|
38
|
+
if not isinstance(platform, str):
|
|
39
|
+
continue
|
|
40
|
+
if platform not in connectors:
|
|
41
|
+
connectors[platform] = get_connector(platform)
|
|
42
|
+
connector = connectors[platform]
|
|
43
|
+
if connector is None:
|
|
44
|
+
continue
|
|
45
|
+
platform_entity_id = entity.get("platform_entity_id")
|
|
46
|
+
url = connector.entity_url(platform_entity_id if isinstance(platform_entity_id, str) else "")
|
|
47
|
+
if url:
|
|
48
|
+
if metadata is None:
|
|
49
|
+
entity["metadata"] = {"web_url": url}
|
|
50
|
+
else:
|
|
51
|
+
metadata["web_url"] = url
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def search_entities(
|
|
55
|
+
query: str,
|
|
56
|
+
entity_types: list[str] | None = None,
|
|
57
|
+
limit: int = 10,
|
|
58
|
+
min_score: float = 0.03,
|
|
59
|
+
platform: str | None = None,
|
|
60
|
+
) -> list[EntityResult]:
|
|
61
|
+
"""Hybrid search: combines vector similarity with full-text via RRF."""
|
|
62
|
+
embedding = list(await asyncio.to_thread(_cached_query_embedding, query))
|
|
63
|
+
backend = get_backend()
|
|
64
|
+
results = await backend.search_entities(
|
|
65
|
+
embedding, query, entity_types, limit, min_score, platform=platform
|
|
66
|
+
)
|
|
67
|
+
_enrich_web_url(results)
|
|
68
|
+
if results:
|
|
69
|
+
ids = [r["id"] for r in results]
|
|
70
|
+
asyncio.create_task(backend.touch_last_accessed_by_ids(ids))
|
|
71
|
+
return results
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def get_entity(entity_id: str) -> EntityResult | None:
|
|
75
|
+
"""Fetch a single entity by UUID, unambiguous UUID prefix, or platform ref.
|
|
76
|
+
|
|
77
|
+
Platform ref formats accepted:
|
|
78
|
+
- ``"{platform}/{platform_entity_id}"``
|
|
79
|
+
- ``"{platform}/{resource_type}/{platform_entity_id}"`` (resource_type ignored)
|
|
80
|
+
"""
|
|
81
|
+
backend = get_backend()
|
|
82
|
+
entity: EntityResult | None
|
|
83
|
+
if len(entity_id) == 36 or (len(entity_id) == 32 and "-" not in entity_id):
|
|
84
|
+
entity = await backend.get_entity_by_id(entity_id)
|
|
85
|
+
elif "/" in entity_id:
|
|
86
|
+
parts = entity_id.split("/")
|
|
87
|
+
platform = parts[0]
|
|
88
|
+
pid = "/".join(parts[2:]) if len(parts) >= 3 else "/".join(parts[1:])
|
|
89
|
+
entity = await backend.get_entity_by_platform(platform, pid)
|
|
90
|
+
else:
|
|
91
|
+
# UUID prefix — must be unambiguous
|
|
92
|
+
results = await backend.get_entities_by_id_prefix(entity_id)
|
|
93
|
+
if len(results) > 1:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Ambiguous prefix {entity_id!r} matches {len(results)} entities"
|
|
96
|
+
)
|
|
97
|
+
entity = results[0] if results else None
|
|
98
|
+
if entity is not None:
|
|
99
|
+
_enrich_web_url([entity])
|
|
100
|
+
return entity
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def get_entity_by_url(url: str) -> EntityResult | None:
|
|
104
|
+
"""Fetch a single existing entity by URL without fetching or creating it."""
|
|
105
|
+
from agentgraph.connectors.registry import bootstrap, get_connector
|
|
106
|
+
from agentgraph.server.router import classify_url, normalise_url_for_matching
|
|
107
|
+
|
|
108
|
+
normalised_url = normalise_url_for_matching(url)
|
|
109
|
+
bootstrap()
|
|
110
|
+
ref = classify_url(normalised_url)
|
|
111
|
+
backend = get_backend()
|
|
112
|
+
|
|
113
|
+
if ref is not None:
|
|
114
|
+
entity = await backend.get_entity_by_platform(ref.source, ref.resource_id)
|
|
115
|
+
if entity is not None:
|
|
116
|
+
_enrich_web_url([entity])
|
|
117
|
+
return entity
|
|
118
|
+
|
|
119
|
+
connector = get_connector("web")
|
|
120
|
+
web_ref = connector.resolve_url(normalised_url) if connector is not None else None
|
|
121
|
+
if web_ref is None:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
entity = await backend.get_entity_by_platform(web_ref.source, web_ref.resource_id)
|
|
125
|
+
if entity is None:
|
|
126
|
+
entity = await _get_web_entity_by_metadata_url(normalised_url)
|
|
127
|
+
if entity is not None:
|
|
128
|
+
_enrich_web_url([entity])
|
|
129
|
+
return entity
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def get_edges(
|
|
133
|
+
entity_id: str,
|
|
134
|
+
edge_type: str | None = None,
|
|
135
|
+
direction: str = "both",
|
|
136
|
+
) -> list[EdgeResult]:
|
|
137
|
+
return await get_backend().get_edges(entity_id, edge_type, direction)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def traverse_graph(
|
|
141
|
+
entity_id: str,
|
|
142
|
+
max_depth: int = 2,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
return await get_backend().traverse_graph(entity_id, max_depth)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def query_by_filter(
|
|
148
|
+
entity_type: str,
|
|
149
|
+
filters: dict[str, str],
|
|
150
|
+
limit: int = 50,
|
|
151
|
+
order_by: str = "last_accessed",
|
|
152
|
+
since: str | None = None,
|
|
153
|
+
authored_by_me: bool = False,
|
|
154
|
+
has_attachments: bool = False,
|
|
155
|
+
) -> list[EntityResult]:
|
|
156
|
+
since_dt = parse_since(since) if since else None
|
|
157
|
+
authored_by: list[str] | None = _resolve_me() if authored_by_me else None
|
|
158
|
+
results = await get_backend().query_by_filter(
|
|
159
|
+
entity_type, filters, limit, order_by, since_dt, authored_by,
|
|
160
|
+
has_attachments=has_attachments,
|
|
161
|
+
)
|
|
162
|
+
_enrich_web_url(results)
|
|
163
|
+
return results
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def list_entities(
|
|
167
|
+
entity_types: list[str] | None = None,
|
|
168
|
+
platform: str | None = None,
|
|
169
|
+
since: str | None = None,
|
|
170
|
+
limit: int = 50,
|
|
171
|
+
) -> list[EntityResult]:
|
|
172
|
+
since_dt = parse_since(since) if since else None
|
|
173
|
+
results = await get_backend().list_entities(entity_types, platform, since_dt, limit)
|
|
174
|
+
_enrich_web_url(results)
|
|
175
|
+
return results
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def list_entities_page(
|
|
179
|
+
entity_types: list[str] | None = None,
|
|
180
|
+
platform: str | None = None,
|
|
181
|
+
since: str | None = None,
|
|
182
|
+
limit: int = 50,
|
|
183
|
+
offset: int = 0,
|
|
184
|
+
order_by: str | None = "last_accessed",
|
|
185
|
+
order_dir: str = "desc",
|
|
186
|
+
) -> tuple[list[EntityResult], int]:
|
|
187
|
+
since_dt = parse_since(since) if since else None
|
|
188
|
+
results, total = await get_backend().list_entities_page(
|
|
189
|
+
entity_types, platform, since_dt, limit, offset, order_by, order_dir
|
|
190
|
+
)
|
|
191
|
+
_enrich_web_url(results)
|
|
192
|
+
return results, total
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def get_edges_for_entities(entity_ids: list[str]) -> list[EdgeResult]:
|
|
196
|
+
return await get_backend().get_edges_for_entities(entity_ids)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def get_entities_by_ids(entity_ids: list[str]) -> list[EntityResult]:
|
|
200
|
+
results = await get_backend().get_entities_by_ids(entity_ids)
|
|
201
|
+
_enrich_web_url(results)
|
|
202
|
+
return results
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _resolve_me() -> list[str] | None:
|
|
206
|
+
"""Return the current user's canonical identifiers by polling registered connectors."""
|
|
207
|
+
from agentgraph.connectors.registry import get_all_connectors
|
|
208
|
+
user_ids: list[str] = []
|
|
209
|
+
for connector in get_all_connectors():
|
|
210
|
+
for user_id in type(connector).current_user_ids():
|
|
211
|
+
if user_id not in user_ids:
|
|
212
|
+
user_ids.append(user_id)
|
|
213
|
+
return user_ids or None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _get_web_entity_by_metadata_url(url: str) -> EntityResult | None:
|
|
217
|
+
backend = get_backend()
|
|
218
|
+
for key in ("url", "final_url"):
|
|
219
|
+
results = await backend.query_by_filter(
|
|
220
|
+
"Document",
|
|
221
|
+
{"platform": "web", key: url},
|
|
222
|
+
1,
|
|
223
|
+
"updated_at",
|
|
224
|
+
None,
|
|
225
|
+
None,
|
|
226
|
+
)
|
|
227
|
+
if results:
|
|
228
|
+
return results[0]
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def is_http_url(target: str) -> bool:
|
|
233
|
+
parsed = urlparse(target)
|
|
234
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def parse_since(since: str) -> datetime:
|
|
238
|
+
"""Parse a relative duration (12h, 30m, 2d) or ISO timestamp string."""
|
|
239
|
+
m = re.fullmatch(r"(\d+)(h|m|d)", since.strip())
|
|
240
|
+
if m:
|
|
241
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
242
|
+
delta = {"h": timedelta(hours=n), "m": timedelta(minutes=n), "d": timedelta(days=n)}[unit]
|
|
243
|
+
return datetime.now(UTC) - delta
|
|
244
|
+
return datetime.fromisoformat(since)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Idempotent upsert layer for entities and edges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from agentgraph.connectors.base import EntityBatch
|
|
8
|
+
from agentgraph.core.context import get_backend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def upsert_batch(batch: EntityBatch) -> None:
|
|
12
|
+
"""Persist an EntityBatch to the graph, generating embeddings as needed."""
|
|
13
|
+
person_embeddings, entity_embeddings = await asyncio.to_thread(_build_embeddings, batch)
|
|
14
|
+
|
|
15
|
+
await get_backend().upsert_batch(batch, person_embeddings, entity_embeddings)
|
|
16
|
+
await _link_references(batch)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_embeddings(
|
|
20
|
+
batch: EntityBatch,
|
|
21
|
+
) -> tuple[dict[str, list[float] | None], dict[str, list[float] | None]]:
|
|
22
|
+
from agentgraph.graph.embeddings import encode_passage
|
|
23
|
+
|
|
24
|
+
person_embeddings: dict[str, list[float] | None] = {}
|
|
25
|
+
for p in batch.persons:
|
|
26
|
+
canonical_key = p.canonical_email or f"{p.platform}:{p.platform_user_id}"
|
|
27
|
+
text = " ".join(filter(None, [p.display_name, p.canonical_email]))
|
|
28
|
+
vec: list[float] | None = encode_passage(text) if text else None
|
|
29
|
+
person_embeddings[canonical_key] = vec
|
|
30
|
+
person_embeddings[p.platform_user_id] = vec # also indexed by user_id for edge resolution
|
|
31
|
+
|
|
32
|
+
entity_embeddings: dict[str, list[float] | None] = {}
|
|
33
|
+
for e in batch.entities:
|
|
34
|
+
if not e.is_stub and e.content:
|
|
35
|
+
text = f"{e.title or ''} {e.content}".strip()
|
|
36
|
+
entity_embeddings[e.platform_entity_id] = encode_passage(text)
|
|
37
|
+
else:
|
|
38
|
+
entity_embeddings[e.platform_entity_id] = None
|
|
39
|
+
|
|
40
|
+
return person_embeddings, entity_embeddings
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _link_references(batch: EntityBatch) -> None:
|
|
44
|
+
"""After a batch is persisted, create cross-platform 'references' edges."""
|
|
45
|
+
from agentgraph.graph.link import link_entity_to_urls
|
|
46
|
+
|
|
47
|
+
for entity in batch.entities:
|
|
48
|
+
if entity.content:
|
|
49
|
+
await link_entity_to_urls(entity.platform_entity_id, entity.platform, entity.content)
|
agentgraph/logging.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Logging setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from logging.handlers import RotatingFileHandler
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
MAX_LOG_BYTES = 1024 * 1024
|
|
11
|
+
LOG_BACKUP_COUNT = 7
|
|
12
|
+
_AGENTGRAPH_HANDLER = "_agentgraph_handler"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def configure_logging(level: str = "INFO", log_file: str | Path | None = None) -> None:
|
|
16
|
+
log_level = getattr(logging, level.upper(), logging.INFO)
|
|
17
|
+
|
|
18
|
+
# Force-configure the root logger, overriding any handlers uvicorn already added.
|
|
19
|
+
root = logging.getLogger()
|
|
20
|
+
root.setLevel(log_level)
|
|
21
|
+
|
|
22
|
+
if log_file is not None:
|
|
23
|
+
path = Path(log_file).expanduser()
|
|
24
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
candidate_handler = next(
|
|
26
|
+
(candidate for candidate in root.handlers if getattr(candidate, _AGENTGRAPH_HANDLER, False)),
|
|
27
|
+
None,
|
|
28
|
+
)
|
|
29
|
+
handler = candidate_handler if isinstance(candidate_handler, RotatingFileHandler) else None
|
|
30
|
+
if handler is None:
|
|
31
|
+
handler = RotatingFileHandler(
|
|
32
|
+
path,
|
|
33
|
+
maxBytes=MAX_LOG_BYTES,
|
|
34
|
+
backupCount=LOG_BACKUP_COUNT,
|
|
35
|
+
encoding="utf-8",
|
|
36
|
+
)
|
|
37
|
+
setattr(handler, _AGENTGRAPH_HANDLER, True)
|
|
38
|
+
root.addHandler(handler)
|
|
39
|
+
elif Path(handler.baseFilename) != path:
|
|
40
|
+
root.removeHandler(handler)
|
|
41
|
+
handler.close()
|
|
42
|
+
handler = RotatingFileHandler(
|
|
43
|
+
path,
|
|
44
|
+
maxBytes=MAX_LOG_BYTES,
|
|
45
|
+
backupCount=LOG_BACKUP_COUNT,
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
)
|
|
48
|
+
setattr(handler, _AGENTGRAPH_HANDLER, True)
|
|
49
|
+
root.addHandler(handler)
|
|
50
|
+
handler.setLevel(log_level)
|
|
51
|
+
handler.setFormatter(
|
|
52
|
+
logging.Formatter(
|
|
53
|
+
fmt="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
|
54
|
+
datefmt="%H:%M:%S",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
elif not root.handlers:
|
|
58
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
59
|
+
handler.setFormatter(
|
|
60
|
+
logging.Formatter(
|
|
61
|
+
fmt="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
|
62
|
+
datefmt="%H:%M:%S",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
root.addHandler(handler)
|
|
66
|
+
else:
|
|
67
|
+
for handler in root.handlers:
|
|
68
|
+
handler.setLevel(log_level)
|
|
69
|
+
|
|
70
|
+
# Quiet noisy third-party loggers regardless of level
|
|
71
|
+
for noisy in (
|
|
72
|
+
"httpx",
|
|
73
|
+
"httpcore",
|
|
74
|
+
"fastembed",
|
|
75
|
+
"uvicorn.access",
|
|
76
|
+
"googleapiclient.discovery_cache",
|
|
77
|
+
):
|
|
78
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
File without changes
|