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,1471 @@
|
|
|
1
|
+
"""SQLite + FTS5 storage backend."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
|
|
4
|
+
# pyright: reportUnknownArgumentType=false
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import aiosqlite
|
|
19
|
+
|
|
20
|
+
from agentgraph.backends.sqlite.vector import (
|
|
21
|
+
load_sqlite_vec,
|
|
22
|
+
pack_embedding,
|
|
23
|
+
vector_ranked,
|
|
24
|
+
)
|
|
25
|
+
from agentgraph.connectors.base import EntityBatch, EntityRecord, PersonRecord
|
|
26
|
+
from agentgraph.core.storage import EdgeResult, EntityResult, StorageBackend
|
|
27
|
+
from agentgraph.perf import timed
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_SCHEMA_SQL = (Path(__file__).parent / "schema.sql").read_text()
|
|
32
|
+
|
|
33
|
+
_FTS5_SPECIAL = re.compile(r"[^\w\s]", re.UNICODE)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fts5_query(text: str) -> str:
|
|
37
|
+
"""Strip FTS5 syntax characters so arbitrary user text doesn't cause parse errors."""
|
|
38
|
+
return _FTS5_SPECIAL.sub(" ", text).strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_VALID_ORDER_BY = {"created_at", "updated_at", "last_accessed", "synced_at"}
|
|
42
|
+
_LIST_PAGE_ORDER_BY = {
|
|
43
|
+
**{field: field for field in _VALID_ORDER_BY},
|
|
44
|
+
"display_name": """
|
|
45
|
+
COALESCE(
|
|
46
|
+
NULLIF(TRIM(title), ''),
|
|
47
|
+
NULLIF(TRIM(json_extract(metadata, '$.display_name')), ''),
|
|
48
|
+
NULLIF(TRIM(json_extract(metadata, '$.canonical_email')), ''),
|
|
49
|
+
NULLIF(TRIM(content), ''),
|
|
50
|
+
platform_entity_id,
|
|
51
|
+
id
|
|
52
|
+
) COLLATE NOCASE
|
|
53
|
+
""",
|
|
54
|
+
"entity_type": "entity_type COLLATE NOCASE",
|
|
55
|
+
"platform": "platform COLLATE NOCASE",
|
|
56
|
+
}
|
|
57
|
+
_COLUMN_FILTERS = {"platform", "platform_entity_id", "entity_type"}
|
|
58
|
+
_FTS_DELETE_CHUNK_SIZE = 500
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _now() -> str:
|
|
62
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _new_id() -> str:
|
|
66
|
+
return str(uuid.uuid4())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _append_merged_people(
|
|
70
|
+
metadata: dict[str, Any],
|
|
71
|
+
merged_people: list[dict[str, str]],
|
|
72
|
+
merged_person_ids: set[str],
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Append valid, previously merged Person summaries without duplication."""
|
|
75
|
+
existing_merged_people = metadata.get("merged_people", [])
|
|
76
|
+
if not isinstance(existing_merged_people, list):
|
|
77
|
+
return
|
|
78
|
+
for existing_person in existing_merged_people:
|
|
79
|
+
if not isinstance(existing_person, dict):
|
|
80
|
+
continue
|
|
81
|
+
existing_id = existing_person.get("id")
|
|
82
|
+
existing_title = existing_person.get("title")
|
|
83
|
+
existing_ref = existing_person.get("platform_entity_id")
|
|
84
|
+
if (
|
|
85
|
+
not isinstance(existing_id, str)
|
|
86
|
+
or not isinstance(existing_title, str)
|
|
87
|
+
or not isinstance(existing_ref, str)
|
|
88
|
+
or existing_id in merged_person_ids
|
|
89
|
+
):
|
|
90
|
+
continue
|
|
91
|
+
merged_people.append(
|
|
92
|
+
{
|
|
93
|
+
"id": existing_id,
|
|
94
|
+
"title": existing_title,
|
|
95
|
+
"platform_entity_id": existing_ref,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
merged_person_ids.add(existing_id)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SQLiteBackend(StorageBackend):
|
|
102
|
+
def __init__(
|
|
103
|
+
self, db_path: str = "~/.agentgraph/agentgraph.db", vector_mode: str = "sqlite-vec"
|
|
104
|
+
) -> None:
|
|
105
|
+
self._db_path = str(Path(db_path).expanduser()) if db_path != ":memory:" else db_path
|
|
106
|
+
self._vector_mode = vector_mode
|
|
107
|
+
self._conn: aiosqlite.Connection | None = None
|
|
108
|
+
self._read_conn: aiosqlite.Connection | None = None
|
|
109
|
+
self._vec_loaded = False
|
|
110
|
+
# Serialises concurrent write transactions — SQLite only supports one writer at a time
|
|
111
|
+
# and we use explicit BEGIN/COMMIT, so concurrent polls would deadlock without this.
|
|
112
|
+
self._write_lock: asyncio.Lock | None = None
|
|
113
|
+
|
|
114
|
+
async def initialize(self) -> None:
|
|
115
|
+
self._write_lock = asyncio.Lock()
|
|
116
|
+
|
|
117
|
+
if self._db_path != ":memory:":
|
|
118
|
+
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
self._conn = await aiosqlite.connect(self._db_path, isolation_level=None)
|
|
121
|
+
self._conn.row_factory = sqlite3.Row
|
|
122
|
+
if self._db_path == ":memory:":
|
|
123
|
+
self._read_conn = self._conn
|
|
124
|
+
|
|
125
|
+
if self._db_path != ":memory:":
|
|
126
|
+
await self._conn.execute("PRAGMA journal_mode=WAL")
|
|
127
|
+
await self._conn.execute("PRAGMA foreign_keys=ON")
|
|
128
|
+
await self._conn.executescript(_SCHEMA_SQL)
|
|
129
|
+
|
|
130
|
+
await self._run_migrations()
|
|
131
|
+
|
|
132
|
+
if self._db_path != ":memory:":
|
|
133
|
+
self._read_conn = await aiosqlite.connect(self._db_path, isolation_level=None)
|
|
134
|
+
self._read_conn.row_factory = sqlite3.Row
|
|
135
|
+
await self._read_conn.execute("PRAGMA foreign_keys=ON")
|
|
136
|
+
|
|
137
|
+
if self._vector_mode == "sqlite-vec":
|
|
138
|
+
assert self._read_conn is not None
|
|
139
|
+
self._vec_loaded = await load_sqlite_vec(self._read_conn)
|
|
140
|
+
if self._vec_loaded:
|
|
141
|
+
logger.info("sqlite-vec extension loaded")
|
|
142
|
+
else:
|
|
143
|
+
logger.info("sqlite-vec not available, falling back to numpy")
|
|
144
|
+
|
|
145
|
+
async def close(self) -> None:
|
|
146
|
+
if self._read_conn is not None and self._read_conn is not self._conn:
|
|
147
|
+
await self._read_conn.close()
|
|
148
|
+
self._read_conn = None
|
|
149
|
+
if self._conn is not None:
|
|
150
|
+
await self._conn.close()
|
|
151
|
+
self._conn = None
|
|
152
|
+
self._read_conn = None
|
|
153
|
+
|
|
154
|
+
def _conn_or_raise(self) -> aiosqlite.Connection:
|
|
155
|
+
if self._conn is None:
|
|
156
|
+
raise RuntimeError("SQLiteBackend not initialized — call initialize() first")
|
|
157
|
+
return self._conn
|
|
158
|
+
|
|
159
|
+
def _read_conn_or_raise(self) -> aiosqlite.Connection:
|
|
160
|
+
if self._read_conn is None:
|
|
161
|
+
raise RuntimeError("SQLiteBackend not initialized — call initialize() first")
|
|
162
|
+
return self._read_conn
|
|
163
|
+
|
|
164
|
+
# --- Internal helpers ---
|
|
165
|
+
|
|
166
|
+
async def _run_migrations(self) -> None:
|
|
167
|
+
conn = self._conn_or_raise()
|
|
168
|
+
cursor = await conn.execute("PRAGMA table_info(entities)")
|
|
169
|
+
columns = {row["name"] for row in await cursor.fetchall()}
|
|
170
|
+
if "cumulative_dwell_ms" not in columns:
|
|
171
|
+
await conn.execute(
|
|
172
|
+
"ALTER TABLE entities ADD COLUMN cumulative_dwell_ms INTEGER NOT NULL DEFAULT 0"
|
|
173
|
+
)
|
|
174
|
+
if "bookmarked" not in columns:
|
|
175
|
+
await conn.execute(
|
|
176
|
+
"ALTER TABLE entities ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0"
|
|
177
|
+
)
|
|
178
|
+
if "observed_at" not in columns:
|
|
179
|
+
await conn.execute("ALTER TABLE entities ADD COLUMN observed_at TEXT")
|
|
180
|
+
await conn.execute(
|
|
181
|
+
"CREATE INDEX IF NOT EXISTS idx_entities_bookmarked ON entities(bookmarked)"
|
|
182
|
+
)
|
|
183
|
+
await conn.execute(
|
|
184
|
+
"CREATE INDEX IF NOT EXISTS idx_entities_platform_synced_at ON entities(platform, synced_at)"
|
|
185
|
+
)
|
|
186
|
+
await conn.execute(
|
|
187
|
+
"CREATE INDEX IF NOT EXISTS idx_entities_last_accessed_id ON entities(last_accessed DESC, id ASC)"
|
|
188
|
+
)
|
|
189
|
+
await conn.execute(
|
|
190
|
+
"CREATE INDEX IF NOT EXISTS idx_entities_type_last_accessed ON entities(entity_type, last_accessed DESC)"
|
|
191
|
+
)
|
|
192
|
+
await conn.execute(
|
|
193
|
+
"CREATE INDEX IF NOT EXISTS idx_entities_type_last_accessed_id ON entities(entity_type, last_accessed DESC, id ASC)"
|
|
194
|
+
)
|
|
195
|
+
await conn.execute(
|
|
196
|
+
"CREATE INDEX IF NOT EXISTS idx_entities_type_created_at ON entities(entity_type, created_at DESC)"
|
|
197
|
+
)
|
|
198
|
+
await conn.execute(
|
|
199
|
+
"CREATE INDEX IF NOT EXISTS idx_entities_type_updated_at ON entities(entity_type, updated_at DESC)"
|
|
200
|
+
)
|
|
201
|
+
await conn.execute(
|
|
202
|
+
"CREATE INDEX IF NOT EXISTS idx_entities_platform_last_accessed_id ON entities(platform, last_accessed DESC, id ASC)"
|
|
203
|
+
)
|
|
204
|
+
await conn.execute(
|
|
205
|
+
"CREATE INDEX IF NOT EXISTS idx_entities_platform_type_last_accessed ON entities(platform, entity_type, last_accessed DESC)"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def _fetchall(self, sql: str, params: list[Any] | None = None) -> list[Any]:
|
|
209
|
+
conn = self._read_conn_or_raise()
|
|
210
|
+
with timed("sqlite.fetchall"):
|
|
211
|
+
cursor = await conn.execute(sql, params or [])
|
|
212
|
+
return list(await cursor.fetchall())
|
|
213
|
+
|
|
214
|
+
async def _fetchone(self, sql: str, params: list[Any] | None = None) -> Any:
|
|
215
|
+
conn = self._read_conn_or_raise()
|
|
216
|
+
with timed("sqlite.fetchone"):
|
|
217
|
+
cursor = await conn.execute(sql, params or [])
|
|
218
|
+
return await cursor.fetchone()
|
|
219
|
+
|
|
220
|
+
async def _fetchval(self, sql: str, params: list[Any] | None = None) -> Any:
|
|
221
|
+
row = await self._fetchone(sql, params)
|
|
222
|
+
return row[0] if row else None
|
|
223
|
+
|
|
224
|
+
async def _execute(self, sql: str, params: list[Any] | None = None) -> None:
|
|
225
|
+
conn = self._conn_or_raise()
|
|
226
|
+
with timed("sqlite.execute"):
|
|
227
|
+
await conn.execute(sql, params or [])
|
|
228
|
+
|
|
229
|
+
async def _resolve_existing_entity_id(
|
|
230
|
+
self,
|
|
231
|
+
conn: aiosqlite.Connection,
|
|
232
|
+
platform: str,
|
|
233
|
+
platform_entity_id: str,
|
|
234
|
+
) -> str | None:
|
|
235
|
+
cursor = await conn.execute(
|
|
236
|
+
"SELECT id FROM entities WHERE platform = ? AND platform_entity_id = ?",
|
|
237
|
+
[platform, platform_entity_id],
|
|
238
|
+
)
|
|
239
|
+
row = await cursor.fetchone()
|
|
240
|
+
return str(row[0]) if row else None
|
|
241
|
+
|
|
242
|
+
async def _resolve_existing_person_id(
|
|
243
|
+
self,
|
|
244
|
+
conn: aiosqlite.Connection,
|
|
245
|
+
platform: str,
|
|
246
|
+
platform_user_id: str,
|
|
247
|
+
) -> str | None:
|
|
248
|
+
cursor = await conn.execute(
|
|
249
|
+
"""
|
|
250
|
+
SELECT id
|
|
251
|
+
FROM entities
|
|
252
|
+
WHERE entity_type = 'Person'
|
|
253
|
+
AND platform = 'canonical'
|
|
254
|
+
AND json_extract(metadata, ?) = ?
|
|
255
|
+
""",
|
|
256
|
+
[f"$.{platform}_user_id", platform_user_id],
|
|
257
|
+
)
|
|
258
|
+
row = await cursor.fetchone()
|
|
259
|
+
return str(row[0]) if row else None
|
|
260
|
+
|
|
261
|
+
# --- Write ---
|
|
262
|
+
|
|
263
|
+
async def upsert_batch(
|
|
264
|
+
self,
|
|
265
|
+
batch: EntityBatch,
|
|
266
|
+
person_embeddings: dict[str, list[float] | None],
|
|
267
|
+
entity_embeddings: dict[str, list[float] | None],
|
|
268
|
+
) -> None:
|
|
269
|
+
assert self._write_lock is not None
|
|
270
|
+
async with self._write_lock:
|
|
271
|
+
conn = self._conn_or_raise()
|
|
272
|
+
with timed(
|
|
273
|
+
"sqlite.upsert_batch",
|
|
274
|
+
entities=len(batch.entities),
|
|
275
|
+
persons=len(batch.persons),
|
|
276
|
+
edges=len(batch.edges),
|
|
277
|
+
):
|
|
278
|
+
await conn.execute("BEGIN")
|
|
279
|
+
try:
|
|
280
|
+
person_id_map = await self._upsert_persons(
|
|
281
|
+
conn, batch.persons, person_embeddings
|
|
282
|
+
)
|
|
283
|
+
entity_id_map = await self._upsert_entities(
|
|
284
|
+
conn, batch.entities, entity_embeddings
|
|
285
|
+
)
|
|
286
|
+
await self._upsert_edges(conn, batch, person_id_map, entity_id_map)
|
|
287
|
+
await conn.execute("COMMIT")
|
|
288
|
+
except Exception:
|
|
289
|
+
await conn.execute("ROLLBACK")
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
async def _upsert_persons(
|
|
293
|
+
self,
|
|
294
|
+
conn: aiosqlite.Connection,
|
|
295
|
+
persons: list[PersonRecord],
|
|
296
|
+
embeddings: dict[str, list[float] | None],
|
|
297
|
+
) -> dict[str, str]:
|
|
298
|
+
id_map: dict[str, str] = {}
|
|
299
|
+
for p in persons:
|
|
300
|
+
canonical_key = p.canonical_email or f"{p.platform}:{p.platform_user_id}"
|
|
301
|
+
meta: dict[str, str] = {}
|
|
302
|
+
if p.canonical_email:
|
|
303
|
+
meta["canonical_email"] = p.canonical_email
|
|
304
|
+
meta[f"{p.platform}_user_id"] = p.platform_user_id
|
|
305
|
+
if p.platform_username:
|
|
306
|
+
meta[f"{p.platform}_username"] = p.platform_username
|
|
307
|
+
|
|
308
|
+
embedding = embeddings.get(canonical_key)
|
|
309
|
+
emb_blob = pack_embedding(embedding) if embedding else None
|
|
310
|
+
now = _now()
|
|
311
|
+
|
|
312
|
+
existing_id = await self._resolve_person_for_upsert(
|
|
313
|
+
conn, canonical_key, p.platform, p.platform_user_id
|
|
314
|
+
)
|
|
315
|
+
if existing_id is not None:
|
|
316
|
+
existing_cursor = await conn.execute(
|
|
317
|
+
"SELECT title, content FROM entities WHERE id = ?", [existing_id]
|
|
318
|
+
)
|
|
319
|
+
existing_row = await existing_cursor.fetchone()
|
|
320
|
+
existing_title = str(existing_row[0]) if existing_row and existing_row[0] else ""
|
|
321
|
+
existing_content = str(existing_row[1]) if existing_row and existing_row[1] else ""
|
|
322
|
+
fts_title = p.display_name if p.display_name is not None else existing_title
|
|
323
|
+
fts_content = (
|
|
324
|
+
p.canonical_email if p.canonical_email is not None else existing_content
|
|
325
|
+
)
|
|
326
|
+
rewrite_fts = fts_title != existing_title or fts_content != existing_content
|
|
327
|
+
await conn.execute(
|
|
328
|
+
"""
|
|
329
|
+
UPDATE entities
|
|
330
|
+
SET title = COALESCE(?, title),
|
|
331
|
+
content = COALESCE(?, content),
|
|
332
|
+
content_embedding = COALESCE(?, content_embedding),
|
|
333
|
+
metadata = json_patch(metadata, ?),
|
|
334
|
+
last_accessed = ?
|
|
335
|
+
WHERE id = ?
|
|
336
|
+
""",
|
|
337
|
+
[
|
|
338
|
+
p.display_name,
|
|
339
|
+
p.canonical_email,
|
|
340
|
+
emb_blob,
|
|
341
|
+
json.dumps(meta),
|
|
342
|
+
now,
|
|
343
|
+
existing_id,
|
|
344
|
+
],
|
|
345
|
+
)
|
|
346
|
+
entity_id = existing_id
|
|
347
|
+
else:
|
|
348
|
+
cursor = await conn.execute(
|
|
349
|
+
"""
|
|
350
|
+
INSERT INTO entities
|
|
351
|
+
(id, entity_type, platform, platform_entity_id, title, content,
|
|
352
|
+
content_embedding, metadata, last_accessed)
|
|
353
|
+
VALUES (?, 'Person', 'canonical', ?, ?, ?, ?, ?, ?)
|
|
354
|
+
RETURNING id
|
|
355
|
+
""",
|
|
356
|
+
[
|
|
357
|
+
_new_id(),
|
|
358
|
+
canonical_key,
|
|
359
|
+
p.display_name,
|
|
360
|
+
p.canonical_email,
|
|
361
|
+
emb_blob,
|
|
362
|
+
json.dumps(meta),
|
|
363
|
+
now,
|
|
364
|
+
],
|
|
365
|
+
)
|
|
366
|
+
row = await cursor.fetchone()
|
|
367
|
+
if row is None:
|
|
368
|
+
raise RuntimeError("Failed to upsert person entity")
|
|
369
|
+
entity_id = str(row[0])
|
|
370
|
+
fts_title = p.display_name or ""
|
|
371
|
+
fts_content = p.canonical_email or ""
|
|
372
|
+
rewrite_fts = bool(fts_title or fts_content)
|
|
373
|
+
|
|
374
|
+
if rewrite_fts:
|
|
375
|
+
if existing_id is not None:
|
|
376
|
+
await conn.execute("DELETE FROM entities_fts WHERE id = ?", [entity_id])
|
|
377
|
+
await conn.execute(
|
|
378
|
+
"INSERT INTO entities_fts (id, title, content) VALUES (?, ?, ?)",
|
|
379
|
+
[entity_id, fts_title, fts_content],
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
id_map[p.platform_user_id] = entity_id
|
|
383
|
+
if p.canonical_email:
|
|
384
|
+
id_map[p.canonical_email] = entity_id
|
|
385
|
+
return id_map
|
|
386
|
+
|
|
387
|
+
async def _resolve_person_for_upsert(
|
|
388
|
+
self,
|
|
389
|
+
conn: aiosqlite.Connection,
|
|
390
|
+
canonical_key: str,
|
|
391
|
+
platform: str,
|
|
392
|
+
platform_user_id: str,
|
|
393
|
+
) -> str | None:
|
|
394
|
+
cursor = await conn.execute(
|
|
395
|
+
"""
|
|
396
|
+
SELECT id
|
|
397
|
+
FROM entities
|
|
398
|
+
WHERE entity_type = 'Person'
|
|
399
|
+
AND platform = 'canonical'
|
|
400
|
+
AND platform_entity_id = ?
|
|
401
|
+
""",
|
|
402
|
+
[canonical_key],
|
|
403
|
+
)
|
|
404
|
+
row = await cursor.fetchone()
|
|
405
|
+
if row:
|
|
406
|
+
return str(row[0])
|
|
407
|
+
return await self._resolve_existing_person_id(conn, platform, platform_user_id)
|
|
408
|
+
|
|
409
|
+
async def _upsert_entities(
|
|
410
|
+
self,
|
|
411
|
+
conn: aiosqlite.Connection,
|
|
412
|
+
entities: list[EntityRecord],
|
|
413
|
+
embeddings: dict[str, list[float] | None],
|
|
414
|
+
) -> dict[str, str]:
|
|
415
|
+
id_map: dict[str, str] = {}
|
|
416
|
+
# FTS maintenance is independent of edge resolution. Deferring it avoids
|
|
417
|
+
# two SQLite round trips for every entity in a connector-sized batch.
|
|
418
|
+
fts_delete_ids: list[str] = []
|
|
419
|
+
fts_entries: dict[str, tuple[str, str]] = {}
|
|
420
|
+
now = _now()
|
|
421
|
+
for e in entities:
|
|
422
|
+
if e.is_stub:
|
|
423
|
+
cursor = await conn.execute(
|
|
424
|
+
"""
|
|
425
|
+
INSERT INTO entities (id, entity_type, platform, platform_entity_id, last_accessed)
|
|
426
|
+
VALUES (?, ?, ?, ?, ?)
|
|
427
|
+
ON CONFLICT (platform, platform_entity_id) DO UPDATE SET
|
|
428
|
+
last_accessed = EXCLUDED.last_accessed
|
|
429
|
+
RETURNING id
|
|
430
|
+
""",
|
|
431
|
+
[_new_id(), e.entity_type, e.platform, e.platform_entity_id, now],
|
|
432
|
+
)
|
|
433
|
+
else:
|
|
434
|
+
existing_cursor = await conn.execute(
|
|
435
|
+
"""
|
|
436
|
+
SELECT id, title, content FROM entities
|
|
437
|
+
WHERE platform = ? AND platform_entity_id = ?
|
|
438
|
+
""",
|
|
439
|
+
[e.platform, e.platform_entity_id],
|
|
440
|
+
)
|
|
441
|
+
existing_row = await existing_cursor.fetchone()
|
|
442
|
+
existing_title = str(existing_row[1]) if existing_row and existing_row[1] else ""
|
|
443
|
+
existing_content = str(existing_row[2]) if existing_row and existing_row[2] else ""
|
|
444
|
+
fts_title = e.title if e.title is not None else existing_title
|
|
445
|
+
fts_content = e.content if e.content is not None else existing_content
|
|
446
|
+
rewrite_fts = (
|
|
447
|
+
existing_row is None
|
|
448
|
+
or fts_title != existing_title
|
|
449
|
+
or fts_content != existing_content
|
|
450
|
+
)
|
|
451
|
+
embedding = embeddings.get(e.platform_entity_id)
|
|
452
|
+
emb_blob = pack_embedding(embedding) if embedding else None
|
|
453
|
+
created = e.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if e.created_at else None
|
|
454
|
+
updated = e.updated_at.strftime("%Y-%m-%dT%H:%M:%SZ") if e.updated_at else None
|
|
455
|
+
cursor = await conn.execute(
|
|
456
|
+
"""
|
|
457
|
+
INSERT INTO entities
|
|
458
|
+
(id, entity_type, platform, platform_entity_id, title, content,
|
|
459
|
+
content_embedding, metadata, created_at, updated_at, synced_at, last_accessed)
|
|
460
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
461
|
+
ON CONFLICT (platform, platform_entity_id) DO UPDATE SET
|
|
462
|
+
entity_type = CASE WHEN entities.entity_type = 'Document' THEN EXCLUDED.entity_type ELSE entities.entity_type END,
|
|
463
|
+
title = COALESCE(EXCLUDED.title, entities.title),
|
|
464
|
+
content = COALESCE(EXCLUDED.content, entities.content),
|
|
465
|
+
content_embedding = COALESCE(EXCLUDED.content_embedding, entities.content_embedding),
|
|
466
|
+
metadata = EXCLUDED.metadata,
|
|
467
|
+
updated_at = COALESCE(EXCLUDED.updated_at, entities.updated_at),
|
|
468
|
+
synced_at = EXCLUDED.last_accessed,
|
|
469
|
+
last_accessed = EXCLUDED.last_accessed
|
|
470
|
+
RETURNING id
|
|
471
|
+
""",
|
|
472
|
+
[
|
|
473
|
+
_new_id(),
|
|
474
|
+
e.entity_type,
|
|
475
|
+
e.platform,
|
|
476
|
+
e.platform_entity_id,
|
|
477
|
+
e.title,
|
|
478
|
+
e.content,
|
|
479
|
+
emb_blob,
|
|
480
|
+
json.dumps(dict(e.metadata)),
|
|
481
|
+
created,
|
|
482
|
+
updated,
|
|
483
|
+
now,
|
|
484
|
+
now,
|
|
485
|
+
],
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
row = await cursor.fetchone()
|
|
489
|
+
if row is None:
|
|
490
|
+
raise RuntimeError(
|
|
491
|
+
f"Failed to upsert entity {e.platform}:{e.platform_entity_id}"
|
|
492
|
+
)
|
|
493
|
+
entity_id: str = row[0]
|
|
494
|
+
if rewrite_fts:
|
|
495
|
+
if existing_row is not None:
|
|
496
|
+
fts_delete_ids.append(entity_id)
|
|
497
|
+
if fts_title or fts_content:
|
|
498
|
+
fts_entries[entity_id] = (fts_title, fts_content)
|
|
499
|
+
id_map[e.platform_entity_id] = entity_id
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
row = await cursor.fetchone()
|
|
503
|
+
if row is None:
|
|
504
|
+
raise RuntimeError(
|
|
505
|
+
f"Failed to upsert stub entity {e.platform}:{e.platform_entity_id}"
|
|
506
|
+
)
|
|
507
|
+
id_map[e.platform_entity_id] = row[0]
|
|
508
|
+
|
|
509
|
+
for start in range(0, len(fts_delete_ids), _FTS_DELETE_CHUNK_SIZE):
|
|
510
|
+
ids = fts_delete_ids[start : start + _FTS_DELETE_CHUNK_SIZE]
|
|
511
|
+
placeholders = ",".join("?" * len(ids))
|
|
512
|
+
await conn.execute(f"DELETE FROM entities_fts WHERE id IN ({placeholders})", ids)
|
|
513
|
+
inserts = [
|
|
514
|
+
[entity_id, title, content]
|
|
515
|
+
for entity_id, entry in fts_entries.items()
|
|
516
|
+
for title, content in [entry]
|
|
517
|
+
]
|
|
518
|
+
if inserts:
|
|
519
|
+
await conn.executemany(
|
|
520
|
+
"INSERT INTO entities_fts (id, title, content) VALUES (?, ?, ?)", inserts
|
|
521
|
+
)
|
|
522
|
+
return id_map
|
|
523
|
+
|
|
524
|
+
async def _upsert_edges(
|
|
525
|
+
self,
|
|
526
|
+
conn: aiosqlite.Connection,
|
|
527
|
+
batch: EntityBatch,
|
|
528
|
+
person_id_map: dict[str, str],
|
|
529
|
+
entity_id_map: dict[str, str],
|
|
530
|
+
) -> None:
|
|
531
|
+
now = _now()
|
|
532
|
+
for edge in batch.edges:
|
|
533
|
+
source_id: str | None = (
|
|
534
|
+
entity_id_map.get(edge.source_platform_entity_id)
|
|
535
|
+
if edge.source_platform_entity_id
|
|
536
|
+
else person_id_map.get(edge.source_platform_user_id or "")
|
|
537
|
+
if edge.source_platform_user_id
|
|
538
|
+
else None
|
|
539
|
+
)
|
|
540
|
+
if not source_id:
|
|
541
|
+
if edge.source_platform_entity_id:
|
|
542
|
+
source_id = await self._resolve_existing_entity_id(
|
|
543
|
+
conn, edge.platform, edge.source_platform_entity_id
|
|
544
|
+
)
|
|
545
|
+
elif edge.source_platform_user_id:
|
|
546
|
+
source_id = await self._resolve_existing_person_id(
|
|
547
|
+
conn, edge.platform, edge.source_platform_user_id
|
|
548
|
+
)
|
|
549
|
+
target_id: str | None = (
|
|
550
|
+
entity_id_map.get(edge.target_platform_entity_id)
|
|
551
|
+
if edge.target_platform_entity_id
|
|
552
|
+
else person_id_map.get(edge.target_platform_user_id or "")
|
|
553
|
+
if edge.target_platform_user_id
|
|
554
|
+
else None
|
|
555
|
+
)
|
|
556
|
+
if not target_id:
|
|
557
|
+
if edge.target_platform_entity_id:
|
|
558
|
+
target_id = await self._resolve_existing_entity_id(
|
|
559
|
+
conn, edge.platform, edge.target_platform_entity_id
|
|
560
|
+
)
|
|
561
|
+
elif edge.target_platform_user_id:
|
|
562
|
+
target_id = await self._resolve_existing_person_id(
|
|
563
|
+
conn, edge.platform, edge.target_platform_user_id
|
|
564
|
+
)
|
|
565
|
+
if not source_id:
|
|
566
|
+
logger.warning("Skipping edge %s — source not resolved", edge.edge_type)
|
|
567
|
+
continue
|
|
568
|
+
if not target_id:
|
|
569
|
+
logger.warning("Skipping edge %s — target not resolved", edge.edge_type)
|
|
570
|
+
continue
|
|
571
|
+
await conn.execute(
|
|
572
|
+
"""
|
|
573
|
+
INSERT INTO edges (id, edge_type, source_entity_id, target_entity_id, platform, properties, created_at)
|
|
574
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
575
|
+
ON CONFLICT (edge_type, source_entity_id, target_entity_id) DO NOTHING
|
|
576
|
+
""",
|
|
577
|
+
[
|
|
578
|
+
_new_id(),
|
|
579
|
+
edge.edge_type,
|
|
580
|
+
source_id,
|
|
581
|
+
target_id,
|
|
582
|
+
edge.platform,
|
|
583
|
+
json.dumps(dict(edge.properties)),
|
|
584
|
+
now,
|
|
585
|
+
],
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
async def merge_person_entities(
|
|
589
|
+
self,
|
|
590
|
+
primary_entity_id: str,
|
|
591
|
+
duplicate_entity_ids: list[str],
|
|
592
|
+
) -> EntityResult:
|
|
593
|
+
duplicate_ids = [eid for eid in duplicate_entity_ids if eid != primary_entity_id]
|
|
594
|
+
if not duplicate_ids:
|
|
595
|
+
entity = await self.get_entity_by_id(primary_entity_id)
|
|
596
|
+
if entity is None:
|
|
597
|
+
raise ValueError(f"Entity {primary_entity_id!r} not found")
|
|
598
|
+
if entity["entity_type"] != "Person":
|
|
599
|
+
raise ValueError(f"Entity {primary_entity_id!r} is not a Person")
|
|
600
|
+
return entity
|
|
601
|
+
|
|
602
|
+
assert self._write_lock is not None
|
|
603
|
+
async with self._write_lock:
|
|
604
|
+
conn = self._conn_or_raise()
|
|
605
|
+
await conn.execute("BEGIN")
|
|
606
|
+
try:
|
|
607
|
+
all_ids = [primary_entity_id, *duplicate_ids]
|
|
608
|
+
placeholders = ",".join("?" * len(all_ids))
|
|
609
|
+
cursor = await conn.execute(
|
|
610
|
+
f"""
|
|
611
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
612
|
+
title, content, metadata, last_accessed
|
|
613
|
+
FROM entities
|
|
614
|
+
WHERE id IN ({placeholders})
|
|
615
|
+
""",
|
|
616
|
+
all_ids,
|
|
617
|
+
)
|
|
618
|
+
rows = await cursor.fetchall()
|
|
619
|
+
by_id = {str(row["id"]): row for row in rows}
|
|
620
|
+
missing = [eid for eid in all_ids if eid not in by_id]
|
|
621
|
+
if missing:
|
|
622
|
+
raise ValueError(f"Person entity not found: {missing[0]}")
|
|
623
|
+
for eid, row in by_id.items():
|
|
624
|
+
if row["entity_type"] != "Person":
|
|
625
|
+
raise ValueError(f"Entity {eid!r} is not a Person")
|
|
626
|
+
|
|
627
|
+
primary = by_id[primary_entity_id]
|
|
628
|
+
primary_metadata = json.loads(primary["metadata"] or "{}")
|
|
629
|
+
merged_people: list[dict[str, str]] = []
|
|
630
|
+
merged_person_ids: set[str] = set()
|
|
631
|
+
_append_merged_people(
|
|
632
|
+
primary_metadata,
|
|
633
|
+
merged_people,
|
|
634
|
+
merged_person_ids,
|
|
635
|
+
)
|
|
636
|
+
for eid in duplicate_ids:
|
|
637
|
+
duplicate = by_id[eid]
|
|
638
|
+
duplicate_metadata = json.loads(duplicate["metadata"] or "{}")
|
|
639
|
+
_append_merged_people(
|
|
640
|
+
duplicate_metadata,
|
|
641
|
+
merged_people,
|
|
642
|
+
merged_person_ids,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
merged_people.append(
|
|
646
|
+
{
|
|
647
|
+
"id": eid,
|
|
648
|
+
"title": str(duplicate["title"] or duplicate["platform_entity_id"]),
|
|
649
|
+
"platform_entity_id": str(duplicate["platform_entity_id"]),
|
|
650
|
+
}
|
|
651
|
+
)
|
|
652
|
+
merged_person_ids.add(eid)
|
|
653
|
+
|
|
654
|
+
merged_metadata: dict[str, Any] = {}
|
|
655
|
+
for eid in duplicate_ids:
|
|
656
|
+
merged_metadata.update(json.loads(by_id[eid]["metadata"] or "{}"))
|
|
657
|
+
merged_metadata.update(primary_metadata)
|
|
658
|
+
merged_metadata["merged_people"] = merged_people
|
|
659
|
+
|
|
660
|
+
title = primary["title"] or next(
|
|
661
|
+
(by_id[eid]["title"] for eid in duplicate_ids if by_id[eid]["title"]),
|
|
662
|
+
None,
|
|
663
|
+
)
|
|
664
|
+
content = primary["content"] or next(
|
|
665
|
+
(by_id[eid]["content"] for eid in duplicate_ids if by_id[eid]["content"]),
|
|
666
|
+
None,
|
|
667
|
+
)
|
|
668
|
+
last_accessed_values = [
|
|
669
|
+
value for value in (by_id[eid]["last_accessed"] for eid in all_ids) if value
|
|
670
|
+
]
|
|
671
|
+
last_accessed = max(last_accessed_values) if last_accessed_values else _now()
|
|
672
|
+
|
|
673
|
+
await conn.execute(
|
|
674
|
+
"""
|
|
675
|
+
UPDATE entities
|
|
676
|
+
SET title = ?, content = ?, metadata = ?, last_accessed = ?
|
|
677
|
+
WHERE id = ?
|
|
678
|
+
""",
|
|
679
|
+
[title, content, json.dumps(merged_metadata), last_accessed, primary_entity_id],
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
dup_placeholders = ",".join("?" * len(duplicate_ids))
|
|
683
|
+
await conn.execute(
|
|
684
|
+
f"""
|
|
685
|
+
UPDATE OR IGNORE edges
|
|
686
|
+
SET source_entity_id = ?
|
|
687
|
+
WHERE source_entity_id IN ({dup_placeholders})
|
|
688
|
+
""",
|
|
689
|
+
[primary_entity_id, *duplicate_ids],
|
|
690
|
+
)
|
|
691
|
+
await conn.execute(
|
|
692
|
+
f"DELETE FROM edges WHERE source_entity_id IN ({dup_placeholders})",
|
|
693
|
+
duplicate_ids,
|
|
694
|
+
)
|
|
695
|
+
await conn.execute(
|
|
696
|
+
f"""
|
|
697
|
+
UPDATE OR IGNORE edges
|
|
698
|
+
SET target_entity_id = ?
|
|
699
|
+
WHERE target_entity_id IN ({dup_placeholders})
|
|
700
|
+
""",
|
|
701
|
+
[primary_entity_id, *duplicate_ids],
|
|
702
|
+
)
|
|
703
|
+
await conn.execute(
|
|
704
|
+
f"DELETE FROM edges WHERE target_entity_id IN ({dup_placeholders})",
|
|
705
|
+
duplicate_ids,
|
|
706
|
+
)
|
|
707
|
+
await conn.execute(
|
|
708
|
+
"DELETE FROM edges WHERE source_entity_id = ? AND target_entity_id = ?",
|
|
709
|
+
[primary_entity_id, primary_entity_id],
|
|
710
|
+
)
|
|
711
|
+
await conn.execute(
|
|
712
|
+
f"DELETE FROM entities_fts WHERE id IN ({','.join('?' * len(all_ids))})",
|
|
713
|
+
all_ids,
|
|
714
|
+
)
|
|
715
|
+
await conn.execute(
|
|
716
|
+
f"DELETE FROM entities WHERE id IN ({dup_placeholders})",
|
|
717
|
+
duplicate_ids,
|
|
718
|
+
)
|
|
719
|
+
if title or content:
|
|
720
|
+
await conn.execute(
|
|
721
|
+
"INSERT INTO entities_fts (id, title, content) VALUES (?, ?, ?)",
|
|
722
|
+
[primary_entity_id, title or "", content or ""],
|
|
723
|
+
)
|
|
724
|
+
await conn.execute("COMMIT")
|
|
725
|
+
except Exception:
|
|
726
|
+
await conn.execute("ROLLBACK")
|
|
727
|
+
raise
|
|
728
|
+
|
|
729
|
+
entity = await self.get_entity_by_id(primary_entity_id)
|
|
730
|
+
if entity is None:
|
|
731
|
+
raise RuntimeError("Merged primary person disappeared")
|
|
732
|
+
return entity
|
|
733
|
+
|
|
734
|
+
async def set_entity_bookmarked(
|
|
735
|
+
self,
|
|
736
|
+
entity_id: str,
|
|
737
|
+
bookmarked: bool,
|
|
738
|
+
) -> EntityResult:
|
|
739
|
+
now = _now()
|
|
740
|
+
cursor = await self._conn_or_raise().execute(
|
|
741
|
+
"""
|
|
742
|
+
UPDATE entities
|
|
743
|
+
SET bookmarked = ?, last_accessed = ?
|
|
744
|
+
WHERE id = ?
|
|
745
|
+
RETURNING id, entity_type, platform, platform_entity_id,
|
|
746
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
747
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
748
|
+
""",
|
|
749
|
+
[1 if bookmarked else 0, now, entity_id],
|
|
750
|
+
)
|
|
751
|
+
row = await cursor.fetchone()
|
|
752
|
+
if row is None:
|
|
753
|
+
raise ValueError(f"Entity {entity_id!r} not found")
|
|
754
|
+
return _row_to_entity(row)
|
|
755
|
+
|
|
756
|
+
async def delete_entity(self, entity_id: str) -> EntityResult:
|
|
757
|
+
"""Delete one entity by internal ID. Edges cascade; FTS rows are removed explicitly."""
|
|
758
|
+
assert self._write_lock is not None
|
|
759
|
+
async with self._write_lock:
|
|
760
|
+
conn = self._conn_or_raise()
|
|
761
|
+
await conn.execute("BEGIN")
|
|
762
|
+
try:
|
|
763
|
+
cursor = await conn.execute(
|
|
764
|
+
"""
|
|
765
|
+
DELETE FROM entities
|
|
766
|
+
WHERE id = ?
|
|
767
|
+
RETURNING id, entity_type, platform, platform_entity_id,
|
|
768
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
769
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
770
|
+
""",
|
|
771
|
+
[entity_id],
|
|
772
|
+
)
|
|
773
|
+
row = await cursor.fetchone()
|
|
774
|
+
if row is None:
|
|
775
|
+
raise ValueError(f"Entity {entity_id!r} not found")
|
|
776
|
+
await conn.execute("DELETE FROM entities_fts WHERE id = ?", [entity_id])
|
|
777
|
+
await conn.execute("COMMIT")
|
|
778
|
+
except Exception:
|
|
779
|
+
await conn.execute("ROLLBACK")
|
|
780
|
+
raise
|
|
781
|
+
return _row_to_entity(row)
|
|
782
|
+
|
|
783
|
+
# --- Read: entities ---
|
|
784
|
+
|
|
785
|
+
async def search_entities(
|
|
786
|
+
self,
|
|
787
|
+
query_vec: list[float],
|
|
788
|
+
query_text: str,
|
|
789
|
+
entity_types: list[str] | None,
|
|
790
|
+
limit: int,
|
|
791
|
+
min_score: float,
|
|
792
|
+
platform: str | None = None,
|
|
793
|
+
) -> list[EntityResult]:
|
|
794
|
+
conn = self._read_conn_or_raise()
|
|
795
|
+
|
|
796
|
+
with timed("sqlite.search_entities", limit=limit, platform=platform):
|
|
797
|
+
initial_candidate_limit = limit * 2
|
|
798
|
+
max_candidate_limit = limit * 5
|
|
799
|
+
|
|
800
|
+
# BM25 via FTS5
|
|
801
|
+
fts_ids: list[tuple[str, int]] = []
|
|
802
|
+
with timed("sqlite.search.fts", limit=initial_candidate_limit, platform=platform):
|
|
803
|
+
try:
|
|
804
|
+
extra_clause = ""
|
|
805
|
+
fts_extra_params: list[Any] = []
|
|
806
|
+
if entity_types:
|
|
807
|
+
placeholders = ",".join("?" * len(entity_types))
|
|
808
|
+
extra_clause += f" AND e.entity_type IN ({placeholders})"
|
|
809
|
+
fts_extra_params.extend(entity_types)
|
|
810
|
+
if platform:
|
|
811
|
+
extra_clause += " AND e.platform = ?"
|
|
812
|
+
fts_extra_params.append(platform)
|
|
813
|
+
|
|
814
|
+
cursor = await conn.execute(
|
|
815
|
+
f"""
|
|
816
|
+
SELECT e.id
|
|
817
|
+
FROM entities_fts f
|
|
818
|
+
JOIN entities e ON e.id = f.id
|
|
819
|
+
WHERE entities_fts MATCH ? {extra_clause}
|
|
820
|
+
ORDER BY f.rank
|
|
821
|
+
LIMIT ?
|
|
822
|
+
""",
|
|
823
|
+
[_fts5_query(query_text), *fts_extra_params, initial_candidate_limit],
|
|
824
|
+
)
|
|
825
|
+
rows = await cursor.fetchall()
|
|
826
|
+
fts_ids = [(row[0], i + 1) for i, row in enumerate(rows)]
|
|
827
|
+
except Exception:
|
|
828
|
+
pass
|
|
829
|
+
|
|
830
|
+
# Vector search is the expensive leg. A saturated initial FTS
|
|
831
|
+
# window already has enough lexical candidates for the requested
|
|
832
|
+
# result count, so avoid the O(n) vector scan in that common case.
|
|
833
|
+
# Sparse FTS queries use the larger window to preserve the existing
|
|
834
|
+
# hybrid-search recall.
|
|
835
|
+
if len(fts_ids) >= initial_candidate_limit:
|
|
836
|
+
vec_ids: list[tuple[str, int]] = []
|
|
837
|
+
logger.debug(
|
|
838
|
+
"search skipped vector scan because FTS returned %d candidates",
|
|
839
|
+
len(fts_ids),
|
|
840
|
+
)
|
|
841
|
+
else:
|
|
842
|
+
vec_ids = await vector_ranked(
|
|
843
|
+
conn,
|
|
844
|
+
query_vec,
|
|
845
|
+
entity_types,
|
|
846
|
+
limit,
|
|
847
|
+
self._vector_mode,
|
|
848
|
+
self._vec_loaded,
|
|
849
|
+
platform=platform,
|
|
850
|
+
candidate_limit=max_candidate_limit,
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
# RRF fusion (k=60, fulltext weight=2x)
|
|
854
|
+
# Rule: if BM25 found anything, include only results that BM25 also found.
|
|
855
|
+
with timed("sqlite.search.fusion", limit=limit):
|
|
856
|
+
fts_set = {eid for eid, _ in fts_ids}
|
|
857
|
+
vec_rank: dict[str, int] = {eid: rank for eid, rank in vec_ids}
|
|
858
|
+
fts_rank: dict[str, int] = {eid: rank for eid, rank in fts_ids}
|
|
859
|
+
|
|
860
|
+
candidates = fts_set if fts_set else set(vec_rank)
|
|
861
|
+
if not candidates:
|
|
862
|
+
return []
|
|
863
|
+
|
|
864
|
+
scored: list[tuple[str, float]] = []
|
|
865
|
+
for eid in candidates:
|
|
866
|
+
score = 0.0
|
|
867
|
+
if eid in vec_rank:
|
|
868
|
+
score += 1.0 / (60 + vec_rank[eid])
|
|
869
|
+
if eid in fts_rank:
|
|
870
|
+
score += 2.0 / (60 + fts_rank[eid])
|
|
871
|
+
scored.append((eid, score))
|
|
872
|
+
|
|
873
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
874
|
+
top = scored[:limit]
|
|
875
|
+
|
|
876
|
+
if not top:
|
|
877
|
+
return []
|
|
878
|
+
|
|
879
|
+
import math
|
|
880
|
+
|
|
881
|
+
id_list = [eid for eid, _ in top]
|
|
882
|
+
score_map = {eid: sc for eid, sc in top}
|
|
883
|
+
placeholders = ",".join("?" * len(id_list))
|
|
884
|
+
with timed("sqlite.search.hydrate", count=len(id_list)):
|
|
885
|
+
cursor = await conn.execute(
|
|
886
|
+
f"""
|
|
887
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
888
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
889
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
890
|
+
FROM entities WHERE id IN ({placeholders})
|
|
891
|
+
""",
|
|
892
|
+
id_list,
|
|
893
|
+
)
|
|
894
|
+
rows = await cursor.fetchall()
|
|
895
|
+
results: list[dict[str, Any]] = []
|
|
896
|
+
for row in rows:
|
|
897
|
+
r = _row_to_entity(row)
|
|
898
|
+
base_score = score_map.get(r["id"], 0.0)
|
|
899
|
+
dwell_ms = r.get("cumulative_dwell_ms", 0)
|
|
900
|
+
dwell_boost = 0.1 * math.log10(1 + (dwell_ms / 1000.0))
|
|
901
|
+
r["score"] = base_score + dwell_boost
|
|
902
|
+
|
|
903
|
+
if (r["score"] or 0) >= min_score:
|
|
904
|
+
results.append(r)
|
|
905
|
+
|
|
906
|
+
def _score(result: dict[str, Any]) -> float:
|
|
907
|
+
raw_score = result.get("score")
|
|
908
|
+
return float(raw_score) if isinstance(raw_score, int | float) else 0.0
|
|
909
|
+
|
|
910
|
+
results.sort(key=_score, reverse=True)
|
|
911
|
+
return results
|
|
912
|
+
|
|
913
|
+
async def get_entity_by_id(self, entity_id: str) -> EntityResult | None:
|
|
914
|
+
row = await self._fetchone(
|
|
915
|
+
"""
|
|
916
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
917
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
918
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
919
|
+
FROM entities WHERE id = ?
|
|
920
|
+
""",
|
|
921
|
+
[entity_id],
|
|
922
|
+
)
|
|
923
|
+
return _row_to_entity(row) if row else None
|
|
924
|
+
|
|
925
|
+
async def get_entities_by_ids(self, entity_ids: list[str]) -> list[EntityResult]:
|
|
926
|
+
if not entity_ids:
|
|
927
|
+
return []
|
|
928
|
+
placeholders = ",".join("?" * len(entity_ids))
|
|
929
|
+
rows = await self._fetchall(
|
|
930
|
+
f"""
|
|
931
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
932
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
933
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
934
|
+
FROM entities WHERE id IN ({placeholders})
|
|
935
|
+
""",
|
|
936
|
+
entity_ids,
|
|
937
|
+
)
|
|
938
|
+
return [_row_to_entity(r) for r in rows]
|
|
939
|
+
|
|
940
|
+
async def get_entities_by_id_prefix(self, prefix: str) -> list[EntityResult]:
|
|
941
|
+
rows = await self._fetchall(
|
|
942
|
+
"""
|
|
943
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
944
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
945
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
946
|
+
FROM entities WHERE id LIKE ?
|
|
947
|
+
""",
|
|
948
|
+
[f"{prefix}%"],
|
|
949
|
+
)
|
|
950
|
+
return [_row_to_entity(row) for row in rows]
|
|
951
|
+
|
|
952
|
+
async def get_entity_by_platform(
|
|
953
|
+
self, platform: str, platform_entity_id: str
|
|
954
|
+
) -> EntityResult | None:
|
|
955
|
+
row = await self._fetchone(
|
|
956
|
+
"""
|
|
957
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
958
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
959
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
960
|
+
FROM entities WHERE platform = ? AND platform_entity_id = ?
|
|
961
|
+
""",
|
|
962
|
+
[platform, platform_entity_id],
|
|
963
|
+
)
|
|
964
|
+
return _row_to_entity(row) if row else None
|
|
965
|
+
|
|
966
|
+
async def list_entities(
|
|
967
|
+
self,
|
|
968
|
+
entity_types: list[str] | None,
|
|
969
|
+
platform: str | None,
|
|
970
|
+
since: datetime | None,
|
|
971
|
+
limit: int,
|
|
972
|
+
) -> list[EntityResult]:
|
|
973
|
+
clauses: list[str] = []
|
|
974
|
+
params: list[Any] = []
|
|
975
|
+
if entity_types:
|
|
976
|
+
placeholders = ",".join("?" * len(entity_types))
|
|
977
|
+
clauses.append(f"entity_type IN ({placeholders})")
|
|
978
|
+
params.extend(entity_types)
|
|
979
|
+
if platform:
|
|
980
|
+
clauses.append("platform = ?")
|
|
981
|
+
params.append(platform)
|
|
982
|
+
if since:
|
|
983
|
+
clauses.append("updated_at >= ?")
|
|
984
|
+
params.append(since.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
985
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
986
|
+
params.append(limit)
|
|
987
|
+
rows = await self._fetchall(
|
|
988
|
+
f"""
|
|
989
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
990
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
991
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
992
|
+
FROM entities
|
|
993
|
+
{where}
|
|
994
|
+
ORDER BY last_accessed DESC
|
|
995
|
+
LIMIT ?
|
|
996
|
+
""",
|
|
997
|
+
params,
|
|
998
|
+
)
|
|
999
|
+
return [_row_to_entity(row) for row in rows]
|
|
1000
|
+
|
|
1001
|
+
async def list_entities_page(
|
|
1002
|
+
self,
|
|
1003
|
+
entity_types: list[str] | None,
|
|
1004
|
+
platform: str | None,
|
|
1005
|
+
since: datetime | None,
|
|
1006
|
+
limit: int,
|
|
1007
|
+
offset: int,
|
|
1008
|
+
order_by: str | None,
|
|
1009
|
+
order_dir: str,
|
|
1010
|
+
) -> tuple[list[EntityResult], int]:
|
|
1011
|
+
order_by_sql = (
|
|
1012
|
+
_LIST_PAGE_ORDER_BY.get(order_by, "last_accessed") if order_by is not None else None
|
|
1013
|
+
)
|
|
1014
|
+
if order_dir.upper() not in {"ASC", "DESC"}:
|
|
1015
|
+
order_dir = "DESC"
|
|
1016
|
+
order_clause = (
|
|
1017
|
+
f"ORDER BY {order_by_sql} {order_dir}, id ASC" if order_by_sql is not None else ""
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
clauses: list[str] = []
|
|
1021
|
+
params: list[Any] = []
|
|
1022
|
+
if entity_types:
|
|
1023
|
+
placeholders = ",".join("?" * len(entity_types))
|
|
1024
|
+
clauses.append(f"entity_type IN ({placeholders})")
|
|
1025
|
+
params.extend(entity_types)
|
|
1026
|
+
if platform:
|
|
1027
|
+
clauses.append("platform = ?")
|
|
1028
|
+
params.append(platform)
|
|
1029
|
+
if since:
|
|
1030
|
+
clauses.append("updated_at >= ?")
|
|
1031
|
+
params.append(since.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
1032
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
1033
|
+
|
|
1034
|
+
count_row = await self._fetchone(f"SELECT COUNT(*) AS count FROM entities {where}", params)
|
|
1035
|
+
total = int(count_row["count"]) if count_row else 0
|
|
1036
|
+
rows = await self._fetchall(
|
|
1037
|
+
f"""
|
|
1038
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
1039
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
1040
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
1041
|
+
FROM entities
|
|
1042
|
+
{where}
|
|
1043
|
+
{order_clause}
|
|
1044
|
+
LIMIT ? OFFSET ?
|
|
1045
|
+
""",
|
|
1046
|
+
[*params, limit, offset],
|
|
1047
|
+
)
|
|
1048
|
+
return [_row_to_entity(row) for row in rows], total
|
|
1049
|
+
|
|
1050
|
+
async def query_by_filter(
|
|
1051
|
+
self,
|
|
1052
|
+
entity_type: str,
|
|
1053
|
+
filters: dict[str, str],
|
|
1054
|
+
limit: int,
|
|
1055
|
+
order_by: str,
|
|
1056
|
+
since: datetime | None,
|
|
1057
|
+
authored_by: list[str] | None,
|
|
1058
|
+
has_attachments: bool = False,
|
|
1059
|
+
) -> list[EntityResult]:
|
|
1060
|
+
if order_by not in _VALID_ORDER_BY:
|
|
1061
|
+
order_by = "last_accessed"
|
|
1062
|
+
|
|
1063
|
+
params: list[Any] = [entity_type]
|
|
1064
|
+
extra_clauses: list[str] = []
|
|
1065
|
+
for k, v in filters.items():
|
|
1066
|
+
if k in _COLUMN_FILTERS:
|
|
1067
|
+
extra_clauses.append(f"e.{k} = ?")
|
|
1068
|
+
else:
|
|
1069
|
+
extra_clauses.append(f"json_extract(e.metadata, '$.{k}') = ?")
|
|
1070
|
+
params.append(v)
|
|
1071
|
+
if since:
|
|
1072
|
+
extra_clauses.append("e.updated_at >= ?")
|
|
1073
|
+
params.append(since.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
1074
|
+
if has_attachments:
|
|
1075
|
+
extra_clauses.append(
|
|
1076
|
+
"json_extract(e.metadata, '$.attachments') IS NOT NULL"
|
|
1077
|
+
" AND json_extract(e.metadata, '$.attachments') != '[]'"
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
authored_join = ""
|
|
1081
|
+
authored_params: list[Any] = []
|
|
1082
|
+
if authored_by:
|
|
1083
|
+
placeholders = ", ".join("?" for _ in authored_by)
|
|
1084
|
+
metadata_placeholders = ", ".join("?" for _ in authored_by)
|
|
1085
|
+
authored_join = f"""
|
|
1086
|
+
JOIN edges _auth ON _auth.edge_type = 'authored' AND _auth.target_entity_id = e.id
|
|
1087
|
+
JOIN entities _p ON _p.id = _auth.source_entity_id AND _p.entity_type = 'Person'
|
|
1088
|
+
AND (
|
|
1089
|
+
_p.platform_entity_id IN ({placeholders})
|
|
1090
|
+
OR EXISTS (
|
|
1091
|
+
SELECT 1 FROM json_each(_p.metadata)
|
|
1092
|
+
WHERE json_each.value IN ({metadata_placeholders})
|
|
1093
|
+
)
|
|
1094
|
+
)
|
|
1095
|
+
"""
|
|
1096
|
+
authored_params.extend([*authored_by, *authored_by])
|
|
1097
|
+
|
|
1098
|
+
where_extra = ("AND " + " AND ".join(extra_clauses)) if extra_clauses else ""
|
|
1099
|
+
params.append(limit)
|
|
1100
|
+
with timed(
|
|
1101
|
+
"sqlite.query_by_filter", entity_type=entity_type, order_by=order_by, limit=limit
|
|
1102
|
+
):
|
|
1103
|
+
rows = await self._fetchall(
|
|
1104
|
+
f"""
|
|
1105
|
+
SELECT e.id, e.entity_type, e.platform, e.platform_entity_id,
|
|
1106
|
+
e.title, e.content, e.metadata, e.created_at, e.updated_at,
|
|
1107
|
+
e.synced_at, e.observed_at, e.last_accessed,
|
|
1108
|
+
e.cumulative_dwell_ms, e.bookmarked
|
|
1109
|
+
FROM entities e
|
|
1110
|
+
{authored_join}
|
|
1111
|
+
WHERE e.entity_type = ? {where_extra}
|
|
1112
|
+
ORDER BY e.{order_by} DESC
|
|
1113
|
+
LIMIT ?
|
|
1114
|
+
""",
|
|
1115
|
+
[*authored_params, *params],
|
|
1116
|
+
)
|
|
1117
|
+
return [_row_to_entity(row) for row in rows]
|
|
1118
|
+
|
|
1119
|
+
# --- Read: edges ---
|
|
1120
|
+
|
|
1121
|
+
async def get_edges(
|
|
1122
|
+
self,
|
|
1123
|
+
entity_id: str,
|
|
1124
|
+
edge_type: str | None,
|
|
1125
|
+
direction: str,
|
|
1126
|
+
) -> list[EdgeResult]:
|
|
1127
|
+
conditions: list[str] = []
|
|
1128
|
+
params: list[Any] = []
|
|
1129
|
+
if direction in ("out", "both"):
|
|
1130
|
+
conditions.append("e.source_entity_id = ?")
|
|
1131
|
+
params.append(entity_id)
|
|
1132
|
+
if direction in ("in", "both"):
|
|
1133
|
+
conditions.append("e.target_entity_id = ?")
|
|
1134
|
+
params.append(entity_id)
|
|
1135
|
+
if not conditions:
|
|
1136
|
+
return []
|
|
1137
|
+
|
|
1138
|
+
type_clause = ""
|
|
1139
|
+
if edge_type:
|
|
1140
|
+
type_clause = "AND e.edge_type = ?"
|
|
1141
|
+
params.append(edge_type)
|
|
1142
|
+
|
|
1143
|
+
where = " OR ".join(f"({c})" for c in conditions)
|
|
1144
|
+
rows = await self._fetchall(
|
|
1145
|
+
f"""
|
|
1146
|
+
SELECT e.id, e.edge_type, e.platform, e.properties,
|
|
1147
|
+
e.source_entity_id, e.target_entity_id,
|
|
1148
|
+
se.platform_entity_id AS source_ref,
|
|
1149
|
+
te.platform_entity_id AS target_ref
|
|
1150
|
+
FROM edges e
|
|
1151
|
+
LEFT JOIN entities se ON se.id = e.source_entity_id
|
|
1152
|
+
LEFT JOIN entities te ON te.id = e.target_entity_id
|
|
1153
|
+
WHERE ({where}) {type_clause}
|
|
1154
|
+
ORDER BY e.created_at DESC
|
|
1155
|
+
""",
|
|
1156
|
+
params,
|
|
1157
|
+
)
|
|
1158
|
+
return [_row_to_edge(row) for row in rows]
|
|
1159
|
+
|
|
1160
|
+
async def get_edges_for_entities(self, entity_ids: list[str]) -> list[EdgeResult]:
|
|
1161
|
+
if not entity_ids:
|
|
1162
|
+
return []
|
|
1163
|
+
placeholders = ",".join("?" * len(entity_ids))
|
|
1164
|
+
rows = await self._fetchall(
|
|
1165
|
+
f"""
|
|
1166
|
+
SELECT e.id, e.edge_type, e.platform, e.properties,
|
|
1167
|
+
e.source_entity_id, e.target_entity_id,
|
|
1168
|
+
se.platform_entity_id AS source_ref,
|
|
1169
|
+
te.platform_entity_id AS target_ref
|
|
1170
|
+
FROM edges e
|
|
1171
|
+
LEFT JOIN entities se ON se.id = e.source_entity_id
|
|
1172
|
+
LEFT JOIN entities te ON te.id = e.target_entity_id
|
|
1173
|
+
WHERE e.source_entity_id IN ({placeholders})
|
|
1174
|
+
OR e.target_entity_id IN ({placeholders})
|
|
1175
|
+
""",
|
|
1176
|
+
entity_ids + entity_ids,
|
|
1177
|
+
)
|
|
1178
|
+
return [_row_to_edge(row) for row in rows]
|
|
1179
|
+
|
|
1180
|
+
async def traverse_graph(self, entity_id: str, max_depth: int) -> dict[str, Any]:
|
|
1181
|
+
conn = self._read_conn_or_raise()
|
|
1182
|
+
visited: set[str] = set()
|
|
1183
|
+
frontier: list[str] = [entity_id]
|
|
1184
|
+
all_nodes: list[EntityResult] = []
|
|
1185
|
+
all_edges: list[EdgeResult] = []
|
|
1186
|
+
seen_edge_ids: set[str] = set()
|
|
1187
|
+
|
|
1188
|
+
for _ in range(max_depth):
|
|
1189
|
+
if not frontier:
|
|
1190
|
+
break
|
|
1191
|
+
placeholders = ",".join("?" * len(frontier))
|
|
1192
|
+
cursor = await conn.execute(
|
|
1193
|
+
f"""
|
|
1194
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
1195
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
1196
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
1197
|
+
FROM entities WHERE id IN ({placeholders})
|
|
1198
|
+
""",
|
|
1199
|
+
frontier,
|
|
1200
|
+
)
|
|
1201
|
+
for row in await cursor.fetchall():
|
|
1202
|
+
eid = row["id"]
|
|
1203
|
+
if eid not in visited:
|
|
1204
|
+
visited.add(eid)
|
|
1205
|
+
all_nodes.append(_row_to_entity(row))
|
|
1206
|
+
|
|
1207
|
+
cursor = await conn.execute(
|
|
1208
|
+
f"""
|
|
1209
|
+
SELECT e.id, e.edge_type, e.platform, e.properties,
|
|
1210
|
+
e.source_entity_id, e.target_entity_id,
|
|
1211
|
+
se.platform_entity_id AS source_ref,
|
|
1212
|
+
te.platform_entity_id AS target_ref
|
|
1213
|
+
FROM edges e
|
|
1214
|
+
LEFT JOIN entities se ON se.id = e.source_entity_id
|
|
1215
|
+
LEFT JOIN entities te ON te.id = e.target_entity_id
|
|
1216
|
+
WHERE e.source_entity_id IN ({placeholders})
|
|
1217
|
+
OR e.target_entity_id IN ({placeholders})
|
|
1218
|
+
""",
|
|
1219
|
+
frontier + frontier,
|
|
1220
|
+
)
|
|
1221
|
+
next_frontier: list[str] = []
|
|
1222
|
+
for row in await cursor.fetchall():
|
|
1223
|
+
edge_id = str(row["id"])
|
|
1224
|
+
if edge_id not in seen_edge_ids:
|
|
1225
|
+
seen_edge_ids.add(edge_id)
|
|
1226
|
+
all_edges.append(_row_to_edge(row))
|
|
1227
|
+
for key in ("source_entity_id", "target_entity_id"):
|
|
1228
|
+
val: str | None = row[key]
|
|
1229
|
+
if val and val not in visited:
|
|
1230
|
+
next_frontier.append(val)
|
|
1231
|
+
frontier = list(set(next_frontier))
|
|
1232
|
+
|
|
1233
|
+
# Load final frontier
|
|
1234
|
+
unvisited = [eid for eid in frontier if eid not in visited]
|
|
1235
|
+
if unvisited:
|
|
1236
|
+
placeholders = ",".join("?" * len(unvisited))
|
|
1237
|
+
cursor = await conn.execute(
|
|
1238
|
+
f"""
|
|
1239
|
+
SELECT id, entity_type, platform, platform_entity_id,
|
|
1240
|
+
title, content, metadata, created_at, updated_at, synced_at,
|
|
1241
|
+
observed_at, last_accessed, cumulative_dwell_ms, bookmarked
|
|
1242
|
+
FROM entities WHERE id IN ({placeholders})
|
|
1243
|
+
""",
|
|
1244
|
+
unvisited,
|
|
1245
|
+
)
|
|
1246
|
+
for row in await cursor.fetchall():
|
|
1247
|
+
all_nodes.append(_row_to_entity(row))
|
|
1248
|
+
|
|
1249
|
+
return {"nodes": all_nodes, "edges": all_edges}
|
|
1250
|
+
|
|
1251
|
+
# --- Linking ---
|
|
1252
|
+
|
|
1253
|
+
async def find_entity_id(self, platform: str, platform_entity_id: str) -> str | None:
|
|
1254
|
+
return await self._fetchval(
|
|
1255
|
+
"SELECT id FROM entities WHERE platform = ? AND platform_entity_id = ?",
|
|
1256
|
+
[platform, platform_entity_id],
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
async def upsert_stub_entity(
|
|
1260
|
+
self, entity_type: str, platform: str, platform_entity_id: str
|
|
1261
|
+
) -> str:
|
|
1262
|
+
cursor = await self._conn_or_raise().execute(
|
|
1263
|
+
"""
|
|
1264
|
+
INSERT INTO entities (id, entity_type, platform, platform_entity_id, last_accessed)
|
|
1265
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1266
|
+
ON CONFLICT (platform, platform_entity_id) DO UPDATE SET
|
|
1267
|
+
last_accessed = EXCLUDED.last_accessed
|
|
1268
|
+
RETURNING id
|
|
1269
|
+
""",
|
|
1270
|
+
[_new_id(), entity_type, platform, platform_entity_id, _now()],
|
|
1271
|
+
)
|
|
1272
|
+
row = await cursor.fetchone()
|
|
1273
|
+
if row is None:
|
|
1274
|
+
raise RuntimeError(f"Failed to upsert stub entity {platform}:{platform_entity_id}")
|
|
1275
|
+
return row[0]
|
|
1276
|
+
|
|
1277
|
+
async def insert_references_edge(self, source_id: str, target_id: str) -> None:
|
|
1278
|
+
await self._execute(
|
|
1279
|
+
"""
|
|
1280
|
+
INSERT INTO edges (id, edge_type, source_entity_id, target_entity_id, platform, properties)
|
|
1281
|
+
VALUES (?, 'references', ?, ?, 'cross', '{}')
|
|
1282
|
+
ON CONFLICT (edge_type, source_entity_id, target_entity_id) DO NOTHING
|
|
1283
|
+
""",
|
|
1284
|
+
[_new_id(), source_id, target_id],
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
# --- GC ---
|
|
1288
|
+
|
|
1289
|
+
async def gc_entities(self, retention_days: int) -> int:
|
|
1290
|
+
cutoff = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1291
|
+
# SQLite doesn't have interval arithmetic; compute cutoff in Python
|
|
1292
|
+
from datetime import timedelta
|
|
1293
|
+
|
|
1294
|
+
cutoff_dt = datetime.now(UTC) - timedelta(days=retention_days)
|
|
1295
|
+
cutoff = cutoff_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1296
|
+
|
|
1297
|
+
assert self._write_lock is not None
|
|
1298
|
+
async with self._write_lock:
|
|
1299
|
+
conn = self._conn_or_raise()
|
|
1300
|
+
await conn.execute("BEGIN")
|
|
1301
|
+
try:
|
|
1302
|
+
cursor = await conn.execute(
|
|
1303
|
+
"SELECT id FROM entities WHERE last_accessed < ? AND bookmarked = 0",
|
|
1304
|
+
[cutoff],
|
|
1305
|
+
)
|
|
1306
|
+
to_delete = [row[0] for row in await cursor.fetchall()]
|
|
1307
|
+
if to_delete:
|
|
1308
|
+
placeholders = ",".join("?" * len(to_delete))
|
|
1309
|
+
await conn.execute(
|
|
1310
|
+
f"DELETE FROM entities WHERE id IN ({placeholders})", to_delete
|
|
1311
|
+
)
|
|
1312
|
+
await conn.execute(
|
|
1313
|
+
f"DELETE FROM entities_fts WHERE id IN ({placeholders})", to_delete
|
|
1314
|
+
)
|
|
1315
|
+
await conn.execute("COMMIT")
|
|
1316
|
+
except Exception:
|
|
1317
|
+
await conn.execute("ROLLBACK")
|
|
1318
|
+
raise
|
|
1319
|
+
return len(to_delete)
|
|
1320
|
+
|
|
1321
|
+
# --- Observations ---
|
|
1322
|
+
|
|
1323
|
+
# --- Sync state ---
|
|
1324
|
+
|
|
1325
|
+
async def load_cursor(self, source: str) -> dict[str, Any]:
|
|
1326
|
+
val = await self._fetchval("SELECT cursor FROM sync_state WHERE source = ?", [source])
|
|
1327
|
+
return json.loads(val) if val else {}
|
|
1328
|
+
|
|
1329
|
+
async def save_cursor(self, source: str, cursor: dict[str, Any]) -> None:
|
|
1330
|
+
await self._execute(
|
|
1331
|
+
"""
|
|
1332
|
+
INSERT INTO sync_state (source, cursor, updated_at)
|
|
1333
|
+
VALUES (?, ?, ?)
|
|
1334
|
+
ON CONFLICT (source) DO UPDATE SET
|
|
1335
|
+
cursor = EXCLUDED.cursor,
|
|
1336
|
+
updated_at = EXCLUDED.updated_at
|
|
1337
|
+
""",
|
|
1338
|
+
[source, json.dumps(cursor), _now()],
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
# --- Connector support ---
|
|
1342
|
+
|
|
1343
|
+
async def increment_dwell_time(
|
|
1344
|
+
self, platform: str, platform_entity_id: str, dwell_ms: int
|
|
1345
|
+
) -> None:
|
|
1346
|
+
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1347
|
+
await self._execute(
|
|
1348
|
+
"""
|
|
1349
|
+
UPDATE entities
|
|
1350
|
+
SET cumulative_dwell_ms = cumulative_dwell_ms + ?, observed_at = ?
|
|
1351
|
+
WHERE platform = ? AND platform_entity_id = ?
|
|
1352
|
+
""",
|
|
1353
|
+
[dwell_ms, now, platform, platform_entity_id],
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
async def get_last_synced_at(self, platform: str, platform_entity_id: str) -> datetime | None:
|
|
1357
|
+
val = await self._fetchval(
|
|
1358
|
+
"""
|
|
1359
|
+
SELECT max(synced_at) FROM entities
|
|
1360
|
+
WHERE platform = ? AND platform_entity_id = ?
|
|
1361
|
+
""",
|
|
1362
|
+
[platform, platform_entity_id],
|
|
1363
|
+
)
|
|
1364
|
+
return datetime.fromisoformat(val) if val else None
|
|
1365
|
+
|
|
1366
|
+
async def get_platform_last_synced_at(self, platform: str) -> datetime | None:
|
|
1367
|
+
val = await self._fetchval(
|
|
1368
|
+
"""
|
|
1369
|
+
SELECT max(synced_at) FROM entities
|
|
1370
|
+
WHERE platform = ?
|
|
1371
|
+
""",
|
|
1372
|
+
[platform],
|
|
1373
|
+
)
|
|
1374
|
+
return datetime.fromisoformat(val) if val else None
|
|
1375
|
+
|
|
1376
|
+
async def get_platforms_last_synced_at(
|
|
1377
|
+
self,
|
|
1378
|
+
platforms: list[str],
|
|
1379
|
+
) -> dict[str, datetime | None]:
|
|
1380
|
+
if not platforms:
|
|
1381
|
+
return {}
|
|
1382
|
+
placeholders = ",".join("?" for _ in platforms)
|
|
1383
|
+
rows = await self._fetchall(
|
|
1384
|
+
f"""
|
|
1385
|
+
SELECT platform, max(synced_at) AS last_synced_at
|
|
1386
|
+
FROM entities
|
|
1387
|
+
WHERE platform IN ({placeholders})
|
|
1388
|
+
GROUP BY platform
|
|
1389
|
+
""",
|
|
1390
|
+
platforms,
|
|
1391
|
+
)
|
|
1392
|
+
result: dict[str, datetime | None] = dict.fromkeys(platforms, None)
|
|
1393
|
+
for row in rows:
|
|
1394
|
+
val = row["last_synced_at"]
|
|
1395
|
+
result[row["platform"]] = datetime.fromisoformat(val) if val else None
|
|
1396
|
+
return result
|
|
1397
|
+
|
|
1398
|
+
async def reset_synced_at(self, platform: str, platform_entity_id: str) -> None:
|
|
1399
|
+
await self._execute(
|
|
1400
|
+
"UPDATE entities SET synced_at = NULL WHERE platform = ? AND platform_entity_id = ?",
|
|
1401
|
+
[platform, platform_entity_id],
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
async def touch_last_accessed(self, platform: str, platform_entity_id: str) -> None:
|
|
1405
|
+
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1406
|
+
await self._execute(
|
|
1407
|
+
"UPDATE entities SET last_accessed = ? WHERE platform = ? AND platform_entity_id = ?",
|
|
1408
|
+
[now, platform, platform_entity_id],
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
async def touch_last_accessed_by_ids(self, entity_ids: list[str]) -> None:
|
|
1412
|
+
if not entity_ids:
|
|
1413
|
+
return
|
|
1414
|
+
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1415
|
+
placeholders = ",".join("?" * len(entity_ids))
|
|
1416
|
+
await self._execute(
|
|
1417
|
+
f"UPDATE entities SET last_accessed = ? WHERE id IN ({placeholders})",
|
|
1418
|
+
[now, *entity_ids],
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
async def get_entity_type(self, platform: str, platform_entity_id: str) -> str | None:
|
|
1422
|
+
return await self._fetchval(
|
|
1423
|
+
"SELECT entity_type FROM entities WHERE platform = ? AND platform_entity_id = ?",
|
|
1424
|
+
[platform, platform_entity_id],
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
async def get_entity_platform_ref(self, entity_id: str) -> tuple[str, str] | None:
|
|
1428
|
+
row = await self._fetchone(
|
|
1429
|
+
"SELECT platform, platform_entity_id FROM entities WHERE id = ?",
|
|
1430
|
+
[entity_id],
|
|
1431
|
+
)
|
|
1432
|
+
if row is None:
|
|
1433
|
+
return None
|
|
1434
|
+
return (row["platform"], row["platform_entity_id"])
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
# --- Row serialization helpers ---
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _row_to_entity(row: Any) -> EntityResult:
|
|
1441
|
+
keys = row.keys() if hasattr(row, "keys") else []
|
|
1442
|
+
return {
|
|
1443
|
+
"id": row["id"],
|
|
1444
|
+
"entity_type": row["entity_type"],
|
|
1445
|
+
"platform": row["platform"],
|
|
1446
|
+
"platform_entity_id": row["platform_entity_id"],
|
|
1447
|
+
"title": row["title"],
|
|
1448
|
+
"content": row["content"],
|
|
1449
|
+
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
|
1450
|
+
"created_at": row["created_at"],
|
|
1451
|
+
"updated_at": row["updated_at"],
|
|
1452
|
+
"synced_at": row["synced_at"] if "synced_at" in keys else None,
|
|
1453
|
+
"observed_at": row["observed_at"] if "observed_at" in keys else None,
|
|
1454
|
+
"last_accessed": row["last_accessed"] if "last_accessed" in keys else None,
|
|
1455
|
+
"cumulative_dwell_ms": row["cumulative_dwell_ms"] if "cumulative_dwell_ms" in keys else 0,
|
|
1456
|
+
"bookmarked": bool(row["bookmarked"]) if "bookmarked" in keys else False,
|
|
1457
|
+
"score": row["score"] if "score" in keys else None,
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _row_to_edge(row: Any) -> EdgeResult:
|
|
1462
|
+
return {
|
|
1463
|
+
"id": row["id"],
|
|
1464
|
+
"edge_type": row["edge_type"],
|
|
1465
|
+
"platform": row["platform"],
|
|
1466
|
+
"properties": json.loads(row["properties"]) if row["properties"] else {},
|
|
1467
|
+
"source_entity_id": row["source_entity_id"],
|
|
1468
|
+
"target_entity_id": row["target_entity_id"],
|
|
1469
|
+
"source_ref": row["source_ref"],
|
|
1470
|
+
"target_ref": row["target_ref"],
|
|
1471
|
+
}
|