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,309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Sequence
|
|
7
|
+
|
|
8
|
+
from ..auth import AwsIamDatabaseAuthProvider
|
|
9
|
+
from ..models import (
|
|
10
|
+
GraphEdgeMutation,
|
|
11
|
+
GraphFactReadResult,
|
|
12
|
+
GraphVertexUpsert,
|
|
13
|
+
PlatformTenantContext,
|
|
14
|
+
ReasoningTraceStep,
|
|
15
|
+
)
|
|
16
|
+
from ..ports import GraphStoreAdapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NeptuneOpenCypherAdapter(GraphStoreAdapter):
|
|
20
|
+
"""
|
|
21
|
+
Production Amazon Neptune adapter using parameterized openCypher
|
|
22
|
+
with mandatory tenant/namespace guards, idempotent batch MERGE,
|
|
23
|
+
bi-temporal functional edge invalidation, and reasoning trace recording.
|
|
24
|
+
Supports AWS IAM SigV4 and cross-account STS AssumeRole via `aws_auth_provider`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
endpoint_by_tenant: dict[str, str],
|
|
30
|
+
region: str = "us-east-1",
|
|
31
|
+
aws_auth_provider: AwsIamDatabaseAuthProvider | None = None,
|
|
32
|
+
):
|
|
33
|
+
self.endpoints = endpoint_by_tenant
|
|
34
|
+
self.region = region
|
|
35
|
+
self.aws_auth_provider = aws_auth_provider
|
|
36
|
+
self._clients: dict[str, Any] = {}
|
|
37
|
+
|
|
38
|
+
def _client(self, tenant_id: str):
|
|
39
|
+
import boto3
|
|
40
|
+
|
|
41
|
+
ep = self.endpoints.get(tenant_id) or self.endpoints["default"]
|
|
42
|
+
if ep not in self._clients:
|
|
43
|
+
endpoint_url = ep if ep.startswith("http") else f"https://{ep}:8182"
|
|
44
|
+
session = (
|
|
45
|
+
self.aws_auth_provider.get_boto3_session()
|
|
46
|
+
if self.aws_auth_provider is not None
|
|
47
|
+
else boto3.Session(region_name=self.region)
|
|
48
|
+
)
|
|
49
|
+
self._clients[ep] = session.client(
|
|
50
|
+
"neptunedata",
|
|
51
|
+
region_name=self.region,
|
|
52
|
+
endpoint_url=endpoint_url,
|
|
53
|
+
)
|
|
54
|
+
return self._clients[ep]
|
|
55
|
+
|
|
56
|
+
async def _run_cypher(
|
|
57
|
+
self, tenant_id: str, query: str, params: dict[str, Any]
|
|
58
|
+
) -> list[dict[str, Any]]:
|
|
59
|
+
client = self._client(tenant_id)
|
|
60
|
+
resp = await asyncio.to_thread(
|
|
61
|
+
client.execute_open_cypher_query,
|
|
62
|
+
openCypherQuery=query,
|
|
63
|
+
parameters=json.dumps(params),
|
|
64
|
+
)
|
|
65
|
+
return resp.get("results", [])
|
|
66
|
+
|
|
67
|
+
async def upsert_vertices_and_bitemporal_edges(
|
|
68
|
+
self,
|
|
69
|
+
tenant_id: str,
|
|
70
|
+
vertices: Sequence[GraphVertexUpsert],
|
|
71
|
+
edges: Sequence[GraphEdgeMutation],
|
|
72
|
+
) -> None:
|
|
73
|
+
if vertices:
|
|
74
|
+
vertex_dicts = [v.model_dump(exclude={"embedding"}) for v in vertices]
|
|
75
|
+
await self._run_cypher(
|
|
76
|
+
tenant_id,
|
|
77
|
+
"""
|
|
78
|
+
UNWIND $vertices AS v
|
|
79
|
+
MERGE (n:Entity {`~id`: v.vertex_id, tenant_id: v.tenant_id})
|
|
80
|
+
ON CREATE SET n.canonical_name = v.canonical_name,
|
|
81
|
+
n.entity_class = v.entity_class,
|
|
82
|
+
n.upper_class = v.upper_class,
|
|
83
|
+
n.summary = v.summary,
|
|
84
|
+
n.namespace = v.namespace,
|
|
85
|
+
n.updated_at = v.updated_at
|
|
86
|
+
ON MATCH SET n.summary = v.summary,
|
|
87
|
+
n.updated_at = v.updated_at
|
|
88
|
+
""",
|
|
89
|
+
{"vertices": vertex_dicts},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
for e in edges:
|
|
93
|
+
if e.is_functional:
|
|
94
|
+
# Close t_invalid on any prior active functional edge with a different target
|
|
95
|
+
await self._run_cypher(
|
|
96
|
+
tenant_id,
|
|
97
|
+
"""
|
|
98
|
+
MATCH (s:Entity {`~id`: $src_id, tenant_id: $tenant_id})
|
|
99
|
+
-[r:RELATES {predicate: $pred, namespace: $ns, t_invalid: 'ACTIVE'}]->
|
|
100
|
+
(old_t:Entity)
|
|
101
|
+
WHERE old_t.`~id` <> $tgt_id
|
|
102
|
+
SET r.t_invalid = $t_valid
|
|
103
|
+
""",
|
|
104
|
+
{
|
|
105
|
+
"src_id": e.source_vertex_id,
|
|
106
|
+
"tenant_id": tenant_id,
|
|
107
|
+
"pred": e.predicate,
|
|
108
|
+
"ns": e.namespace,
|
|
109
|
+
"tgt_id": e.target_vertex_id,
|
|
110
|
+
"t_valid": e.t_valid,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
await self._run_cypher(
|
|
115
|
+
tenant_id,
|
|
116
|
+
"""
|
|
117
|
+
MATCH (s:Entity {`~id`: $src_id, tenant_id: $tenant_id})
|
|
118
|
+
MATCH (t:Entity {`~id`: $tgt_id, tenant_id: $tenant_id})
|
|
119
|
+
MERGE (s)-[r:RELATES {
|
|
120
|
+
predicate: $pred,
|
|
121
|
+
namespace: $ns,
|
|
122
|
+
tenant_id: $tenant_id,
|
|
123
|
+
t_invalid: 'ACTIVE'
|
|
124
|
+
}]->(t)
|
|
125
|
+
ON CREATE SET r.edge_id = $edge_id,
|
|
126
|
+
r.t_valid = $t_valid,
|
|
127
|
+
r.t_created = $t_created,
|
|
128
|
+
r.fact_summary = $summary,
|
|
129
|
+
r.confidence = $conf,
|
|
130
|
+
r.reinforcement_count = 1,
|
|
131
|
+
r.source_episode_id = $ep_id,
|
|
132
|
+
r.source_app = $app,
|
|
133
|
+
r.user_id = $uid
|
|
134
|
+
ON MATCH SET r.fact_summary = $summary,
|
|
135
|
+
r.reinforcement_count = coalesce(r.reinforcement_count, 1) + 1,
|
|
136
|
+
r.confidence = CASE
|
|
137
|
+
WHEN coalesce(r.confidence, 0.9) + 0.03 > 1.0 THEN 1.0
|
|
138
|
+
ELSE coalesce(r.confidence, 0.9) + 0.03
|
|
139
|
+
END,
|
|
140
|
+
r.updated_at = $t_valid
|
|
141
|
+
""",
|
|
142
|
+
{
|
|
143
|
+
"src_id": e.source_vertex_id,
|
|
144
|
+
"tgt_id": e.target_vertex_id,
|
|
145
|
+
"tenant_id": tenant_id,
|
|
146
|
+
"pred": e.predicate,
|
|
147
|
+
"ns": e.namespace,
|
|
148
|
+
"edge_id": e.edge_id,
|
|
149
|
+
"t_valid": e.t_valid,
|
|
150
|
+
"t_created": e.t_created,
|
|
151
|
+
"summary": e.fact_summary,
|
|
152
|
+
"conf": e.confidence,
|
|
153
|
+
"ep_id": e.source_episode_id,
|
|
154
|
+
"app": e.source_app,
|
|
155
|
+
"uid": e.user_id,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def expand_active_subgraph(
|
|
160
|
+
self,
|
|
161
|
+
ctx: PlatformTenantContext,
|
|
162
|
+
seed_vertex_ids: Sequence[str],
|
|
163
|
+
as_of_iso: str | None = None,
|
|
164
|
+
max_hops: int = 2,
|
|
165
|
+
limit: int = 25,
|
|
166
|
+
) -> list[GraphFactReadResult]:
|
|
167
|
+
if not seed_vertex_ids:
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
if as_of_iso is None:
|
|
171
|
+
temporal_filter = "r.t_invalid = 'ACTIVE'"
|
|
172
|
+
else:
|
|
173
|
+
temporal_filter = (
|
|
174
|
+
"r.t_valid <= $as_of AND (r.t_invalid = 'ACTIVE' OR r.t_invalid > $as_of)"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
frontier = list(dict.fromkeys(seed_vertex_ids))
|
|
178
|
+
visited_vertices = set(frontier)
|
|
179
|
+
seen_edges: set[tuple[str, str, str, str, str]] = set()
|
|
180
|
+
out: list[GraphFactReadResult] = []
|
|
181
|
+
|
|
182
|
+
for _hop in range(max(1, max_hops)):
|
|
183
|
+
if not frontier or len(out) >= limit:
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
results = await self._run_cypher(
|
|
187
|
+
ctx.tenant_id,
|
|
188
|
+
f"""
|
|
189
|
+
MATCH (s:Entity)-[r:RELATES]->(t:Entity)
|
|
190
|
+
WHERE (s.`~id` IN $seed_ids OR t.`~id` IN $seed_ids)
|
|
191
|
+
AND s.tenant_id = $tenant_id
|
|
192
|
+
AND t.tenant_id = $tenant_id
|
|
193
|
+
AND s.namespace IN $read_ns
|
|
194
|
+
AND t.namespace IN $read_ns
|
|
195
|
+
AND r.namespace IN $read_ns
|
|
196
|
+
AND {temporal_filter}
|
|
197
|
+
RETURN s.`~id` AS src_id,
|
|
198
|
+
s.canonical_name AS src,
|
|
199
|
+
coalesce(s.entity_class, 'p:Concept') AS src_cls,
|
|
200
|
+
r.predicate AS pred,
|
|
201
|
+
t.`~id` AS tgt_id,
|
|
202
|
+
t.canonical_name AS tgt,
|
|
203
|
+
coalesce(t.entity_class, 'p:Concept') AS tgt_cls,
|
|
204
|
+
r.fact_summary AS summary,
|
|
205
|
+
r.t_valid AS t_valid,
|
|
206
|
+
r.t_invalid AS t_invalid,
|
|
207
|
+
r.namespace AS ns,
|
|
208
|
+
coalesce(r.source_episode_id, '') AS ep_id,
|
|
209
|
+
coalesce(r.confidence, 1.0) AS conf
|
|
210
|
+
LIMIT $limit
|
|
211
|
+
""",
|
|
212
|
+
{
|
|
213
|
+
"seed_ids": frontier,
|
|
214
|
+
"tenant_id": ctx.tenant_id,
|
|
215
|
+
"read_ns": ctx.read_namespaces,
|
|
216
|
+
"as_of": as_of_iso or "",
|
|
217
|
+
"limit": limit,
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
next_frontier: list[str] = []
|
|
221
|
+
for r in results:
|
|
222
|
+
edge_key = (
|
|
223
|
+
r["src_id"],
|
|
224
|
+
r["pred"],
|
|
225
|
+
r["tgt_id"],
|
|
226
|
+
r["ns"],
|
|
227
|
+
r["t_valid"],
|
|
228
|
+
)
|
|
229
|
+
if edge_key in seen_edges:
|
|
230
|
+
continue
|
|
231
|
+
seen_edges.add(edge_key)
|
|
232
|
+
out.append(
|
|
233
|
+
GraphFactReadResult(
|
|
234
|
+
source_name=r["src"],
|
|
235
|
+
source_class=r.get("src_cls", "p:Concept"),
|
|
236
|
+
predicate=r["pred"],
|
|
237
|
+
target_name=r["tgt"],
|
|
238
|
+
target_class=r.get("tgt_cls", "p:Concept"),
|
|
239
|
+
fact_summary=r["summary"],
|
|
240
|
+
t_valid=r["t_valid"],
|
|
241
|
+
t_invalid=r["t_invalid"],
|
|
242
|
+
namespace=r["ns"],
|
|
243
|
+
source_episode_id=r.get("ep_id", ""),
|
|
244
|
+
confidence=float(r["conf"]),
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
for vid in (r["src_id"], r["tgt_id"]):
|
|
248
|
+
if vid not in visited_vertices:
|
|
249
|
+
visited_vertices.add(vid)
|
|
250
|
+
next_frontier.append(vid)
|
|
251
|
+
if len(out) >= limit:
|
|
252
|
+
break
|
|
253
|
+
frontier = next_frontier
|
|
254
|
+
|
|
255
|
+
return out[:limit]
|
|
256
|
+
|
|
257
|
+
async def record_reasoning_trace(
|
|
258
|
+
self,
|
|
259
|
+
ctx: PlatformTenantContext,
|
|
260
|
+
run_id: str,
|
|
261
|
+
session_id: str,
|
|
262
|
+
steps: Sequence[ReasoningTraceStep],
|
|
263
|
+
) -> None:
|
|
264
|
+
step_dicts = [s.model_dump() for s in steps]
|
|
265
|
+
await self._run_cypher(
|
|
266
|
+
ctx.tenant_id,
|
|
267
|
+
"""
|
|
268
|
+
MERGE (run:AgentRun {`~id`: $run_id, tenant_id: $tenant_id})
|
|
269
|
+
ON CREATE SET run.app_name = $app,
|
|
270
|
+
run.session_id = $session_id,
|
|
271
|
+
run.namespace = $ns,
|
|
272
|
+
run.created_at = $now
|
|
273
|
+
WITH run
|
|
274
|
+
UNWIND $steps AS st
|
|
275
|
+
MERGE (tc:ToolCall {`~id`: $run_id + '_' + toString(st.step_index), tenant_id: $tenant_id})
|
|
276
|
+
SET tc.tool_name = st.tool_name,
|
|
277
|
+
tc.input_summary = st.tool_input_summary,
|
|
278
|
+
tc.output_summary = st.tool_output_summary,
|
|
279
|
+
tc.status = st.status,
|
|
280
|
+
tc.namespace = $ns
|
|
281
|
+
MERGE (run)-[:EXECUTED_STEP {step_index: st.step_index}]->(tc)
|
|
282
|
+
WITH tc, st
|
|
283
|
+
UNWIND st.touched_vertex_ids AS vid
|
|
284
|
+
MATCH (e:Entity {`~id`: vid, tenant_id: $tenant_id})
|
|
285
|
+
MERGE (tc)-[:TOUCHED_ENTITY]->(e)
|
|
286
|
+
""",
|
|
287
|
+
{
|
|
288
|
+
"run_id": run_id,
|
|
289
|
+
"tenant_id": ctx.tenant_id,
|
|
290
|
+
"app": ctx.app_name,
|
|
291
|
+
"session_id": session_id,
|
|
292
|
+
"ns": ctx.app_private_namespace,
|
|
293
|
+
"now": datetime.now(timezone.utc).isoformat(),
|
|
294
|
+
"steps": step_dicts,
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
async def purge_episode_lineage(
|
|
299
|
+
self, tenant_id: str, episode_ids: Sequence[str], user_id: str
|
|
300
|
+
) -> None:
|
|
301
|
+
await self._run_cypher(
|
|
302
|
+
tenant_id,
|
|
303
|
+
"""
|
|
304
|
+
MATCH ()-[r:RELATES {tenant_id: $tenant_id}]->()
|
|
305
|
+
WHERE r.source_episode_id IN $ep_ids OR r.user_id = $uid
|
|
306
|
+
DELETE r
|
|
307
|
+
""",
|
|
308
|
+
{"tenant_id": tenant_id, "ep_ids": list(episode_ids), "uid": user_id},
|
|
309
|
+
)
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Sequence
|
|
5
|
+
|
|
6
|
+
from ..governance import EntityResolutionGuard
|
|
7
|
+
from ..models import (
|
|
8
|
+
EpisodicRecord,
|
|
9
|
+
GraphVertexUpsert,
|
|
10
|
+
OutboxOperation,
|
|
11
|
+
PlatformTenantContext,
|
|
12
|
+
)
|
|
13
|
+
from ..ports import EpisodicAndSeedStoreAdapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenSearchSeedAdapter(EpisodicAndSeedStoreAdapter):
|
|
17
|
+
"""
|
|
18
|
+
Swappable Amazon OpenSearch adapter for Tier 1 Episodic Search and Tier 2 Hybrid
|
|
19
|
+
(BM25 + k-NN HNSW) Entry-Point Seeding & Entity Resolution Blocking.
|
|
20
|
+
Enforces tenant_id and namespace isolation filters on every query and write.
|
|
21
|
+
Can be paired with PostgreSQL (RegistryStoreAdapter) and Amazon Neptune (GraphStoreAdapter).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
hosts: list[dict[str, Any]] | list[str],
|
|
27
|
+
http_auth: tuple[str, str] | Any = None,
|
|
28
|
+
seed_index_name: str = "platform_graph_seeds",
|
|
29
|
+
episodic_index_name: str = "platform_episodic_memories",
|
|
30
|
+
outbox_index_name: str = "platform_graph_outbox",
|
|
31
|
+
use_ssl: bool = True,
|
|
32
|
+
):
|
|
33
|
+
self.hosts = hosts
|
|
34
|
+
self.http_auth = http_auth
|
|
35
|
+
self.seed_index = seed_index_name
|
|
36
|
+
self.episodic_index = episodic_index_name
|
|
37
|
+
self.outbox_index = outbox_index_name
|
|
38
|
+
self.use_ssl = use_ssl
|
|
39
|
+
self._client: Any = None
|
|
40
|
+
|
|
41
|
+
def _get_client(self):
|
|
42
|
+
if self._client is None:
|
|
43
|
+
from opensearchpy import AsyncOpenSearch
|
|
44
|
+
|
|
45
|
+
self._client = AsyncOpenSearch(
|
|
46
|
+
hosts=self.hosts,
|
|
47
|
+
http_auth=self.http_auth,
|
|
48
|
+
use_ssl=self.use_ssl,
|
|
49
|
+
verify_certs=self.use_ssl,
|
|
50
|
+
)
|
|
51
|
+
return self._client
|
|
52
|
+
|
|
53
|
+
async def close(self) -> None:
|
|
54
|
+
if self._client is not None:
|
|
55
|
+
await self._client.close()
|
|
56
|
+
self._client = None
|
|
57
|
+
|
|
58
|
+
async def resolve_existing_vertex_id(
|
|
59
|
+
self,
|
|
60
|
+
ctx: PlatformTenantContext,
|
|
61
|
+
entity_class: str,
|
|
62
|
+
canonical_name: str,
|
|
63
|
+
embedding: list[float],
|
|
64
|
+
similarity_threshold: float = 0.91,
|
|
65
|
+
) -> str | None:
|
|
66
|
+
client = self._get_client()
|
|
67
|
+
query_body = {
|
|
68
|
+
"size": 1,
|
|
69
|
+
"query": {
|
|
70
|
+
"bool": {
|
|
71
|
+
"filter": [
|
|
72
|
+
{"term": {"tenant_id": ctx.tenant_id}},
|
|
73
|
+
{"terms": {"namespace": ctx.read_namespaces}},
|
|
74
|
+
{"term": {"entity_class": entity_class}},
|
|
75
|
+
],
|
|
76
|
+
"should": [
|
|
77
|
+
{"term": {"canonical_name.keyword": canonical_name}},
|
|
78
|
+
{
|
|
79
|
+
"knn": {
|
|
80
|
+
"embedding": {
|
|
81
|
+
"vector": embedding,
|
|
82
|
+
"k": 3,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
"minimum_should_match": 1,
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
resp = await client.search(index=self.seed_index, body=query_body)
|
|
92
|
+
hits = resp.get("hits", {}).get("hits", [])
|
|
93
|
+
for hit in hits:
|
|
94
|
+
src = hit["_source"]
|
|
95
|
+
cand_name = src.get("canonical_name", "")
|
|
96
|
+
score = float(hit.get("_score", 0.0))
|
|
97
|
+
if EntityResolutionGuard.can_merge(
|
|
98
|
+
cand_name, canonical_name, score, similarity_threshold
|
|
99
|
+
):
|
|
100
|
+
return src["vertex_id"]
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
async def commit_episodes_and_outbox(
|
|
104
|
+
self,
|
|
105
|
+
ctx: PlatformTenantContext,
|
|
106
|
+
episodes: Sequence[EpisodicRecord],
|
|
107
|
+
vertex_upserts: Sequence[GraphVertexUpsert],
|
|
108
|
+
outbox_op: OutboxOperation | None,
|
|
109
|
+
) -> None:
|
|
110
|
+
client = self._get_client()
|
|
111
|
+
allowed_write_ns = {ctx.write_namespace, ctx.app_private_namespace}
|
|
112
|
+
|
|
113
|
+
bulk_ops: list[dict[str, Any]] = []
|
|
114
|
+
for ep in episodes:
|
|
115
|
+
if ep.tenant_id != ctx.tenant_id or ep.app_name != ctx.app_name:
|
|
116
|
+
raise PermissionError("OpenSearch episodic write tenant/app isolation error")
|
|
117
|
+
bulk_ops.append({"index": {"_index": self.episodic_index, "_id": ep.episode_id}})
|
|
118
|
+
bulk_ops.append(
|
|
119
|
+
{
|
|
120
|
+
"episode_id": ep.episode_id,
|
|
121
|
+
"tenant_id": ep.tenant_id,
|
|
122
|
+
"app_name": ep.app_name,
|
|
123
|
+
"user_id": ep.user_id,
|
|
124
|
+
"session_id": ep.session_id,
|
|
125
|
+
"namespace": ep.namespace,
|
|
126
|
+
"author": ep.author,
|
|
127
|
+
"content_text": ep.content_text,
|
|
128
|
+
"embedding": ep.embedding,
|
|
129
|
+
"created_at": ep.created_at.isoformat(),
|
|
130
|
+
"metadata": ep.metadata,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
for v in vertex_upserts:
|
|
135
|
+
if v.tenant_id != ctx.tenant_id or v.namespace not in allowed_write_ns:
|
|
136
|
+
raise PermissionError(
|
|
137
|
+
f"OpenSearch seed write namespace error: {v.namespace}"
|
|
138
|
+
)
|
|
139
|
+
doc_id = f"{v.tenant_id}:{v.vertex_id}"
|
|
140
|
+
bulk_ops.append({"index": {"_index": self.seed_index, "_id": doc_id}})
|
|
141
|
+
bulk_ops.append(v.model_dump())
|
|
142
|
+
|
|
143
|
+
if outbox_op is not None:
|
|
144
|
+
bulk_ops.append(
|
|
145
|
+
{"index": {"_index": self.outbox_index, "_id": outbox_op.outbox_id}}
|
|
146
|
+
)
|
|
147
|
+
bulk_ops.append(
|
|
148
|
+
{
|
|
149
|
+
**outbox_op.model_dump(),
|
|
150
|
+
"status": "PENDING",
|
|
151
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if bulk_ops:
|
|
156
|
+
await client.bulk(body=bulk_ops, refresh="wait_for")
|
|
157
|
+
|
|
158
|
+
async def hybrid_seed_search(
|
|
159
|
+
self,
|
|
160
|
+
ctx: PlatformTenantContext,
|
|
161
|
+
query_text: str,
|
|
162
|
+
query_embedding: list[float],
|
|
163
|
+
limit: int = 8,
|
|
164
|
+
) -> list[GraphVertexUpsert]:
|
|
165
|
+
client = self._get_client()
|
|
166
|
+
security_filter = [
|
|
167
|
+
{"term": {"tenant_id": ctx.tenant_id}},
|
|
168
|
+
{"terms": {"namespace": ctx.read_namespaces}},
|
|
169
|
+
]
|
|
170
|
+
# Execute BM25 lexical search + k-NN vector search and fuse with RRF
|
|
171
|
+
body = {
|
|
172
|
+
"size": limit,
|
|
173
|
+
"query": {
|
|
174
|
+
"bool": {
|
|
175
|
+
"filter": security_filter,
|
|
176
|
+
"should": [
|
|
177
|
+
{
|
|
178
|
+
"multi_match": {
|
|
179
|
+
"query": query_text,
|
|
180
|
+
"fields": ["canonical_name^2.0", "summary"],
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"knn": {
|
|
185
|
+
"embedding": {
|
|
186
|
+
"vector": query_embedding,
|
|
187
|
+
"k": limit * 2,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
"minimum_should_match": 1,
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
"_source": {"excludes": ["embedding"]},
|
|
196
|
+
}
|
|
197
|
+
resp = await client.search(index=self.seed_index, body=body)
|
|
198
|
+
hits = resp.get("hits", {}).get("hits", [])
|
|
199
|
+
return [
|
|
200
|
+
GraphVertexUpsert(**{**h["_source"], "embedding": []})
|
|
201
|
+
for h in hits
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
async def search_episodes(
|
|
205
|
+
self,
|
|
206
|
+
ctx: PlatformTenantContext,
|
|
207
|
+
query_embedding: list[float],
|
|
208
|
+
limit: int = 4,
|
|
209
|
+
) -> list[EpisodicRecord]:
|
|
210
|
+
client = self._get_client()
|
|
211
|
+
body = {
|
|
212
|
+
"size": limit,
|
|
213
|
+
"query": {
|
|
214
|
+
"bool": {
|
|
215
|
+
"filter": [
|
|
216
|
+
{"term": {"tenant_id": ctx.tenant_id}},
|
|
217
|
+
{"term": {"app_name": ctx.app_name}},
|
|
218
|
+
{"term": {"user_id": ctx.user_id}},
|
|
219
|
+
],
|
|
220
|
+
"must": [
|
|
221
|
+
{
|
|
222
|
+
"knn": {
|
|
223
|
+
"embedding": {
|
|
224
|
+
"vector": query_embedding,
|
|
225
|
+
"k": limit * 2,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
],
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
"_source": {"excludes": ["embedding"]},
|
|
233
|
+
}
|
|
234
|
+
resp = await client.search(index=self.episodic_index, body=body)
|
|
235
|
+
hits = resp.get("hits", {}).get("hits", [])
|
|
236
|
+
return [
|
|
237
|
+
EpisodicRecord(**{**h["_source"], "embedding": []})
|
|
238
|
+
for h in hits
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
async def fetch_pending_outbox(self, batch_size: int = 25) -> list[OutboxOperation]:
|
|
242
|
+
client = self._get_client()
|
|
243
|
+
body = {
|
|
244
|
+
"size": batch_size,
|
|
245
|
+
"sort": [{"created_at": {"order": "asc"}}],
|
|
246
|
+
"query": {
|
|
247
|
+
"bool": {
|
|
248
|
+
"filter": [
|
|
249
|
+
{"terms": {"status": ["PENDING", "RETRY"]}},
|
|
250
|
+
{"range": {"attempts": {"lt": 5}}},
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
resp = await client.search(index=self.outbox_index, body=body)
|
|
256
|
+
hits = resp.get("hits", {}).get("hits", [])
|
|
257
|
+
return [
|
|
258
|
+
OutboxOperation(
|
|
259
|
+
outbox_id=h["_source"]["outbox_id"],
|
|
260
|
+
tenant_id=h["_source"]["tenant_id"],
|
|
261
|
+
operation_type=h["_source"]["operation_type"],
|
|
262
|
+
payload=h["_source"]["payload"],
|
|
263
|
+
attempts=h["_source"].get("attempts", 0),
|
|
264
|
+
)
|
|
265
|
+
for h in hits
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
async def mark_outbox_processed(self, outbox_id: str) -> None:
|
|
269
|
+
client = self._get_client()
|
|
270
|
+
await client.update(
|
|
271
|
+
index=self.outbox_index,
|
|
272
|
+
id=outbox_id,
|
|
273
|
+
body={
|
|
274
|
+
"doc": {
|
|
275
|
+
"status": "PROCESSED",
|
|
276
|
+
"processed_at": datetime.now(timezone.utc).isoformat(),
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def mark_outbox_failed(self, outbox_id: str, error_msg: str) -> None:
|
|
282
|
+
client = self._get_client()
|
|
283
|
+
await client.update(
|
|
284
|
+
index=self.outbox_index,
|
|
285
|
+
id=outbox_id,
|
|
286
|
+
body={
|
|
287
|
+
"script": {
|
|
288
|
+
"source": """
|
|
289
|
+
ctx._source.attempts += 1;
|
|
290
|
+
ctx._source.last_error = params.err;
|
|
291
|
+
ctx._source.status = (ctx._source.attempts >= 5) ? 'DEAD_LETTER' : 'RETRY';
|
|
292
|
+
""",
|
|
293
|
+
"lang": "painless",
|
|
294
|
+
"params": {"err": error_msg[:500]},
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
async def purge_user_data(self, ctx: PlatformTenantContext) -> list[str]:
|
|
300
|
+
client = self._get_client()
|
|
301
|
+
query = {
|
|
302
|
+
"query": {
|
|
303
|
+
"bool": {
|
|
304
|
+
"filter": [
|
|
305
|
+
{"term": {"tenant_id": ctx.tenant_id}},
|
|
306
|
+
{"term": {"user_id": ctx.user_id}},
|
|
307
|
+
]
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
"_source": ["episode_id"],
|
|
311
|
+
"size": 1000,
|
|
312
|
+
}
|
|
313
|
+
resp = await client.search(index=self.episodic_index, body=query)
|
|
314
|
+
hits = resp.get("hits", {}).get("hits", [])
|
|
315
|
+
episode_ids = [h["_source"]["episode_id"] for h in hits]
|
|
316
|
+
if episode_ids:
|
|
317
|
+
await client.delete_by_query(
|
|
318
|
+
index=self.episodic_index,
|
|
319
|
+
body={"query": {"terms": {"episode_id": episode_ids}}},
|
|
320
|
+
refresh=True,
|
|
321
|
+
)
|
|
322
|
+
return episode_ids
|