agentic-composite-memory 0.1.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.
- agentic_composite_memory/__init__.py +2 -0
- agentic_composite_memory/py.typed +0 -0
- agentic_composite_memory-0.1.0.dist-info/METADATA +167 -0
- agentic_composite_memory-0.1.0.dist-info/RECORD +31 -0
- agentic_composite_memory-0.1.0.dist-info/WHEEL +4 -0
- agentic_composite_memory-0.1.0.dist-info/entry_points.txt +2 -0
- agentic_composite_memory-0.1.0.dist-info/licenses/LICENSE +17 -0
- agentic_memory_sdk/__init__.py +163 -0
- agentic_memory_sdk/adapters/__init__.py +33 -0
- agentic_memory_sdk/adapters/in_memory.py +439 -0
- agentic_memory_sdk/adapters/neo4j_cypher.py +65 -0
- agentic_memory_sdk/adapters/neptune_cypher.py +309 -0
- agentic_memory_sdk/adapters/opensearch_seed.py +322 -0
- agentic_memory_sdk/adapters/postgres_pgvector.py +484 -0
- agentic_memory_sdk/adapters/postgres_unified.py +511 -0
- agentic_memory_sdk/adapters/redis_playbook.py +157 -0
- agentic_memory_sdk/adapters/redis_unified.py +490 -0
- agentic_memory_sdk/adk_integration.py +250 -0
- agentic_memory_sdk/auth.py +380 -0
- agentic_memory_sdk/cli.py +137 -0
- agentic_memory_sdk/governance.py +713 -0
- agentic_memory_sdk/llm_extractors.py +616 -0
- agentic_memory_sdk/models.py +443 -0
- agentic_memory_sdk/ontology_loader.py +189 -0
- agentic_memory_sdk/ports.py +159 -0
- agentic_memory_sdk/py.typed +0 -0
- agentic_memory_sdk/server/__init__.py +4 -0
- agentic_memory_sdk/server/api.py +328 -0
- agentic_memory_sdk/server/main.py +384 -0
- agentic_memory_sdk/server/mcp_server.py +198 -0
- agentic_memory_sdk/service.py +1214 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from ..auth import CloudAuthProvider, EnterpriseTlsConfig
|
|
8
|
+
from ..governance import EntityResolutionGuard
|
|
9
|
+
from ..models import (
|
|
10
|
+
CompiledTBox,
|
|
11
|
+
EpisodicRecord,
|
|
12
|
+
GraphVertexUpsert,
|
|
13
|
+
OutboxOperation,
|
|
14
|
+
PlatformTenantContext,
|
|
15
|
+
)
|
|
16
|
+
from ..ports import EpisodicAndSeedStoreAdapter, RegistryStoreAdapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PostgresPgVectorAdapter(RegistryStoreAdapter, EpisodicAndSeedStoreAdapter):
|
|
20
|
+
"""
|
|
21
|
+
Production PostgreSQL + pgvector adapter enforcing Row-Level Security (RLS)
|
|
22
|
+
via transaction-local session variables, partitioned HNSW indexes per tenant,
|
|
23
|
+
Reciprocal Rank Fusion (RRF) hybrid search, and an ACID Transactional Outbox.
|
|
24
|
+
Supports Enterprise Cloud Auth (AWS RDS IAM, GCP Cloud SQL IAM, Azure Entra ID,
|
|
25
|
+
Vault dynamic secrets) and mTLS (`EnterpriseTlsConfig`).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
dsn: str,
|
|
31
|
+
min_pool_size: int = 5,
|
|
32
|
+
max_pool_size: int = 30,
|
|
33
|
+
auth_provider: CloudAuthProvider | None = None,
|
|
34
|
+
tls_config: EnterpriseTlsConfig | None = None,
|
|
35
|
+
):
|
|
36
|
+
self.dsn = dsn
|
|
37
|
+
self.min_pool_size = min_pool_size
|
|
38
|
+
self.max_pool_size = max_pool_size
|
|
39
|
+
self.auth_provider = auth_provider
|
|
40
|
+
self.tls_config = tls_config
|
|
41
|
+
self._pool = None
|
|
42
|
+
|
|
43
|
+
async def _get_pool(self):
|
|
44
|
+
if self._pool is None:
|
|
45
|
+
import asyncpg
|
|
46
|
+
|
|
47
|
+
pool_kwargs: dict = {
|
|
48
|
+
"min_size": self.min_pool_size,
|
|
49
|
+
"max_size": self.max_pool_size,
|
|
50
|
+
}
|
|
51
|
+
if self.auth_provider is not None:
|
|
52
|
+
pool_kwargs["user"] = await self.auth_provider.get_username()
|
|
53
|
+
# asyncpg natively invokes an async callable `password` on every new connection,
|
|
54
|
+
# enabling zero-downtime AWS IAM / GCP IAM / Azure Entra token rotation!
|
|
55
|
+
pool_kwargs["password"] = self.auth_provider.get_password
|
|
56
|
+
if self.tls_config is not None:
|
|
57
|
+
pool_kwargs["ssl"] = self.tls_config.build_ssl_context()
|
|
58
|
+
|
|
59
|
+
self._pool = await asyncpg.create_pool(self.dsn, **pool_kwargs)
|
|
60
|
+
return self._pool
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None:
|
|
63
|
+
if self._pool is not None:
|
|
64
|
+
await self._pool.close()
|
|
65
|
+
self._pool = None
|
|
66
|
+
|
|
67
|
+
@asynccontextmanager
|
|
68
|
+
async def _rls_tx(self, ctx: PlatformTenantContext):
|
|
69
|
+
pool = await self._get_pool()
|
|
70
|
+
async with pool.acquire() as conn:
|
|
71
|
+
async with conn.transaction():
|
|
72
|
+
await conn.execute(
|
|
73
|
+
"SELECT set_config('app.tenant_id', $1, true)", ctx.tenant_id
|
|
74
|
+
)
|
|
75
|
+
await conn.execute(
|
|
76
|
+
"SELECT set_config('app.app_name', $1, true)", ctx.app_name
|
|
77
|
+
)
|
|
78
|
+
await conn.execute(
|
|
79
|
+
"SELECT set_config('app.user_id', $1, true)", ctx.user_id
|
|
80
|
+
)
|
|
81
|
+
await conn.execute(
|
|
82
|
+
"SELECT set_config('app.read_namespaces', $1, true)",
|
|
83
|
+
",".join(ctx.read_namespaces),
|
|
84
|
+
)
|
|
85
|
+
await conn.execute(
|
|
86
|
+
"SELECT set_config('app.write_namespace', $1, true)",
|
|
87
|
+
ctx.write_namespace,
|
|
88
|
+
)
|
|
89
|
+
yield conn
|
|
90
|
+
|
|
91
|
+
async def register_app(
|
|
92
|
+
self,
|
|
93
|
+
tenant_id: str,
|
|
94
|
+
app_name: str,
|
|
95
|
+
read_namespaces: list[str],
|
|
96
|
+
write_namespace: str,
|
|
97
|
+
tbox: CompiledTBox,
|
|
98
|
+
) -> None:
|
|
99
|
+
pool = await self._get_pool()
|
|
100
|
+
async with pool.acquire() as conn:
|
|
101
|
+
# Ensure tenant partitions exist before registering app
|
|
102
|
+
await conn.execute("SELECT provision_tenant_memory_partitions($1)", tenant_id)
|
|
103
|
+
await conn.execute(
|
|
104
|
+
"""
|
|
105
|
+
INSERT INTO platform_app_registry
|
|
106
|
+
(tenant_id, app_name, domain_namespace, read_namespaces, write_namespace, ontology_schema, updated_at)
|
|
107
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
|
|
108
|
+
ON CONFLICT (tenant_id, app_name) DO UPDATE
|
|
109
|
+
SET domain_namespace = EXCLUDED.domain_namespace,
|
|
110
|
+
read_namespaces = EXCLUDED.read_namespaces,
|
|
111
|
+
write_namespace = EXCLUDED.write_namespace,
|
|
112
|
+
ontology_schema = EXCLUDED.ontology_schema,
|
|
113
|
+
updated_at = NOW()
|
|
114
|
+
""",
|
|
115
|
+
tenant_id,
|
|
116
|
+
app_name,
|
|
117
|
+
write_namespace,
|
|
118
|
+
read_namespaces,
|
|
119
|
+
write_namespace,
|
|
120
|
+
tbox.model_dump_json(),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def resolve_context(
|
|
124
|
+
self, tenant_id: str, app_name: str, user_id: str
|
|
125
|
+
) -> PlatformTenantContext:
|
|
126
|
+
pool = await self._get_pool()
|
|
127
|
+
async with pool.acquire() as conn:
|
|
128
|
+
row = await conn.fetchrow(
|
|
129
|
+
"""
|
|
130
|
+
SELECT read_namespaces, write_namespace, ontology_schema, max_rpm, max_active_vertices
|
|
131
|
+
FROM platform_app_registry
|
|
132
|
+
WHERE tenant_id = $1 AND app_name = $2
|
|
133
|
+
""",
|
|
134
|
+
tenant_id,
|
|
135
|
+
app_name,
|
|
136
|
+
)
|
|
137
|
+
app_private_ns = f"app:{tenant_id}:{app_name}"
|
|
138
|
+
user_private_ns = f"user:{tenant_id}:{user_id}"
|
|
139
|
+
if not row:
|
|
140
|
+
# Zero-config fallback: auto-provision Tier 0 isolated context + cross-app user namespace
|
|
141
|
+
return PlatformTenantContext(
|
|
142
|
+
tenant_id=tenant_id,
|
|
143
|
+
app_name=app_name,
|
|
144
|
+
user_id=user_id,
|
|
145
|
+
app_private_namespace=app_private_ns,
|
|
146
|
+
write_namespace=app_private_ns,
|
|
147
|
+
read_namespaces=[
|
|
148
|
+
app_private_ns,
|
|
149
|
+
user_private_ns,
|
|
150
|
+
f"global:{tenant_id}:core",
|
|
151
|
+
],
|
|
152
|
+
tbox=CompiledTBox(),
|
|
153
|
+
)
|
|
154
|
+
merged_read_ns = list(
|
|
155
|
+
dict.fromkeys([*list(row["read_namespaces"]), user_private_ns])
|
|
156
|
+
)
|
|
157
|
+
return PlatformTenantContext(
|
|
158
|
+
tenant_id=tenant_id,
|
|
159
|
+
app_name=app_name,
|
|
160
|
+
user_id=user_id,
|
|
161
|
+
app_private_namespace=app_private_ns,
|
|
162
|
+
write_namespace=row["write_namespace"],
|
|
163
|
+
read_namespaces=merged_read_ns,
|
|
164
|
+
tbox=CompiledTBox.model_validate_json(row["ontology_schema"]),
|
|
165
|
+
max_rpm=row["max_rpm"],
|
|
166
|
+
max_active_vertices=row["max_active_vertices"],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def init_schema(
|
|
170
|
+
self, embedding_dim: int = 1536, default_tenant: str = "t1"
|
|
171
|
+
) -> None:
|
|
172
|
+
"""
|
|
173
|
+
Programmatically initializes the PostgreSQL + pgvector RLS schema, partitions,
|
|
174
|
+
HNSW vector indexes, and non-superuser role with configurable embedding dimensions.
|
|
175
|
+
"""
|
|
176
|
+
from pathlib import Path
|
|
177
|
+
|
|
178
|
+
pool = await self._get_pool()
|
|
179
|
+
sql_path = (
|
|
180
|
+
Path(__file__).resolve().parents[3]
|
|
181
|
+
/ "docs"
|
|
182
|
+
/ "sql"
|
|
183
|
+
/ "001_postgres_rls_and_partitions.sql"
|
|
184
|
+
)
|
|
185
|
+
if sql_path.exists():
|
|
186
|
+
ddl = sql_path.read_text(encoding="utf-8")
|
|
187
|
+
ddl = ddl.replace("vector(1536)", f"vector({embedding_dim})")
|
|
188
|
+
async with pool.acquire() as conn:
|
|
189
|
+
await conn.execute(ddl)
|
|
190
|
+
if default_tenant != "t1":
|
|
191
|
+
await conn.execute(
|
|
192
|
+
"SELECT provision_tenant_memory_partitions($1)", default_tenant
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def resolve_existing_vertex_id(
|
|
196
|
+
self,
|
|
197
|
+
ctx: PlatformTenantContext,
|
|
198
|
+
entity_class: str,
|
|
199
|
+
canonical_name: str,
|
|
200
|
+
embedding: list[float],
|
|
201
|
+
similarity_threshold: float = 0.91,
|
|
202
|
+
) -> str | None:
|
|
203
|
+
async with self._rls_tx(ctx) as conn:
|
|
204
|
+
rows = await conn.fetch(
|
|
205
|
+
"""
|
|
206
|
+
SELECT neptune_vertex_id,
|
|
207
|
+
canonical_name,
|
|
208
|
+
1 - (embedding <=> $1::vector) AS sim
|
|
209
|
+
FROM graph_seed_index
|
|
210
|
+
WHERE tenant_id = $5
|
|
211
|
+
AND namespace = ANY($6::text[])
|
|
212
|
+
AND entity_class = $2
|
|
213
|
+
AND (
|
|
214
|
+
LOWER(canonical_name) = LOWER($3)
|
|
215
|
+
OR (1 - (embedding <=> $1::vector)) >= $4
|
|
216
|
+
)
|
|
217
|
+
ORDER BY sim DESC
|
|
218
|
+
LIMIT 5
|
|
219
|
+
""",
|
|
220
|
+
str(embedding),
|
|
221
|
+
entity_class,
|
|
222
|
+
canonical_name,
|
|
223
|
+
similarity_threshold,
|
|
224
|
+
ctx.tenant_id,
|
|
225
|
+
ctx.read_namespaces,
|
|
226
|
+
)
|
|
227
|
+
for row in rows:
|
|
228
|
+
if EntityResolutionGuard.can_merge(
|
|
229
|
+
row["canonical_name"],
|
|
230
|
+
canonical_name,
|
|
231
|
+
float(row["sim"]),
|
|
232
|
+
similarity_threshold,
|
|
233
|
+
):
|
|
234
|
+
return row["neptune_vertex_id"]
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
async def commit_episodes_and_outbox(
|
|
238
|
+
self,
|
|
239
|
+
ctx: PlatformTenantContext,
|
|
240
|
+
episodes: Sequence[EpisodicRecord],
|
|
241
|
+
vertex_upserts: Sequence[GraphVertexUpsert],
|
|
242
|
+
outbox_op: OutboxOperation | None,
|
|
243
|
+
) -> None:
|
|
244
|
+
async with self._rls_tx(ctx) as conn:
|
|
245
|
+
for ep in episodes:
|
|
246
|
+
await conn.execute(
|
|
247
|
+
"""
|
|
248
|
+
INSERT INTO episodic_memories
|
|
249
|
+
(episode_id, tenant_id, app_name, user_id, session_id, namespace, author, content_text, embedding, custom_metadata)
|
|
250
|
+
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::vector, $10::jsonb)
|
|
251
|
+
ON CONFLICT (tenant_id, episode_id) DO NOTHING
|
|
252
|
+
""",
|
|
253
|
+
ep.episode_id,
|
|
254
|
+
ep.tenant_id,
|
|
255
|
+
ep.app_name,
|
|
256
|
+
ep.user_id,
|
|
257
|
+
ep.session_id,
|
|
258
|
+
ep.namespace,
|
|
259
|
+
ep.author,
|
|
260
|
+
ep.content_text,
|
|
261
|
+
str(ep.embedding),
|
|
262
|
+
json.dumps(ep.metadata),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
for v in vertex_upserts:
|
|
266
|
+
await conn.execute(
|
|
267
|
+
"SELECT set_config('app.write_namespace', $1, true)", v.namespace
|
|
268
|
+
)
|
|
269
|
+
await conn.execute(
|
|
270
|
+
"""
|
|
271
|
+
INSERT INTO graph_seed_index
|
|
272
|
+
(neptune_vertex_id, tenant_id, namespace, entity_class, upper_class, canonical_name, summary_text, embedding)
|
|
273
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::vector)
|
|
274
|
+
ON CONFLICT (tenant_id, neptune_vertex_id) DO UPDATE
|
|
275
|
+
SET summary_text = EXCLUDED.summary_text,
|
|
276
|
+
embedding = EXCLUDED.embedding,
|
|
277
|
+
updated_at = NOW()
|
|
278
|
+
""",
|
|
279
|
+
v.vertex_id,
|
|
280
|
+
v.tenant_id,
|
|
281
|
+
v.namespace,
|
|
282
|
+
v.entity_class,
|
|
283
|
+
v.upper_class,
|
|
284
|
+
v.canonical_name,
|
|
285
|
+
v.summary,
|
|
286
|
+
str(v.embedding),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if outbox_op is not None:
|
|
290
|
+
await conn.execute(
|
|
291
|
+
"""
|
|
292
|
+
INSERT INTO graph_transactional_outbox
|
|
293
|
+
(outbox_id, tenant_id, operation_type, payload, status, attempts)
|
|
294
|
+
VALUES ($1::uuid, $2, $3, $4::jsonb, 'PENDING', 0)
|
|
295
|
+
""",
|
|
296
|
+
outbox_op.outbox_id,
|
|
297
|
+
outbox_op.tenant_id,
|
|
298
|
+
outbox_op.operation_type,
|
|
299
|
+
json.dumps(outbox_op.payload),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
async def hybrid_seed_search(
|
|
303
|
+
self,
|
|
304
|
+
ctx: PlatformTenantContext,
|
|
305
|
+
query_text: str,
|
|
306
|
+
query_embedding: list[float],
|
|
307
|
+
limit: int = 8,
|
|
308
|
+
) -> list[GraphVertexUpsert]:
|
|
309
|
+
async with self._rls_tx(ctx) as conn:
|
|
310
|
+
rows = await conn.fetch(
|
|
311
|
+
"""
|
|
312
|
+
WITH vector_search AS (
|
|
313
|
+
SELECT neptune_vertex_id, namespace, entity_class, upper_class, canonical_name, summary_text,
|
|
314
|
+
ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS v_rank
|
|
315
|
+
FROM graph_seed_index
|
|
316
|
+
WHERE tenant_id = $4 AND namespace = ANY($5::text[])
|
|
317
|
+
LIMIT 25
|
|
318
|
+
),
|
|
319
|
+
fts_search AS (
|
|
320
|
+
SELECT neptune_vertex_id, namespace, entity_class, upper_class, canonical_name, summary_text,
|
|
321
|
+
ROW_NUMBER() OVER (
|
|
322
|
+
ORDER BY ts_rank_cd(summary_tsv, websearch_to_tsquery('english', $2)) DESC
|
|
323
|
+
) AS f_rank
|
|
324
|
+
FROM graph_seed_index
|
|
325
|
+
WHERE tenant_id = $4
|
|
326
|
+
AND namespace = ANY($5::text[])
|
|
327
|
+
AND summary_tsv @@ websearch_to_tsquery('english', $2)
|
|
328
|
+
LIMIT 25
|
|
329
|
+
)
|
|
330
|
+
SELECT COALESCE(v.neptune_vertex_id, f.neptune_vertex_id) AS vertex_id,
|
|
331
|
+
COALESCE(v.namespace, f.namespace) AS namespace,
|
|
332
|
+
COALESCE(v.entity_class, f.entity_class) AS entity_class,
|
|
333
|
+
COALESCE(v.upper_class, f.upper_class) AS upper_class,
|
|
334
|
+
COALESCE(v.canonical_name, f.canonical_name) AS canonical_name,
|
|
335
|
+
COALESCE(v.summary_text, f.summary_text) AS summary_text,
|
|
336
|
+
COALESCE(1.0 / (60 + v.v_rank), 0.0) + COALESCE(1.0 / (60 + f.f_rank), 0.0) AS rrf_score
|
|
337
|
+
FROM vector_search v
|
|
338
|
+
FULL OUTER JOIN fts_search f ON v.neptune_vertex_id = f.neptune_vertex_id
|
|
339
|
+
ORDER BY rrf_score DESC
|
|
340
|
+
LIMIT $3
|
|
341
|
+
""",
|
|
342
|
+
str(query_embedding),
|
|
343
|
+
query_text,
|
|
344
|
+
limit,
|
|
345
|
+
ctx.tenant_id,
|
|
346
|
+
ctx.read_namespaces,
|
|
347
|
+
)
|
|
348
|
+
seeds = [
|
|
349
|
+
GraphVertexUpsert(
|
|
350
|
+
vertex_id=r["vertex_id"],
|
|
351
|
+
tenant_id=ctx.tenant_id,
|
|
352
|
+
namespace=r["namespace"],
|
|
353
|
+
entity_class=r["entity_class"],
|
|
354
|
+
upper_class=r["upper_class"],
|
|
355
|
+
canonical_name=r["canonical_name"],
|
|
356
|
+
summary=r["summary_text"],
|
|
357
|
+
embedding=[],
|
|
358
|
+
)
|
|
359
|
+
for r in rows
|
|
360
|
+
]
|
|
361
|
+
seen_ids = {s.vertex_id for s in seeds}
|
|
362
|
+
user_ns = f"user:{ctx.tenant_id}:{ctx.user_id}"
|
|
363
|
+
if user_ns in ctx.read_namespaces:
|
|
364
|
+
user_rows = await conn.fetch(
|
|
365
|
+
"""
|
|
366
|
+
SELECT neptune_vertex_id AS vertex_id, namespace, entity_class,
|
|
367
|
+
upper_class, canonical_name, summary_text
|
|
368
|
+
FROM graph_seed_index
|
|
369
|
+
WHERE tenant_id = $1 AND namespace = $2
|
|
370
|
+
LIMIT 5
|
|
371
|
+
""",
|
|
372
|
+
ctx.tenant_id,
|
|
373
|
+
user_ns,
|
|
374
|
+
)
|
|
375
|
+
for ur in user_rows:
|
|
376
|
+
if ur["vertex_id"] not in seen_ids:
|
|
377
|
+
seeds.append(
|
|
378
|
+
GraphVertexUpsert(
|
|
379
|
+
vertex_id=ur["vertex_id"],
|
|
380
|
+
tenant_id=ctx.tenant_id,
|
|
381
|
+
namespace=ur["namespace"],
|
|
382
|
+
entity_class=ur["entity_class"],
|
|
383
|
+
upper_class=ur["upper_class"],
|
|
384
|
+
canonical_name=ur["canonical_name"],
|
|
385
|
+
summary=ur["summary_text"],
|
|
386
|
+
embedding=[],
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
seen_ids.add(ur["vertex_id"])
|
|
390
|
+
return seeds
|
|
391
|
+
|
|
392
|
+
async def search_episodes(
|
|
393
|
+
self,
|
|
394
|
+
ctx: PlatformTenantContext,
|
|
395
|
+
query_embedding: list[float],
|
|
396
|
+
limit: int = 4,
|
|
397
|
+
) -> list[EpisodicRecord]:
|
|
398
|
+
async with self._rls_tx(ctx) as conn:
|
|
399
|
+
rows = await conn.fetch(
|
|
400
|
+
"""
|
|
401
|
+
SELECT episode_id::text, session_id, namespace, author, content_text, created_at
|
|
402
|
+
FROM episodic_memories
|
|
403
|
+
WHERE tenant_id = $3 AND app_name = $4 AND user_id = $5
|
|
404
|
+
ORDER BY embedding <=> $1::vector
|
|
405
|
+
LIMIT $2
|
|
406
|
+
""",
|
|
407
|
+
str(query_embedding),
|
|
408
|
+
limit,
|
|
409
|
+
ctx.tenant_id,
|
|
410
|
+
ctx.app_name,
|
|
411
|
+
ctx.user_id,
|
|
412
|
+
)
|
|
413
|
+
return [
|
|
414
|
+
EpisodicRecord(
|
|
415
|
+
episode_id=r["episode_id"],
|
|
416
|
+
tenant_id=ctx.tenant_id,
|
|
417
|
+
app_name=ctx.app_name,
|
|
418
|
+
user_id=ctx.user_id,
|
|
419
|
+
session_id=r["session_id"],
|
|
420
|
+
namespace=r["namespace"],
|
|
421
|
+
author=r["author"],
|
|
422
|
+
content_text=r["content_text"],
|
|
423
|
+
embedding=[],
|
|
424
|
+
created_at=r["created_at"],
|
|
425
|
+
)
|
|
426
|
+
for r in rows
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
async def fetch_pending_outbox(self, batch_size: int = 25) -> list[OutboxOperation]:
|
|
430
|
+
pool = await self._get_pool()
|
|
431
|
+
async with pool.acquire() as conn:
|
|
432
|
+
async with conn.transaction():
|
|
433
|
+
rows = await conn.fetch(
|
|
434
|
+
"""
|
|
435
|
+
SELECT outbox_id::text, tenant_id, operation_type, payload, attempts
|
|
436
|
+
FROM graph_transactional_outbox
|
|
437
|
+
WHERE status IN ('PENDING', 'RETRY') AND attempts < 5
|
|
438
|
+
ORDER BY created_at ASC
|
|
439
|
+
LIMIT $1
|
|
440
|
+
FOR UPDATE SKIP LOCKED
|
|
441
|
+
""",
|
|
442
|
+
batch_size,
|
|
443
|
+
)
|
|
444
|
+
return [
|
|
445
|
+
OutboxOperation(
|
|
446
|
+
outbox_id=r["outbox_id"],
|
|
447
|
+
tenant_id=r["tenant_id"],
|
|
448
|
+
operation_type=r["operation_type"],
|
|
449
|
+
payload=json.loads(r["payload"]),
|
|
450
|
+
attempts=r["attempts"],
|
|
451
|
+
)
|
|
452
|
+
for r in rows
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
async def mark_outbox_processed(self, outbox_id: str) -> None:
|
|
456
|
+
pool = await self._get_pool()
|
|
457
|
+
async with pool.acquire() as conn:
|
|
458
|
+
await conn.execute(
|
|
459
|
+
"UPDATE graph_transactional_outbox SET status = 'PROCESSED', processed_at = NOW() WHERE outbox_id = $1::uuid",
|
|
460
|
+
outbox_id,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
async def mark_outbox_failed(self, outbox_id: str, error_msg: str) -> None:
|
|
464
|
+
pool = await self._get_pool()
|
|
465
|
+
async with pool.acquire() as conn:
|
|
466
|
+
await conn.execute(
|
|
467
|
+
"""
|
|
468
|
+
UPDATE graph_transactional_outbox
|
|
469
|
+
SET status = CASE WHEN attempts + 1 >= 5 THEN 'DEAD_LETTER' ELSE 'RETRY' END,
|
|
470
|
+
attempts = attempts + 1,
|
|
471
|
+
last_error = $2
|
|
472
|
+
WHERE outbox_id = $1::uuid
|
|
473
|
+
""",
|
|
474
|
+
outbox_id,
|
|
475
|
+
error_msg[:500],
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
async def purge_user_data(self, ctx: PlatformTenantContext) -> list[str]:
|
|
479
|
+
async with self._rls_tx(ctx) as conn:
|
|
480
|
+
rows = await conn.fetch(
|
|
481
|
+
"DELETE FROM episodic_memories WHERE user_id = $1 RETURNING episode_id::text",
|
|
482
|
+
ctx.user_id,
|
|
483
|
+
)
|
|
484
|
+
return [r["episode_id"] for r in rows]
|