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.
@@ -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]