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,439 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any, Sequence
|
|
5
|
+
|
|
6
|
+
from ..governance import EntityResolutionGuard
|
|
7
|
+
from ..models import (
|
|
8
|
+
CompiledTBox,
|
|
9
|
+
EpisodicRecord,
|
|
10
|
+
GraphEdgeMutation,
|
|
11
|
+
GraphFactReadResult,
|
|
12
|
+
GraphVertexUpsert,
|
|
13
|
+
OutboxOperation,
|
|
14
|
+
PlatformTenantContext,
|
|
15
|
+
PlaybookRecord,
|
|
16
|
+
ReasoningTraceStep,
|
|
17
|
+
)
|
|
18
|
+
from ..ports import (
|
|
19
|
+
EpisodicAndSeedStoreAdapter,
|
|
20
|
+
GraphStoreAdapter,
|
|
21
|
+
PlaybookStoreAdapter,
|
|
22
|
+
RegistryStoreAdapter,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InMemoryUnifiedAdapter(
|
|
27
|
+
RegistryStoreAdapter,
|
|
28
|
+
EpisodicAndSeedStoreAdapter,
|
|
29
|
+
GraphStoreAdapter,
|
|
30
|
+
PlaybookStoreAdapter,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Zero-dependency in-memory reference adapter enforcing the exact same
|
|
34
|
+
RLS namespace isolation, hybrid RRF search, bi-temporal functional edge invalidation,
|
|
35
|
+
reasoning trace storage, transactional outbox, procedural playbooks, and GDPR lineage purge rules.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.registry: dict[tuple[str, str], tuple[list[str], str, CompiledTBox]] = {}
|
|
40
|
+
self.episodes: list[EpisodicRecord] = []
|
|
41
|
+
self.seeds: dict[tuple[str, str], GraphVertexUpsert] = {}
|
|
42
|
+
self.edges: list[dict[str, Any]] = []
|
|
43
|
+
self.traces: dict[tuple[str, str], dict[str, Any]] = {}
|
|
44
|
+
self.outbox: dict[str, OutboxOperation] = {}
|
|
45
|
+
self.playbooks: dict[tuple[str, str], PlaybookRecord] = {}
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
49
|
+
if not a or not b or len(a) != len(b):
|
|
50
|
+
return 0.0
|
|
51
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
52
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
53
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
54
|
+
return dot / (na * nb) if (na and nb) else 0.0
|
|
55
|
+
|
|
56
|
+
async def register_app(
|
|
57
|
+
self,
|
|
58
|
+
tenant_id: str,
|
|
59
|
+
app_name: str,
|
|
60
|
+
read_namespaces: list[str],
|
|
61
|
+
write_namespace: str,
|
|
62
|
+
tbox: CompiledTBox,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.registry[(tenant_id, app_name)] = (
|
|
65
|
+
list(read_namespaces),
|
|
66
|
+
write_namespace,
|
|
67
|
+
tbox,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def resolve_context(
|
|
71
|
+
self, tenant_id: str, app_name: str, user_id: str
|
|
72
|
+
) -> PlatformTenantContext:
|
|
73
|
+
app_ns = f"app:{tenant_id}:{app_name}"
|
|
74
|
+
user_ns = f"user:{tenant_id}:{user_id}"
|
|
75
|
+
if (tenant_id, app_name) not in self.registry:
|
|
76
|
+
# Zero-config fallback: auto-provision Tier 0 isolated context + cross-app user namespace
|
|
77
|
+
return PlatformTenantContext(
|
|
78
|
+
tenant_id=tenant_id,
|
|
79
|
+
app_name=app_name,
|
|
80
|
+
user_id=user_id,
|
|
81
|
+
app_private_namespace=app_ns,
|
|
82
|
+
write_namespace=app_ns,
|
|
83
|
+
read_namespaces=[app_ns, user_ns, f"global:{tenant_id}:core"],
|
|
84
|
+
tbox=CompiledTBox(),
|
|
85
|
+
)
|
|
86
|
+
read_ns, write_ns, tbox = self.registry[(tenant_id, app_name)]
|
|
87
|
+
merged_read_ns = list(dict.fromkeys([*read_ns, user_ns]))
|
|
88
|
+
return PlatformTenantContext(
|
|
89
|
+
tenant_id=tenant_id,
|
|
90
|
+
app_name=app_name,
|
|
91
|
+
user_id=user_id,
|
|
92
|
+
app_private_namespace=app_ns,
|
|
93
|
+
write_namespace=write_ns,
|
|
94
|
+
read_namespaces=merged_read_ns,
|
|
95
|
+
tbox=tbox,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
async def resolve_existing_vertex_id(
|
|
99
|
+
self,
|
|
100
|
+
ctx: PlatformTenantContext,
|
|
101
|
+
entity_class: str,
|
|
102
|
+
canonical_name: str,
|
|
103
|
+
embedding: list[float],
|
|
104
|
+
similarity_threshold: float = 0.91,
|
|
105
|
+
) -> str | None:
|
|
106
|
+
best_vid: str | None = None
|
|
107
|
+
best_sim: float = -1.0
|
|
108
|
+
for (t_id, v_id), seed in self.seeds.items():
|
|
109
|
+
if t_id != ctx.tenant_id or seed.namespace not in ctx.read_namespaces:
|
|
110
|
+
continue
|
|
111
|
+
if seed.entity_class == entity_class:
|
|
112
|
+
sim = self._cosine(seed.embedding, embedding)
|
|
113
|
+
if EntityResolutionGuard.can_merge(
|
|
114
|
+
seed.canonical_name, canonical_name, sim, similarity_threshold
|
|
115
|
+
):
|
|
116
|
+
if (
|
|
117
|
+
EntityResolutionGuard.normalize_name(seed.canonical_name)
|
|
118
|
+
== EntityResolutionGuard.normalize_name(canonical_name)
|
|
119
|
+
):
|
|
120
|
+
return v_id
|
|
121
|
+
if sim > best_sim:
|
|
122
|
+
best_sim = sim
|
|
123
|
+
best_vid = v_id
|
|
124
|
+
return best_vid
|
|
125
|
+
|
|
126
|
+
async def commit_episodes_and_outbox(
|
|
127
|
+
self,
|
|
128
|
+
ctx: PlatformTenantContext,
|
|
129
|
+
episodes: Sequence[EpisodicRecord],
|
|
130
|
+
vertex_upserts: Sequence[GraphVertexUpsert],
|
|
131
|
+
outbox_op: OutboxOperation | None,
|
|
132
|
+
) -> None:
|
|
133
|
+
allowed_write_ns = {
|
|
134
|
+
ctx.write_namespace,
|
|
135
|
+
ctx.app_private_namespace,
|
|
136
|
+
ctx.user_private_namespace,
|
|
137
|
+
}
|
|
138
|
+
for ep in episodes:
|
|
139
|
+
if ep.tenant_id != ctx.tenant_id or ep.app_name != ctx.app_name:
|
|
140
|
+
raise PermissionError("Episodic RLS violation: mismatched tenant_id or app_name")
|
|
141
|
+
self.episodes.append(ep)
|
|
142
|
+
|
|
143
|
+
for v in vertex_upserts:
|
|
144
|
+
if v.tenant_id != ctx.tenant_id or v.namespace not in allowed_write_ns:
|
|
145
|
+
raise PermissionError(
|
|
146
|
+
f"Seed write RLS violation: {v.namespace} not in {allowed_write_ns}"
|
|
147
|
+
)
|
|
148
|
+
self.seeds[(ctx.tenant_id, v.vertex_id)] = v
|
|
149
|
+
|
|
150
|
+
if outbox_op is not None:
|
|
151
|
+
self.outbox[outbox_op.outbox_id] = outbox_op
|
|
152
|
+
|
|
153
|
+
async def hybrid_seed_search(
|
|
154
|
+
self,
|
|
155
|
+
ctx: PlatformTenantContext,
|
|
156
|
+
query_text: str,
|
|
157
|
+
query_embedding: list[float],
|
|
158
|
+
limit: int = 8,
|
|
159
|
+
) -> list[GraphVertexUpsert]:
|
|
160
|
+
candidates = [
|
|
161
|
+
s
|
|
162
|
+
for (t_id, _), s in self.seeds.items()
|
|
163
|
+
if t_id == ctx.tenant_id and s.namespace in ctx.read_namespaces
|
|
164
|
+
]
|
|
165
|
+
if not candidates:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
# Reciprocal Rank Fusion (RRF) combining vector cosine rank and lexical BM25-like overlap rank
|
|
169
|
+
vec_ranked = sorted(
|
|
170
|
+
candidates,
|
|
171
|
+
key=lambda s: self._cosine(s.embedding, query_embedding),
|
|
172
|
+
reverse=True,
|
|
173
|
+
)
|
|
174
|
+
q_terms = {
|
|
175
|
+
w.strip(".,!?;:()[]\"'").lower()
|
|
176
|
+
for w in query_text.split()
|
|
177
|
+
if w.strip(".,!?;:()[]\"'")
|
|
178
|
+
}
|
|
179
|
+
lex_ranked = sorted(
|
|
180
|
+
candidates,
|
|
181
|
+
key=lambda s: len(
|
|
182
|
+
q_terms.intersection(
|
|
183
|
+
{
|
|
184
|
+
w.strip(".,!?;:()[]\"'").lower()
|
|
185
|
+
for w in (s.canonical_name + " " + s.summary).split()
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
),
|
|
189
|
+
reverse=True,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
rrf_scores: dict[str, float] = {}
|
|
193
|
+
by_id: dict[str, GraphVertexUpsert] = {}
|
|
194
|
+
for rank_idx, s in enumerate(vec_ranked, start=1):
|
|
195
|
+
rrf_scores[s.vertex_id] = rrf_scores.get(s.vertex_id, 0.0) + 1.0 / (60 + rank_idx)
|
|
196
|
+
by_id[s.vertex_id] = s
|
|
197
|
+
for rank_idx, s in enumerate(lex_ranked, start=1):
|
|
198
|
+
rrf_scores[s.vertex_id] = rrf_scores.get(s.vertex_id, 0.0) + 1.0 / (60 + rank_idx)
|
|
199
|
+
by_id[s.vertex_id] = s
|
|
200
|
+
|
|
201
|
+
sorted_ids = sorted(rrf_scores.keys(), key=lambda vid: rrf_scores[vid], reverse=True)
|
|
202
|
+
return [by_id[vid] for vid in sorted_ids[:limit]]
|
|
203
|
+
|
|
204
|
+
async def search_episodes(
|
|
205
|
+
self,
|
|
206
|
+
ctx: PlatformTenantContext,
|
|
207
|
+
query_embedding: list[float],
|
|
208
|
+
limit: int = 4,
|
|
209
|
+
) -> list[EpisodicRecord]:
|
|
210
|
+
scoped = [
|
|
211
|
+
e
|
|
212
|
+
for e in self.episodes
|
|
213
|
+
if e.tenant_id == ctx.tenant_id
|
|
214
|
+
and e.app_name == ctx.app_name
|
|
215
|
+
and e.user_id == ctx.user_id
|
|
216
|
+
]
|
|
217
|
+
scoped.sort(key=lambda e: self._cosine(e.embedding, query_embedding), reverse=True)
|
|
218
|
+
return scoped[:limit]
|
|
219
|
+
|
|
220
|
+
async def fetch_pending_outbox(self, batch_size: int = 25) -> list[OutboxOperation]:
|
|
221
|
+
pending = [op for op in self.outbox.values() if op.attempts < 5]
|
|
222
|
+
return pending[:batch_size]
|
|
223
|
+
|
|
224
|
+
async def mark_outbox_processed(self, outbox_id: str) -> None:
|
|
225
|
+
self.outbox.pop(outbox_id, None)
|
|
226
|
+
|
|
227
|
+
async def mark_outbox_failed(self, outbox_id: str, error_msg: str) -> None:
|
|
228
|
+
if outbox_id in self.outbox:
|
|
229
|
+
self.outbox[outbox_id].attempts += 1
|
|
230
|
+
|
|
231
|
+
async def upsert_vertices_and_bitemporal_edges(
|
|
232
|
+
self,
|
|
233
|
+
tenant_id: str,
|
|
234
|
+
vertices: Sequence[GraphVertexUpsert],
|
|
235
|
+
edges: Sequence[GraphEdgeMutation],
|
|
236
|
+
) -> None:
|
|
237
|
+
for v in vertices:
|
|
238
|
+
existing = self.seeds.get((tenant_id, v.vertex_id))
|
|
239
|
+
if existing is None:
|
|
240
|
+
self.seeds[(tenant_id, v.vertex_id)] = v
|
|
241
|
+
else:
|
|
242
|
+
# Preserve existing embedding if outbox stripped it
|
|
243
|
+
merged = v.model_copy()
|
|
244
|
+
if not merged.embedding and existing.embedding:
|
|
245
|
+
merged.embedding = existing.embedding
|
|
246
|
+
self.seeds[(tenant_id, v.vertex_id)] = merged
|
|
247
|
+
|
|
248
|
+
for e in edges:
|
|
249
|
+
if e.is_functional:
|
|
250
|
+
for existing in self.edges:
|
|
251
|
+
if (
|
|
252
|
+
existing["tenant_id"] == tenant_id
|
|
253
|
+
and existing["source_vertex_id"] == e.source_vertex_id
|
|
254
|
+
and existing["predicate"] == e.predicate
|
|
255
|
+
and existing["namespace"] == e.namespace
|
|
256
|
+
and existing["t_invalid"] == "ACTIVE"
|
|
257
|
+
and existing["target_vertex_id"] != e.target_vertex_id
|
|
258
|
+
):
|
|
259
|
+
existing["t_invalid"] = e.t_valid
|
|
260
|
+
|
|
261
|
+
# Idempotent check on (source_vertex_id, predicate, target_vertex_id, namespace, ACTIVE)
|
|
262
|
+
# HippoRAG-inspired Associative Reinforcement: re-confirming an active edge boosts salience!
|
|
263
|
+
already_active = False
|
|
264
|
+
for existing in self.edges:
|
|
265
|
+
if (
|
|
266
|
+
existing["tenant_id"] == tenant_id
|
|
267
|
+
and existing["source_vertex_id"] == e.source_vertex_id
|
|
268
|
+
and existing["target_vertex_id"] == e.target_vertex_id
|
|
269
|
+
and existing["predicate"] == e.predicate
|
|
270
|
+
and existing["namespace"] == e.namespace
|
|
271
|
+
and existing["t_invalid"] == "ACTIVE"
|
|
272
|
+
):
|
|
273
|
+
existing["fact_summary"] = e.fact_summary
|
|
274
|
+
existing["reinforcement_count"] = int(existing.get("reinforcement_count", 1)) + 1
|
|
275
|
+
existing["confidence"] = min(
|
|
276
|
+
1.0, max(float(existing.get("confidence", 0.9)), e.confidence) + 0.03
|
|
277
|
+
)
|
|
278
|
+
already_active = True
|
|
279
|
+
break
|
|
280
|
+
|
|
281
|
+
if not already_active:
|
|
282
|
+
self.edges.append(
|
|
283
|
+
{**e.model_dump(), "t_invalid": "ACTIVE", "reinforcement_count": 1}
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def expand_active_subgraph(
|
|
287
|
+
self,
|
|
288
|
+
ctx: PlatformTenantContext,
|
|
289
|
+
seed_vertex_ids: Sequence[str],
|
|
290
|
+
as_of_iso: str | None = None,
|
|
291
|
+
max_hops: int = 2,
|
|
292
|
+
limit: int = 25,
|
|
293
|
+
) -> list[GraphFactReadResult]:
|
|
294
|
+
# Letta-inspired Core Persona Fast-Path: automatically include the user's cross-app
|
|
295
|
+
# `user:{tenant}:{user_id}` preference vertices in the initial frontier even if the
|
|
296
|
+
# current query vector is orthogonal to their persona preferences!
|
|
297
|
+
user_persona_vids = [
|
|
298
|
+
vid
|
|
299
|
+
for (t_id, vid), s in self.seeds.items()
|
|
300
|
+
if t_id == ctx.tenant_id and s.namespace == ctx.user_private_namespace
|
|
301
|
+
]
|
|
302
|
+
combined_seeds = list(dict.fromkeys([*seed_vertex_ids, *user_persona_vids]))
|
|
303
|
+
if not combined_seeds:
|
|
304
|
+
return []
|
|
305
|
+
frontier = set(combined_seeds)
|
|
306
|
+
visited_vertices = set(combined_seeds)
|
|
307
|
+
seen_edge_keys: set[tuple[str, str, str, str, str]] = set()
|
|
308
|
+
out: list[GraphFactReadResult] = []
|
|
309
|
+
|
|
310
|
+
for _hop in range(max(1, max_hops)):
|
|
311
|
+
if not frontier or len(out) >= limit:
|
|
312
|
+
break
|
|
313
|
+
next_frontier: set[str] = set()
|
|
314
|
+
for r in self.edges:
|
|
315
|
+
if r["tenant_id"] != ctx.tenant_id or r["namespace"] not in ctx.read_namespaces:
|
|
316
|
+
continue
|
|
317
|
+
src_id = r["source_vertex_id"]
|
|
318
|
+
tgt_id = r["target_vertex_id"]
|
|
319
|
+
if src_id not in frontier and tgt_id not in frontier:
|
|
320
|
+
continue
|
|
321
|
+
if as_of_iso is None:
|
|
322
|
+
if r["t_invalid"] != "ACTIVE":
|
|
323
|
+
continue
|
|
324
|
+
else:
|
|
325
|
+
if not (
|
|
326
|
+
r["t_valid"] <= as_of_iso
|
|
327
|
+
and (r["t_invalid"] == "ACTIVE" or r["t_invalid"] > as_of_iso)
|
|
328
|
+
):
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
edge_key = (src_id, r["predicate"], tgt_id, r["namespace"], r["t_valid"])
|
|
332
|
+
if edge_key in seen_edge_keys:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
src_node = self.seeds.get((ctx.tenant_id, src_id))
|
|
336
|
+
tgt_node = self.seeds.get((ctx.tenant_id, tgt_id))
|
|
337
|
+
if src_node and tgt_node:
|
|
338
|
+
if (
|
|
339
|
+
src_node.namespace not in ctx.read_namespaces
|
|
340
|
+
or tgt_node.namespace not in ctx.read_namespaces
|
|
341
|
+
):
|
|
342
|
+
continue
|
|
343
|
+
seen_edge_keys.add(edge_key)
|
|
344
|
+
out.append(
|
|
345
|
+
GraphFactReadResult(
|
|
346
|
+
source_name=src_node.canonical_name,
|
|
347
|
+
source_class=src_node.entity_class,
|
|
348
|
+
predicate=r["predicate"],
|
|
349
|
+
target_name=tgt_node.canonical_name,
|
|
350
|
+
target_class=tgt_node.entity_class,
|
|
351
|
+
fact_summary=r["fact_summary"],
|
|
352
|
+
t_valid=r["t_valid"],
|
|
353
|
+
t_invalid=r["t_invalid"],
|
|
354
|
+
namespace=r["namespace"],
|
|
355
|
+
source_episode_id=r.get("source_episode_id", ""),
|
|
356
|
+
confidence=r["confidence"],
|
|
357
|
+
reinforcement_count=int(r.get("reinforcement_count", 1)),
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
if src_id not in visited_vertices:
|
|
361
|
+
next_frontier.add(src_id)
|
|
362
|
+
if tgt_id not in visited_vertices:
|
|
363
|
+
next_frontier.add(tgt_id)
|
|
364
|
+
if len(out) >= limit:
|
|
365
|
+
break
|
|
366
|
+
visited_vertices.update(next_frontier)
|
|
367
|
+
frontier = next_frontier
|
|
368
|
+
|
|
369
|
+
return out[:limit]
|
|
370
|
+
|
|
371
|
+
async def record_reasoning_trace(
|
|
372
|
+
self,
|
|
373
|
+
ctx: PlatformTenantContext,
|
|
374
|
+
run_id: str,
|
|
375
|
+
session_id: str,
|
|
376
|
+
steps: Sequence[ReasoningTraceStep],
|
|
377
|
+
) -> None:
|
|
378
|
+
self.traces[(ctx.tenant_id, run_id)] = {
|
|
379
|
+
"tenant_id": ctx.tenant_id,
|
|
380
|
+
"app_name": ctx.app_name,
|
|
381
|
+
"session_id": session_id,
|
|
382
|
+
"namespace": ctx.app_private_namespace,
|
|
383
|
+
"steps": [s.model_dump() for s in steps],
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async def purge_user_data(self, ctx: PlatformTenantContext) -> list[str]:
|
|
387
|
+
purged = [
|
|
388
|
+
e.episode_id
|
|
389
|
+
for e in self.episodes
|
|
390
|
+
if e.user_id == ctx.user_id and e.tenant_id == ctx.tenant_id
|
|
391
|
+
]
|
|
392
|
+
purged_set = set(purged)
|
|
393
|
+
self.episodes = [e for e in self.episodes if e.episode_id not in purged_set]
|
|
394
|
+
return purged
|
|
395
|
+
|
|
396
|
+
async def purge_episode_lineage(
|
|
397
|
+
self, tenant_id: str, episode_ids: Sequence[str], user_id: str
|
|
398
|
+
) -> None:
|
|
399
|
+
ep_set = set(episode_ids)
|
|
400
|
+
self.edges = [
|
|
401
|
+
r
|
|
402
|
+
for r in self.edges
|
|
403
|
+
if not (
|
|
404
|
+
r["tenant_id"] == tenant_id
|
|
405
|
+
and (r["source_episode_id"] in ep_set or r["user_id"] == user_id)
|
|
406
|
+
)
|
|
407
|
+
]
|
|
408
|
+
|
|
409
|
+
async def upsert_playbook(self, playbook: PlaybookRecord) -> PlaybookRecord:
|
|
410
|
+
for (t_id, pb_id), existing in list(self.playbooks.items()):
|
|
411
|
+
if (
|
|
412
|
+
t_id == playbook.tenant_id
|
|
413
|
+
and existing.namespace == playbook.namespace
|
|
414
|
+
and existing.trigger_intent.strip().lower()
|
|
415
|
+
== playbook.trigger_intent.strip().lower()
|
|
416
|
+
):
|
|
417
|
+
playbook.playbook_id = pb_id
|
|
418
|
+
playbook.success_count = existing.success_count + 1
|
|
419
|
+
self.playbooks[(t_id, pb_id)] = playbook
|
|
420
|
+
return playbook
|
|
421
|
+
self.playbooks[(playbook.tenant_id, playbook.playbook_id)] = playbook
|
|
422
|
+
return playbook
|
|
423
|
+
|
|
424
|
+
async def match_playbooks(
|
|
425
|
+
self,
|
|
426
|
+
tenant_id: str,
|
|
427
|
+
read_namespaces: list[str],
|
|
428
|
+
query_embedding: list[float],
|
|
429
|
+
similarity_threshold: float = 0.85,
|
|
430
|
+
limit: int = 2,
|
|
431
|
+
) -> list[PlaybookRecord]:
|
|
432
|
+
scored: list[tuple[float, PlaybookRecord]] = []
|
|
433
|
+
for (t_id, _), pb in self.playbooks.items():
|
|
434
|
+
if t_id == tenant_id and pb.namespace in read_namespaces:
|
|
435
|
+
sim = self._cosine(pb.embedding, query_embedding)
|
|
436
|
+
if sim >= similarity_threshold:
|
|
437
|
+
scored.append((sim, pb))
|
|
438
|
+
scored.sort(key=lambda item: (item[0], item[1].success_count), reverse=True)
|
|
439
|
+
return [pb for _, pb in scored[:limit]]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..auth import CloudAuthProvider, EnterpriseTlsConfig
|
|
6
|
+
from .neptune_cypher import NeptuneOpenCypherAdapter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Neo4jGraphAdapter(NeptuneOpenCypherAdapter):
|
|
10
|
+
"""
|
|
11
|
+
Drop-in Neo4j Bolt replacement for `NeptuneOpenCypherAdapter`.
|
|
12
|
+
Reuses the exact openCypher queries while rewriting Neptune's ``~id`` token to
|
|
13
|
+
standard ``vertex_id`` and executing over `AsyncGraphDatabase`.
|
|
14
|
+
Supports Basic Auth, Neo4j Enterprise / Aura SSO Bearer Token (`bearer_token`),
|
|
15
|
+
dynamic `CloudAuthProvider`, and `EnterpriseTlsConfig`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
uri: str,
|
|
21
|
+
auth: tuple[str, str] | None = None,
|
|
22
|
+
database: str = "neo4j",
|
|
23
|
+
auth_provider: CloudAuthProvider | None = None,
|
|
24
|
+
bearer_token: str | None = None,
|
|
25
|
+
tls_config: EnterpriseTlsConfig | None = None,
|
|
26
|
+
):
|
|
27
|
+
self.uri = uri
|
|
28
|
+
self.auth = auth
|
|
29
|
+
self.database = database
|
|
30
|
+
self.auth_provider = auth_provider
|
|
31
|
+
self.bearer_token = bearer_token
|
|
32
|
+
self.tls_config = tls_config
|
|
33
|
+
self._driver: Any = None
|
|
34
|
+
|
|
35
|
+
async def close(self) -> None:
|
|
36
|
+
if self._driver is not None:
|
|
37
|
+
await self._driver.close()
|
|
38
|
+
self._driver = None
|
|
39
|
+
|
|
40
|
+
async def _run_cypher(
|
|
41
|
+
self, tenant_id: str, query: str, params: dict[str, Any]
|
|
42
|
+
) -> list[dict[str, Any]]:
|
|
43
|
+
if self._driver is None:
|
|
44
|
+
import neo4j
|
|
45
|
+
from neo4j import AsyncGraphDatabase
|
|
46
|
+
|
|
47
|
+
resolved_auth: Any = self.auth
|
|
48
|
+
if self.auth_provider is not None:
|
|
49
|
+
user, pwd = await self.auth_provider.get_credentials()
|
|
50
|
+
resolved_auth = (user, pwd)
|
|
51
|
+
elif self.bearer_token is not None:
|
|
52
|
+
resolved_auth = neo4j.bearer_auth(self.bearer_token)
|
|
53
|
+
|
|
54
|
+
driver_kwargs: dict[str, Any] = {"auth": resolved_auth}
|
|
55
|
+
if (
|
|
56
|
+
self.tls_config is not None
|
|
57
|
+
and self.tls_config.enabled
|
|
58
|
+
and not self.uri.startswith(("neo4j+s://", "bolt+s://", "neo4j+ssc://", "bolt+ssc://"))
|
|
59
|
+
):
|
|
60
|
+
driver_kwargs["encrypted"] = True
|
|
61
|
+
self._driver = AsyncGraphDatabase.driver(self.uri, **driver_kwargs)
|
|
62
|
+
neo_query = query.replace("`~id`", "vertex_id")
|
|
63
|
+
async with self._driver.session(database=self.database) as session:
|
|
64
|
+
result = await session.run(neo_query, parameters=params)
|
|
65
|
+
return [dict(record) async for record in result]
|