@pentatonic-ai/ai-agent-sdk 0.7.13 → 0.8.1
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.
- package/package.json +1 -1
- package/packages/memory/openclaw-plugin/index.js +7 -0
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +9 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/engine.test.js +142 -0
- package/packages/memory/src/engine.js +65 -0
- package/packages/memory-engine/compat/server.py +90 -5
- package/packages/memory-engine/docker-compose.yml +18 -8
- package/packages/memory-engine/engine/services/_shared/__init__.py +1 -0
- package/packages/memory-engine/engine/services/_shared/embed_provider.py +431 -0
- package/packages/memory-engine/engine/services/l2/Dockerfile +4 -2
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +640 -81
- package/packages/memory-engine/engine/services/l4/Dockerfile +5 -1
- package/packages/memory-engine/engine/services/l4/server.py +19 -57
- package/packages/memory-engine/engine/services/l5/Dockerfile +3 -1
- package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +24 -32
- package/packages/memory-engine/engine/services/l6/Dockerfile +3 -1
- package/packages/memory-engine/engine/services/l6/l6-document-store.py +24 -29
- package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +128 -0
- package/packages/memory-engine/tests/e2e_arena.sh +28 -4
- package/packages/memory-engine/tests/test_aggregate.py +333 -0
- package/packages/memory-engine/tests/test_arena_safety.py +232 -0
- package/packages/memory-engine/tests/test_channel_stat_reader.py +437 -0
- package/packages/memory-engine/tests/test_channel_stat_rollups.py +308 -0
- package/packages/memory-engine/tests/test_embed_provider.py +354 -0
- package/packages/memory-engine/tests/test_l3_arena_isolation.py +412 -0
|
@@ -17,6 +17,7 @@ import json
|
|
|
17
17
|
import logging
|
|
18
18
|
import os
|
|
19
19
|
import sqlite3
|
|
20
|
+
import sys
|
|
20
21
|
import time
|
|
21
22
|
from datetime import datetime
|
|
22
23
|
from pathlib import Path
|
|
@@ -30,6 +31,10 @@ from neo4j.time import DateTime as Neo4jDateTime, Date as Neo4jDate
|
|
|
30
31
|
from pydantic import BaseModel
|
|
31
32
|
import uvicorn
|
|
32
33
|
|
|
34
|
+
# Shared embed client lives at engine/services/_shared/.
|
|
35
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
36
|
+
from _shared.embed_provider import EmbedClient # noqa: E402
|
|
37
|
+
|
|
33
38
|
|
|
34
39
|
def _serialize_neo4j_value(v: Any) -> Any:
|
|
35
40
|
"""Convert neo4j-specific types to JSON-serialisable equivalents.
|
|
@@ -93,10 +98,27 @@ QMD_DB_PATH = _resolve_qmd_db()
|
|
|
93
98
|
OLLAMA_URL = os.environ.get("PME_OLLAMA_URL", "http://localhost:11434/api/embeddings")
|
|
94
99
|
EMBEDDING_MODEL = os.environ.get("PME_EMBED_MODEL", "nomic-embed-text")
|
|
95
100
|
|
|
96
|
-
# NV-Embed-v2 service (primary, 4096-dim)
|
|
97
|
-
|
|
101
|
+
# NV-Embed-v2 service (primary, 4096-dim). URL/auth/path/body/response are
|
|
102
|
+
# managed by the shared EmbedClient; PME_EMBED_PROVIDER (default openai)
|
|
103
|
+
# selects auth scheme (Bearer vs X-API-Key) and request shape.
|
|
98
104
|
NV_EMBED_ENABLED = os.environ.get("PME_NV_EMBED_ENABLED", "true").lower() == "true"
|
|
99
105
|
|
|
106
|
+
_embed: EmbedClient | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _embed_client() -> EmbedClient:
|
|
110
|
+
"""Lazily build the shared EmbedClient for L2."""
|
|
111
|
+
global _embed
|
|
112
|
+
if _embed is None:
|
|
113
|
+
_embed = EmbedClient.from_env(
|
|
114
|
+
prefix="PME_",
|
|
115
|
+
url_var="PME_NV_EMBED_URL",
|
|
116
|
+
key_var="PME_EMBED_API_KEY",
|
|
117
|
+
model_var="PME_NV_EMBED_MODEL",
|
|
118
|
+
default_url="http://localhost:8041/v1/embeddings",
|
|
119
|
+
)
|
|
120
|
+
return _embed
|
|
121
|
+
|
|
100
122
|
# Sequential processing weights - OPTIMIZED FOR QUALITY
|
|
101
123
|
GRAPH_PRIORITY_BOOST = 0.5 # Extra score for graph-derived results (↑ for better entity/relationship context)
|
|
102
124
|
VECTOR_BASE_WEIGHT = 0.5 # Base weight for vector results (↓ balanced for accuracy over speed)
|
|
@@ -208,6 +230,12 @@ class ChatCompletionRequest(BaseModel):
|
|
|
208
230
|
model: str = "gpt-3.5-turbo"
|
|
209
231
|
max_tokens: int = 1000
|
|
210
232
|
temperature: float = 0.1
|
|
233
|
+
# Optional tenant scope. When absent, the L3 graph layer returns no
|
|
234
|
+
# results (rather than walking the global graph) — other layers
|
|
235
|
+
# still respond, so the call succeeds but with reduced L3 context.
|
|
236
|
+
# Existing single-tenant callers (benchmarks, dev) keep working.
|
|
237
|
+
arena: Optional[str] = None
|
|
238
|
+
arenas: Optional[List[str]] = None
|
|
211
239
|
|
|
212
240
|
class EmbeddingRequest(BaseModel):
|
|
213
241
|
input: Any
|
|
@@ -239,9 +267,15 @@ def extract_query_entities(query: str) -> List[str]:
|
|
|
239
267
|
log.info(f"Extracted entities: {potential_entities}")
|
|
240
268
|
return potential_entities
|
|
241
269
|
|
|
242
|
-
def _hebbian_strengthen(session, node_names: List[str], increment: float = 0.05) -> None:
|
|
243
|
-
"""Hebbian: strengthen edges between co-accessed nodes during query.
|
|
244
|
-
|
|
270
|
+
def _hebbian_strengthen(session, arenas: List[str], node_names: List[str], increment: float = 0.05) -> None:
|
|
271
|
+
"""Hebbian: strengthen edges between co-accessed nodes during query.
|
|
272
|
+
|
|
273
|
+
Scoped by arena so a search inside tenant A can't reinforce edges
|
|
274
|
+
inside tenant B's graph (which would happen via shared entity-name
|
|
275
|
+
nodes pre-arena). When `arenas` is empty (single-tenant local dev,
|
|
276
|
+
benchmarks) we no-op rather than risk a cross-tenant write.
|
|
277
|
+
"""
|
|
278
|
+
if len(node_names) < 2 or not arenas:
|
|
245
279
|
return
|
|
246
280
|
now = datetime.utcnow().isoformat() + "Z"
|
|
247
281
|
for i, n1 in enumerate(node_names):
|
|
@@ -249,16 +283,28 @@ def _hebbian_strengthen(session, node_names: List[str], increment: float = 0.05)
|
|
|
249
283
|
try:
|
|
250
284
|
session.run(
|
|
251
285
|
"""MATCH (a {name: $n1})-[r]-(b {name: $n2})
|
|
286
|
+
WHERE a.arena IN $arenas AND b.arena IN $arenas
|
|
252
287
|
SET r.weight = coalesce(r.weight, 1.0) + $inc,
|
|
253
288
|
r.last_accessed = $now""",
|
|
254
|
-
n1=n1, n2=n2, inc=increment, now=now
|
|
289
|
+
n1=n1, n2=n2, arenas=arenas, inc=increment, now=now
|
|
255
290
|
)
|
|
256
291
|
except Exception:
|
|
257
292
|
pass # non-critical
|
|
258
293
|
|
|
259
294
|
|
|
260
|
-
def search_neo4j_sequential(query: str, entities: List[str], limit: int = 12) -> Dict:
|
|
261
|
-
"""Phase 1: Neo4j graph search with spreading activation + Hebbian.
|
|
295
|
+
def search_neo4j_sequential(query: str, entities: List[str], arenas: List[str], limit: int = 12) -> Dict:
|
|
296
|
+
"""Phase 1: Neo4j graph search with spreading activation + Hebbian.
|
|
297
|
+
|
|
298
|
+
`arenas` is the tenant-scope set the caller is authorised for —
|
|
299
|
+
typically [clientId] or [clientId, clientId:userId]. Every Cypher
|
|
300
|
+
clause filters on `n.arena IN $arenas`, so a search from tenant A
|
|
301
|
+
can never traverse into entity nodes belonging to tenant B even
|
|
302
|
+
when their names collide. Empty `arenas` short-circuits to no
|
|
303
|
+
results — that's safer than walking the entire graph in dev/test.
|
|
304
|
+
"""
|
|
305
|
+
if not arenas:
|
|
306
|
+
log.warning("search_neo4j_sequential called without arenas — returning empty results")
|
|
307
|
+
return {"results": [], "graph_entities": [], "entity_count": 0}
|
|
262
308
|
try:
|
|
263
309
|
driver = GraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH)
|
|
264
310
|
results = []
|
|
@@ -267,19 +313,20 @@ def search_neo4j_sequential(query: str, entities: List[str], limit: int = 12) ->
|
|
|
267
313
|
with driver.session() as session:
|
|
268
314
|
# Search for specific entities — use weighted spreading activation
|
|
269
315
|
for entity in entities:
|
|
270
|
-
# Direct match first
|
|
316
|
+
# Direct match first — arena-scoped on every node we touch.
|
|
271
317
|
cypher = """
|
|
272
318
|
MATCH (n)
|
|
273
|
-
WHERE n.name CONTAINS $entity
|
|
319
|
+
WHERE n.name CONTAINS $entity AND n.arena IN $arenas
|
|
274
320
|
OPTIONAL MATCH (n)-[r]-(connected)
|
|
275
|
-
WHERE
|
|
321
|
+
WHERE connected.arena IN $arenas
|
|
322
|
+
AND coalesce(r.weight, 1.0) >= 0.2
|
|
276
323
|
RETURN n, r, connected, $entity as search_entity,
|
|
277
324
|
coalesce(r.weight, 1.0) AS edge_weight
|
|
278
325
|
ORDER BY edge_weight DESC
|
|
279
326
|
LIMIT $limit
|
|
280
327
|
"""
|
|
281
328
|
|
|
282
|
-
records = session.run(cypher, entity=entity, limit=8)
|
|
329
|
+
records = session.run(cypher, entity=entity, arenas=arenas, limit=8)
|
|
283
330
|
|
|
284
331
|
for record in records:
|
|
285
332
|
node = _serialize_neo4j_value(dict(record["n"]))
|
|
@@ -314,11 +361,17 @@ def search_neo4j_sequential(query: str, entities: List[str], limit: int = 12) ->
|
|
|
314
361
|
"node_data": node
|
|
315
362
|
})
|
|
316
363
|
|
|
317
|
-
# 2-hop spreading activation for high-weight paths
|
|
364
|
+
# 2-hop spreading activation for high-weight paths.
|
|
365
|
+
# Every node along the walk must be in-arena. Without
|
|
366
|
+
# the filter, an activation could walk into another
|
|
367
|
+
# tenant's graph via a name-collision on the start node.
|
|
318
368
|
if entity:
|
|
319
369
|
activation_results = session.run("""
|
|
320
370
|
MATCH (start)-[r1]-(mid)-[r2]-(end)
|
|
321
371
|
WHERE start.name CONTAINS $entity
|
|
372
|
+
AND start.arena IN $arenas
|
|
373
|
+
AND mid.arena IN $arenas
|
|
374
|
+
AND end.arena IN $arenas
|
|
322
375
|
AND coalesce(r1.weight, 1.0) >= 0.5
|
|
323
376
|
AND coalesce(r2.weight, 1.0) >= 0.5
|
|
324
377
|
AND start <> end
|
|
@@ -327,7 +380,7 @@ def search_neo4j_sequential(query: str, entities: List[str], limit: int = 12) ->
|
|
|
327
380
|
mid.name AS via
|
|
328
381
|
ORDER BY activation DESC
|
|
329
382
|
LIMIT 5
|
|
330
|
-
""", entity=entity)
|
|
383
|
+
""", entity=entity, arenas=arenas)
|
|
331
384
|
|
|
332
385
|
for rec in activation_results:
|
|
333
386
|
end_node = _serialize_neo4j_value(dict(rec["end"])) if rec["end"] else {}
|
|
@@ -343,20 +396,24 @@ def search_neo4j_sequential(query: str, entities: List[str], limit: int = 12) ->
|
|
|
343
396
|
"node_data": end_node
|
|
344
397
|
})
|
|
345
398
|
|
|
346
|
-
# General query search if no specific entities found
|
|
399
|
+
# General query search if no specific entities found —
|
|
400
|
+
# arena-gated so the fallback can't walk other tenants'
|
|
401
|
+
# nodes when the heuristic entity extractor returned nothing.
|
|
347
402
|
if not results:
|
|
348
403
|
general_words = [w for w in query.split() if len(w) > 3 and w.lower() not in ['what', 'who', 'where', 'when', 'how']]
|
|
349
404
|
|
|
350
405
|
for word in general_words[:2]:
|
|
351
406
|
cypher = """
|
|
352
407
|
MATCH (n)
|
|
353
|
-
WHERE
|
|
408
|
+
WHERE n.arena IN $arenas
|
|
409
|
+
AND ANY(prop IN keys(n) WHERE n[prop] IS :: STRING AND n[prop] CONTAINS $term)
|
|
354
410
|
OPTIONAL MATCH (n)-[r]-(connected)
|
|
411
|
+
WHERE connected.arena IN $arenas
|
|
355
412
|
RETURN n, r, connected
|
|
356
413
|
LIMIT $limit
|
|
357
414
|
"""
|
|
358
415
|
|
|
359
|
-
records = session.run(cypher, term=word, limit=4)
|
|
416
|
+
records = session.run(cypher, term=word, arenas=arenas, limit=4)
|
|
360
417
|
|
|
361
418
|
for record in records:
|
|
362
419
|
node = _serialize_neo4j_value(dict(record["n"]))
|
|
@@ -373,7 +430,7 @@ def search_neo4j_sequential(query: str, entities: List[str], limit: int = 12) ->
|
|
|
373
430
|
})
|
|
374
431
|
|
|
375
432
|
# Hebbian: strengthen edges between all accessed entities
|
|
376
|
-
_hebbian_strengthen(session, list(graph_entities))
|
|
433
|
+
_hebbian_strengthen(session, arenas, list(graph_entities))
|
|
377
434
|
|
|
378
435
|
driver.close()
|
|
379
436
|
|
|
@@ -389,12 +446,11 @@ def search_neo4j_sequential(query: str, entities: List[str], limit: int = 12) ->
|
|
|
389
446
|
|
|
390
447
|
def get_embedding(text: str) -> List[float]:
|
|
391
448
|
"""Get embedding — tries NV-Embed-v2 (4096-dim) first, falls back to Ollama."""
|
|
392
|
-
# Try NV-Embed-v2 service first
|
|
449
|
+
# Try NV-Embed-v2 service first via the shared EmbedClient (handles
|
|
450
|
+
# provider selection, auth scheme, path, and 401 auto-detect).
|
|
393
451
|
if NV_EMBED_ENABLED:
|
|
394
452
|
try:
|
|
395
|
-
|
|
396
|
-
r.raise_for_status()
|
|
397
|
-
return r.json()["data"][0]["embedding"]
|
|
453
|
+
return _embed_client().embed_one(text)
|
|
398
454
|
except Exception as e:
|
|
399
455
|
log.warning(f"NV-Embed-v2 failed, falling back to Ollama: {e}")
|
|
400
456
|
|
|
@@ -953,9 +1009,12 @@ def sequential_hybridrag_search(query: str, limit: int = 16,
|
|
|
953
1009
|
log.info(f"L1 System files: {len(system_results)} results")
|
|
954
1010
|
|
|
955
1011
|
# L2: HybridRAG orchestration
|
|
956
|
-
# L3: Graph search (entity extraction + Neo4j)
|
|
1012
|
+
# L3: Graph search (entity extraction + Neo4j) — arena-scoped so a
|
|
1013
|
+
# tenant's search can never traverse another tenant's entity graph
|
|
1014
|
+
# via name collisions on shared :Entity nodes. The post-filter shim
|
|
1015
|
+
# protects chunks; this protects the entity-walking layer too.
|
|
957
1016
|
entities = extract_query_entities(query)
|
|
958
|
-
graph_context = search_neo4j_sequential(query, entities, limit=8)
|
|
1017
|
+
graph_context = search_neo4j_sequential(query, entities, arena_list, limit=8)
|
|
959
1018
|
log.info(f"L3 Graph search: {len(graph_context['results'])} results, {graph_context['entity_count']} entities")
|
|
960
1019
|
|
|
961
1020
|
# HyDE: expand query for better vector embeddings
|
|
@@ -1037,9 +1096,12 @@ async def search_endpoint(request: Request) -> dict:
|
|
|
1037
1096
|
|
|
1038
1097
|
results = sequential_hybridrag_search(query, limit=limit, arena=arena, arenas=arenas)
|
|
1039
1098
|
|
|
1040
|
-
# Also return raw graph entities for context enrichment
|
|
1099
|
+
# Also return raw graph entities for context enrichment.
|
|
1100
|
+
# Same arena scope as the cascade search above — without it
|
|
1101
|
+
# the entities returned could include cross-tenant rows.
|
|
1102
|
+
arena_list = list(arenas) if arenas else ([arena] if arena else [])
|
|
1041
1103
|
entities = extract_query_entities(query)
|
|
1042
|
-
graph_context = search_neo4j_sequential(query, entities, limit=8)
|
|
1104
|
+
graph_context = search_neo4j_sequential(query, entities, arena_list, limit=8)
|
|
1043
1105
|
|
|
1044
1106
|
return {
|
|
1045
1107
|
"results": results,
|
|
@@ -1073,17 +1135,23 @@ async def list_models() -> dict:
|
|
|
1073
1135
|
@app.post("/v1/embeddings")
|
|
1074
1136
|
async def create_embeddings(request: EmbeddingRequest) -> dict:
|
|
1075
1137
|
"""Pass-through to NV-Embed-v2 (4096-dim). Batch-native — forwards the full
|
|
1076
|
-
input list in a single HTTP call instead of looping one-at-a-time.
|
|
1138
|
+
input list in a single HTTP call instead of looping one-at-a-time.
|
|
1139
|
+
|
|
1140
|
+
Returns OpenAI-shaped response regardless of upstream provider, so
|
|
1141
|
+
callers (including L4 search and external clients) get a consistent
|
|
1142
|
+
contract from this proxy."""
|
|
1077
1143
|
try:
|
|
1078
|
-
import httpx
|
|
1079
1144
|
inputs = [request.input] if isinstance(request.input, str) else request.input
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1145
|
+
embeddings = await _embed_client().embed_batch_async(inputs)
|
|
1146
|
+
return {
|
|
1147
|
+
"object": "list",
|
|
1148
|
+
"model": request.model or "nv-embed-v2",
|
|
1149
|
+
"data": [
|
|
1150
|
+
{"object": "embedding", "embedding": e, "index": i}
|
|
1151
|
+
for i, e in enumerate(embeddings)
|
|
1152
|
+
],
|
|
1153
|
+
"usage": {"prompt_tokens": 0, "total_tokens": 0},
|
|
1154
|
+
}
|
|
1087
1155
|
except Exception as e:
|
|
1088
1156
|
raise HTTPException(status_code=500, detail=str(e))
|
|
1089
1157
|
|
|
@@ -1098,9 +1166,15 @@ async def chat_completions(request: ChatCompletionRequest) -> dict:
|
|
|
1098
1166
|
|
|
1099
1167
|
query = user_messages[-1].content
|
|
1100
1168
|
|
|
1101
|
-
# Perform sequential HybridRAG search
|
|
1169
|
+
# Perform sequential HybridRAG search — pass through tenant
|
|
1170
|
+
# scope from the request so L3 graph traversal stays inside the
|
|
1171
|
+
# caller's arena. The search function short-circuits L3 to
|
|
1172
|
+
# empty when no arenas are supplied; callers that need L3 must
|
|
1173
|
+
# pass `arena` or `arenas` on the request body.
|
|
1102
1174
|
start_time = time.time()
|
|
1103
|
-
results = sequential_hybridrag_search(
|
|
1175
|
+
results = sequential_hybridrag_search(
|
|
1176
|
+
query, limit=16, arena=request.arena, arenas=request.arenas,
|
|
1177
|
+
)
|
|
1104
1178
|
search_time = time.time() - start_time
|
|
1105
1179
|
|
|
1106
1180
|
# Format results with correct layer structure
|
|
@@ -1156,38 +1230,57 @@ async def chat_completions(request: ChatCompletionRequest) -> dict:
|
|
|
1156
1230
|
raise HTTPException(status_code=500, detail=str(e))
|
|
1157
1231
|
|
|
1158
1232
|
@app.get("/contradictions/{node_name}")
|
|
1159
|
-
async def check_contradictions(node_name: str) -> dict:
|
|
1160
|
-
"""Detect contradictions around a named node.
|
|
1233
|
+
async def check_contradictions(node_name: str, arena: Optional[str] = None) -> dict:
|
|
1234
|
+
"""Detect contradictions around a named node.
|
|
1235
|
+
|
|
1236
|
+
`arena` is required to scope the lookup to one tenant's graph. The
|
|
1237
|
+
endpoint returns a 400 when called without it — silently spanning
|
|
1238
|
+
the entire graph here would leak entity names across tenants via
|
|
1239
|
+
the `node_name` lookup.
|
|
1240
|
+
"""
|
|
1241
|
+
if not arena:
|
|
1242
|
+
raise HTTPException(
|
|
1243
|
+
status_code=400,
|
|
1244
|
+
detail="arena query parameter is required to scope contradiction lookup",
|
|
1245
|
+
)
|
|
1161
1246
|
try:
|
|
1162
1247
|
driver = GraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH)
|
|
1163
1248
|
contradictions = []
|
|
1164
1249
|
with driver.session() as session:
|
|
1165
|
-
# Find the node
|
|
1250
|
+
# Find the node — must be in the caller's arena.
|
|
1166
1251
|
node = session.run(
|
|
1167
|
-
"MATCH (n) WHERE toLower(n.name) = toLower($name)
|
|
1252
|
+
"""MATCH (n) WHERE toLower(n.name) = toLower($name) AND n.arena = $arena
|
|
1253
|
+
RETURN elementId(n) AS id""",
|
|
1254
|
+
name=node_name, arena=arena,
|
|
1168
1255
|
).single()
|
|
1169
1256
|
if not node:
|
|
1170
1257
|
return {"node": node_name, "contradictions": [], "error": "Node not found"}
|
|
1171
1258
|
nid = node["id"]
|
|
1172
1259
|
|
|
1173
|
-
# Explicit CONTRADICTS
|
|
1260
|
+
# Explicit CONTRADICTS — both endpoints must be in the same arena.
|
|
1174
1261
|
for rec in session.run(
|
|
1175
|
-
"""MATCH (a)-[r:CONTRADICTS]-(b)
|
|
1176
|
-
|
|
1262
|
+
"""MATCH (a)-[r:CONTRADICTS]-(b)
|
|
1263
|
+
WHERE elementId(a) = $nid AND b.arena = $arena
|
|
1264
|
+
RETURN a.name AS a, b.name AS b, r.reason AS reason""",
|
|
1265
|
+
nid=nid, arena=arena,
|
|
1177
1266
|
):
|
|
1178
1267
|
contradictions.append({"type": "explicit", "a": rec["a"], "b": rec["b"], "reason": rec["reason"]})
|
|
1179
1268
|
|
|
1180
|
-
# Property conflicts via shared neighbour
|
|
1269
|
+
# Property conflicts via shared neighbour — every node along
|
|
1270
|
+
# the (a)--(shared)--(b) path filtered by arena so a shared
|
|
1271
|
+
# neighbour from another tenant can't trigger a false-positive
|
|
1272
|
+
# conflict in this tenant's view.
|
|
1181
1273
|
for rec in session.run(
|
|
1182
1274
|
"""MATCH (a)--(shared)--(b)
|
|
1183
1275
|
WHERE elementId(a) = $nid AND a <> b
|
|
1276
|
+
AND shared.arena = $arena AND b.arena = $arena
|
|
1184
1277
|
WITH a, b, shared, properties(a) AS pa, properties(b) AS pb
|
|
1185
1278
|
WITH a, b, shared,
|
|
1186
1279
|
[k IN keys(pa) WHERE k IN keys(pb) AND pa[k] <> pb[k]
|
|
1187
1280
|
AND NOT k IN ['last_accessed','embedding','created_at','updated_at','id','weight']] AS ck
|
|
1188
1281
|
WHERE size(ck) > 0
|
|
1189
1282
|
RETURN a.name AS a, b.name AS b, shared.name AS via, ck
|
|
1190
|
-
LIMIT 10""", nid=nid
|
|
1283
|
+
LIMIT 10""", nid=nid, arena=arena,
|
|
1191
1284
|
):
|
|
1192
1285
|
contradictions.append({
|
|
1193
1286
|
"type": "property_conflict", "a": rec["a"], "b": rec["b"],
|
|
@@ -1319,17 +1412,11 @@ def _extract_entities_for_kg(text: str, max_entities: int = 32) -> List[str]:
|
|
|
1319
1412
|
|
|
1320
1413
|
|
|
1321
1414
|
def _embed_batch_local(texts: List[str]) -> List[List[float]]:
|
|
1322
|
-
"""Batch embed via
|
|
1415
|
+
"""Batch embed via the shared EmbedClient. Returns vectors in input order."""
|
|
1323
1416
|
if not texts:
|
|
1324
1417
|
return []
|
|
1325
1418
|
try:
|
|
1326
|
-
|
|
1327
|
-
json={"input": texts, "model": "nv-embed-v2"},
|
|
1328
|
-
timeout=120)
|
|
1329
|
-
r.raise_for_status()
|
|
1330
|
-
data = r.json().get("data", [])
|
|
1331
|
-
# NV-Embed returns [{embedding: [...]}, ...]
|
|
1332
|
-
return [d["embedding"] for d in data]
|
|
1419
|
+
return _embed_client().embed_batch(texts)
|
|
1333
1420
|
except Exception as e:
|
|
1334
1421
|
log.warning(f"NV-Embed batch failed: {e}; trying singletons")
|
|
1335
1422
|
return [get_embedding(t) for t in texts]
|
|
@@ -1451,22 +1538,70 @@ async def index_internal_batch(req: IndexInternalBatchRequest) -> dict:
|
|
|
1451
1538
|
log.error(f"L4 QMD write failed: {e}")
|
|
1452
1539
|
|
|
1453
1540
|
# ---- L3 Neo4j KG ----------------------------------------------------
|
|
1541
|
+
# Every node and edge written here is arena-scoped. Two paths:
|
|
1542
|
+
#
|
|
1543
|
+
# 1. Heuristic Concept extraction — title-case + bigrams over the
|
|
1544
|
+
# chunk body, same as before. Concepts MERGE on (arena, name)
|
|
1545
|
+
# so two tenants can independently mint a "Pricing" concept
|
|
1546
|
+
# without colliding.
|
|
1547
|
+
#
|
|
1548
|
+
# 2. Metadata-driven Person extraction — when the chunk's metadata
|
|
1549
|
+
# carries contact_email / contact_name (Pip emits these from
|
|
1550
|
+
# its ingest pipeline; other clients can do the same), we MERGE
|
|
1551
|
+
# a typed (:Entity:Person) node and connect it to the chunk via
|
|
1552
|
+
# a (:COMMUNICATED) edge that carries channel + direction. This
|
|
1553
|
+
# is the path the relationships UI reads from — it's reliable
|
|
1554
|
+
# because the writer knows exactly who the person is, no NLP
|
|
1555
|
+
# guessing required.
|
|
1556
|
+
#
|
|
1557
|
+
# The compound (arena, name) MERGE guarantees no cross-tenant entity
|
|
1558
|
+
# collapse. Pre-existing unscoped entities (arena IS NULL) are left
|
|
1559
|
+
# alone; the wipe-legacy migration script handles them out of band.
|
|
1454
1560
|
l3_entities = 0
|
|
1455
1561
|
l3_chunks = 0
|
|
1456
1562
|
try:
|
|
1457
1563
|
driver = GraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH)
|
|
1458
1564
|
with driver.session() as session:
|
|
1459
|
-
#
|
|
1565
|
+
# Indexes — idempotent. The compound (arena, name) is the
|
|
1566
|
+
# right shape now that entities are arena-scoped; the legacy
|
|
1567
|
+
# entity_name index stays for the wipe-migration to work
|
|
1568
|
+
# against pre-arena rows, then can be dropped in a follow-up.
|
|
1460
1569
|
try:
|
|
1461
|
-
session.run("CREATE INDEX
|
|
1570
|
+
session.run("CREATE INDEX entity_arena_name IF NOT EXISTS FOR (n:Entity) ON (n.arena, n.name)")
|
|
1571
|
+
session.run("CREATE INDEX person_arena_email IF NOT EXISTS FOR (n:Person) ON (n.arena, n.email)")
|
|
1572
|
+
session.run("CREATE INDEX chunk_arena IF NOT EXISTS FOR (c:Chunk) ON (c.arena)")
|
|
1462
1573
|
session.run("CREATE INDEX chunk_id IF NOT EXISTS FOR (c:Chunk) ON (c.id)")
|
|
1574
|
+
# ChannelStat is the denormalised aggregate read by
|
|
1575
|
+
# /aggregate on the fast path. Compound index covers
|
|
1576
|
+
# the (arena, person_email) lookup that the reader
|
|
1577
|
+
# uses; the per-channel rows are returned in one
|
|
1578
|
+
# range scan.
|
|
1579
|
+
session.run("CREATE INDEX channelstat_arena_email IF NOT EXISTS FOR (s:ChannelStat) ON (s.arena, s.person_email)")
|
|
1580
|
+
# UNIQUE constraint on the writer's MERGE key. Without
|
|
1581
|
+
# this, two concurrent index-internal-batch transactions
|
|
1582
|
+
# can both decide a ChannelStat doesn't exist and create
|
|
1583
|
+
# rival nodes — the index doesn't lock, the constraint
|
|
1584
|
+
# does. The constraint also implies an index on the
|
|
1585
|
+
# full key so the MERGE locks efficiently.
|
|
1586
|
+
session.run("CREATE CONSTRAINT channelstat_unique IF NOT EXISTS FOR (s:ChannelStat) REQUIRE (s.arena, s.person_email, s.channel) IS UNIQUE")
|
|
1463
1587
|
except Exception:
|
|
1464
1588
|
pass
|
|
1465
1589
|
for n in norm:
|
|
1466
|
-
|
|
1467
|
-
|
|
1590
|
+
heuristic_entities = _extract_entities_for_kg(n["content"])
|
|
1591
|
+
meta = n.get("metadata") or {}
|
|
1592
|
+
contact_email = meta.get("contact_email")
|
|
1593
|
+
contact_name = meta.get("contact_name")
|
|
1594
|
+
channel = meta.get("channel")
|
|
1595
|
+
direction = meta.get("direction")
|
|
1596
|
+
occurred_at = meta.get("timestamp") or meta.get("occurred_at") or now_iso
|
|
1597
|
+
# Skip the chunk only when there is genuinely nothing to
|
|
1598
|
+
# connect — heuristic entities AND no person metadata.
|
|
1599
|
+
if not heuristic_entities and not contact_email and not contact_name:
|
|
1468
1600
|
continue
|
|
1469
|
-
# Create the chunk node
|
|
1601
|
+
# Create the chunk node — arena property is the
|
|
1602
|
+
# tenant-isolation anchor. Every read traverses through
|
|
1603
|
+
# this node, so getting the arena right here is the
|
|
1604
|
+
# single most important invariant of this whole block.
|
|
1470
1605
|
session.run(
|
|
1471
1606
|
"""
|
|
1472
1607
|
MERGE (c:Chunk {id: $cid})
|
|
@@ -1480,38 +1615,150 @@ async def index_internal_batch(req: IndexInternalBatchRequest) -> dict:
|
|
|
1480
1615
|
arena=arena, now=now_iso,
|
|
1481
1616
|
)
|
|
1482
1617
|
l3_chunks += 1
|
|
1483
|
-
|
|
1484
|
-
|
|
1618
|
+
|
|
1619
|
+
# Concept entities — heuristic, arena-scoped.
|
|
1620
|
+
for ent in heuristic_entities:
|
|
1485
1621
|
session.run(
|
|
1486
1622
|
"""
|
|
1487
|
-
MERGE (e:Entity {name: $name})
|
|
1623
|
+
MERGE (e:Entity:Concept {arena: $arena, name: $name})
|
|
1488
1624
|
ON CREATE SET e.type = 'Concept',
|
|
1489
1625
|
e.created_at = $now,
|
|
1490
1626
|
e.weight = 1.0
|
|
1491
1627
|
WITH e
|
|
1492
|
-
MATCH (c:Chunk {id: $cid})
|
|
1628
|
+
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
1493
1629
|
MERGE (e)-[r:MENTIONS]->(c)
|
|
1494
1630
|
ON CREATE SET r.weight = 1.0, r.created_at = $now
|
|
1495
1631
|
ON MATCH SET r.weight = coalesce(r.weight, 1.0) + 0.1
|
|
1496
1632
|
""",
|
|
1497
|
-
name=ent, cid=n["id"], now=now_iso,
|
|
1633
|
+
arena=arena, name=ent, cid=n["id"], now=now_iso,
|
|
1498
1634
|
)
|
|
1499
1635
|
l3_entities += 1
|
|
1500
|
-
#
|
|
1501
|
-
# so
|
|
1502
|
-
if
|
|
1503
|
-
|
|
1504
|
-
|
|
1636
|
+
# Concept-concept co-occurrence — same arena on both
|
|
1637
|
+
# ends so cross-tenant CO_OCCURS edges can't form even
|
|
1638
|
+
# if two tenants happen to extract the same concept name.
|
|
1639
|
+
if len(heuristic_entities) >= 2:
|
|
1640
|
+
for i in range(len(heuristic_entities)):
|
|
1641
|
+
for j in range(i + 1, len(heuristic_entities)):
|
|
1505
1642
|
session.run(
|
|
1506
1643
|
"""
|
|
1507
|
-
MATCH (a:Entity {name: $a})
|
|
1508
|
-
MATCH (b:Entity {name: $b})
|
|
1644
|
+
MATCH (a:Entity:Concept {arena: $arena, name: $a})
|
|
1645
|
+
MATCH (b:Entity:Concept {arena: $arena, name: $b})
|
|
1509
1646
|
MERGE (a)-[r:CO_OCCURS]->(b)
|
|
1510
1647
|
ON CREATE SET r.weight = 0.5, r.created_at = $now
|
|
1511
1648
|
ON MATCH SET r.weight = coalesce(r.weight, 0.5) + 0.05
|
|
1512
1649
|
""",
|
|
1513
|
-
|
|
1650
|
+
arena=arena, a=heuristic_entities[i],
|
|
1651
|
+
b=heuristic_entities[j], now=now_iso,
|
|
1514
1652
|
)
|
|
1653
|
+
|
|
1654
|
+
# Person entities — typed via writer-supplied metadata.
|
|
1655
|
+
# Email gets its own node (canonical id for a person);
|
|
1656
|
+
# name gets its own node (display surface). When both
|
|
1657
|
+
# are present they're linked via KNOWN_AS so a query
|
|
1658
|
+
# against either resolves the same person.
|
|
1659
|
+
person_email_node = None
|
|
1660
|
+
if isinstance(contact_email, str) and contact_email.strip():
|
|
1661
|
+
norm_email = contact_email.strip().lower()
|
|
1662
|
+
# Two-phase write: MERGE the Person + COMMUNICATED
|
|
1663
|
+
# edge, then update the ChannelStat aggregate IFF
|
|
1664
|
+
# the edge was just created. The `r._counted` flag
|
|
1665
|
+
# is the idempotency rail — set false on CREATE and
|
|
1666
|
+
# flipped to true after the stat update, so replays
|
|
1667
|
+
# of the same eventId never double-count even when
|
|
1668
|
+
# the chunk already exists.
|
|
1669
|
+
session.run(
|
|
1670
|
+
"""
|
|
1671
|
+
MERGE (p:Entity:Person {arena: $arena, email: $email})
|
|
1672
|
+
ON CREATE SET p.created_at = $now,
|
|
1673
|
+
p.first_seen = $occurred_at,
|
|
1674
|
+
p.last_seen = $occurred_at
|
|
1675
|
+
ON MATCH SET p.last_seen = CASE
|
|
1676
|
+
WHEN $occurred_at > coalesce(p.last_seen, '')
|
|
1677
|
+
THEN $occurred_at
|
|
1678
|
+
ELSE p.last_seen END
|
|
1679
|
+
WITH p
|
|
1680
|
+
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
1681
|
+
MERGE (p)-[r:COMMUNICATED]->(c)
|
|
1682
|
+
ON CREATE SET r.channel = $channel,
|
|
1683
|
+
r.direction = $direction,
|
|
1684
|
+
r.occurred_at = $occurred_at,
|
|
1685
|
+
r.weight = 1.0,
|
|
1686
|
+
r._counted = false
|
|
1687
|
+
WITH p, r
|
|
1688
|
+
// ChannelStat denormalises Person-COMMUNICATED
|
|
1689
|
+
// edge counts so /aggregate becomes a property
|
|
1690
|
+
// read instead of a per-query Cypher walk over
|
|
1691
|
+
// every edge. Read path falls back to the edge
|
|
1692
|
+
// walk for older tenants whose stats haven't
|
|
1693
|
+
// been backfilled, so this is a forward-only
|
|
1694
|
+
// optimisation — no migration needed for stats
|
|
1695
|
+
// to start materialising.
|
|
1696
|
+
FOREACH (_ IN CASE WHEN r._counted = false THEN [1] ELSE [] END |
|
|
1697
|
+
MERGE (s:ChannelStat {arena: $arena, person_email: $email, channel: $channel})
|
|
1698
|
+
ON CREATE SET s.count = 0,
|
|
1699
|
+
s.inbound = 0,
|
|
1700
|
+
s.outbound = 0,
|
|
1701
|
+
s.first_seen = $occurred_at,
|
|
1702
|
+
s.last_seen = $occurred_at,
|
|
1703
|
+
s.created_at = $now
|
|
1704
|
+
SET s.count = s.count + 1,
|
|
1705
|
+
s.inbound = s.inbound + (CASE WHEN $direction = 'inbound' THEN 1 ELSE 0 END),
|
|
1706
|
+
s.outbound = s.outbound + (CASE WHEN $direction = 'outbound' THEN 1 ELSE 0 END),
|
|
1707
|
+
s.first_seen = CASE
|
|
1708
|
+
WHEN $occurred_at < coalesce(s.first_seen, $occurred_at)
|
|
1709
|
+
THEN $occurred_at
|
|
1710
|
+
ELSE s.first_seen END,
|
|
1711
|
+
s.last_seen = CASE
|
|
1712
|
+
WHEN $occurred_at > coalesce(s.last_seen, '')
|
|
1713
|
+
THEN $occurred_at
|
|
1714
|
+
ELSE s.last_seen END,
|
|
1715
|
+
s.updated_at = $now
|
|
1716
|
+
MERGE (p)-[:HAS_STAT]->(s)
|
|
1717
|
+
SET r._counted = true
|
|
1718
|
+
)
|
|
1719
|
+
""",
|
|
1720
|
+
arena=arena, email=norm_email, cid=n["id"],
|
|
1721
|
+
channel=channel, direction=direction,
|
|
1722
|
+
occurred_at=occurred_at, now=now_iso,
|
|
1723
|
+
)
|
|
1724
|
+
person_email_node = norm_email
|
|
1725
|
+
l3_entities += 1
|
|
1726
|
+
if isinstance(contact_name, str) and contact_name.strip():
|
|
1727
|
+
cname = contact_name.strip()
|
|
1728
|
+
session.run(
|
|
1729
|
+
"""
|
|
1730
|
+
MERGE (p:Entity:Person {arena: $arena, name: $name})
|
|
1731
|
+
ON CREATE SET p.created_at = $now,
|
|
1732
|
+
p.first_seen = $occurred_at,
|
|
1733
|
+
p.last_seen = $occurred_at
|
|
1734
|
+
ON MATCH SET p.last_seen = CASE
|
|
1735
|
+
WHEN $occurred_at > coalesce(p.last_seen, '')
|
|
1736
|
+
THEN $occurred_at
|
|
1737
|
+
ELSE p.last_seen END
|
|
1738
|
+
WITH p
|
|
1739
|
+
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
1740
|
+
MERGE (p)-[r:COMMUNICATED]->(c)
|
|
1741
|
+
ON CREATE SET r.channel = $channel,
|
|
1742
|
+
r.direction = $direction,
|
|
1743
|
+
r.occurred_at = $occurred_at,
|
|
1744
|
+
r.weight = 1.0
|
|
1745
|
+
""",
|
|
1746
|
+
arena=arena, name=cname, cid=n["id"],
|
|
1747
|
+
channel=channel, direction=direction,
|
|
1748
|
+
occurred_at=occurred_at, now=now_iso,
|
|
1749
|
+
)
|
|
1750
|
+
l3_entities += 1
|
|
1751
|
+
# Link name→email node so the relationships query
|
|
1752
|
+
# can resolve either alias to the same person.
|
|
1753
|
+
if person_email_node:
|
|
1754
|
+
session.run(
|
|
1755
|
+
"""
|
|
1756
|
+
MATCH (n:Person {arena: $arena, name: $name})
|
|
1757
|
+
MATCH (e:Person {arena: $arena, email: $email})
|
|
1758
|
+
MERGE (n)-[:KNOWN_AS]->(e)
|
|
1759
|
+
""",
|
|
1760
|
+
arena=arena, name=cname, email=person_email_node,
|
|
1761
|
+
)
|
|
1515
1762
|
driver.close()
|
|
1516
1763
|
except Exception as e:
|
|
1517
1764
|
log.error(f"L3 KG write failed: {e}")
|
|
@@ -1530,16 +1777,43 @@ async def index_internal_batch(req: IndexInternalBatchRequest) -> dict:
|
|
|
1530
1777
|
|
|
1531
1778
|
@app.post("/forget-internal")
|
|
1532
1779
|
async def forget_internal(request: Request) -> dict:
|
|
1533
|
-
"""Wipe L0 + L4-qmd + L3.
|
|
1780
|
+
"""Wipe L0 + L4-qmd + L3.
|
|
1781
|
+
|
|
1782
|
+
Two modes:
|
|
1783
|
+
- Tenant-scoped (default, safe): pass `{"arena": "<tenant>"}` and
|
|
1784
|
+
only that tenant's rows are deleted. Used by tenant offboarding
|
|
1785
|
+
and by tests.
|
|
1786
|
+
- Global (unsafe): the bench harness needs to wipe everything
|
|
1787
|
+
between runs. Require an explicit `{"confirm": "GLOBAL_WIPE"}`
|
|
1788
|
+
flag — without it we refuse rather than nuke shared infra.
|
|
1789
|
+
|
|
1790
|
+
Pre-fix this endpoint silently ignored the arena param and always
|
|
1791
|
+
deleted globally. That meant a tenant offboarding script — or any
|
|
1792
|
+
caller that read the param-name and trusted it — would erase every
|
|
1793
|
+
other tenant's L3 graph and wipe the shared sqlite stores. Hence
|
|
1794
|
+
the explicit confirm gate now.
|
|
1795
|
+
"""
|
|
1534
1796
|
try:
|
|
1535
1797
|
body = await request.json()
|
|
1536
1798
|
except Exception:
|
|
1537
1799
|
body = {}
|
|
1538
|
-
arena = body.get("arena")
|
|
1800
|
+
arena = body.get("arena")
|
|
1801
|
+
confirm = body.get("confirm")
|
|
1802
|
+
if not arena and confirm != "GLOBAL_WIPE":
|
|
1803
|
+
raise HTTPException(
|
|
1804
|
+
status_code=400,
|
|
1805
|
+
detail="forget-internal requires either 'arena' (tenant-scoped) "
|
|
1806
|
+
"or 'confirm: GLOBAL_WIPE' (unsafe, deletes everything).",
|
|
1807
|
+
)
|
|
1539
1808
|
deleted = {"l0": 0, "l4_qmd": 0, "l3_entities": 0, "l3_chunks": 0}
|
|
1809
|
+
|
|
1810
|
+
# ---- L0 BM25 (sqlite) ----------------------------------------------
|
|
1811
|
+
# The L0 chunks table doesn't carry an arena column today, so we
|
|
1812
|
+
# only support GLOBAL_WIPE here. Tenant-scoped L0 deletes are a
|
|
1813
|
+
# follow-up (needs schema migration to add `arena` to L0 rows).
|
|
1540
1814
|
try:
|
|
1541
1815
|
l0_db = Path(os.environ.get("PME_MEMORY_DB", str(L0_MEMORY_DB)))
|
|
1542
|
-
if l0_db.exists():
|
|
1816
|
+
if l0_db.exists() and confirm == "GLOBAL_WIPE":
|
|
1543
1817
|
conn = sqlite3.connect(str(l0_db), timeout=5)
|
|
1544
1818
|
cur = conn.execute("DELETE FROM chunks")
|
|
1545
1819
|
deleted["l0"] = cur.rowcount
|
|
@@ -1550,25 +1824,310 @@ async def forget_internal(request: Request) -> dict:
|
|
|
1550
1824
|
conn.commit(); conn.close()
|
|
1551
1825
|
except Exception as e:
|
|
1552
1826
|
log.error(f"L0 forget failed: {e}")
|
|
1827
|
+
|
|
1828
|
+
# ---- L4 sqlite-vec --------------------------------------------------
|
|
1829
|
+
# Same situation as L0 — no per-arena column on chunks. Global only
|
|
1830
|
+
# for now; tenant-scoped delete is a follow-up.
|
|
1553
1831
|
try:
|
|
1554
|
-
if Path(QMD_DB_PATH).exists():
|
|
1832
|
+
if Path(QMD_DB_PATH).exists() and confirm == "GLOBAL_WIPE":
|
|
1555
1833
|
conn = sqlite3.connect(QMD_DB_PATH, timeout=5)
|
|
1556
1834
|
cur = conn.execute("DELETE FROM chunks")
|
|
1557
1835
|
deleted["l4_qmd"] = cur.rowcount
|
|
1558
1836
|
conn.commit(); conn.close()
|
|
1559
1837
|
except Exception as e:
|
|
1560
1838
|
log.error(f"L4 QMD forget failed: {e}")
|
|
1839
|
+
|
|
1840
|
+
# ---- L3 Neo4j -------------------------------------------------------
|
|
1841
|
+
# Neo4j chunks AND entities both carry arena now, so tenant-scoped
|
|
1842
|
+
# delete works correctly here even if L0/L4 still need a migration.
|
|
1561
1843
|
try:
|
|
1562
1844
|
driver = GraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH)
|
|
1563
1845
|
with driver.session() as session:
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1846
|
+
if arena:
|
|
1847
|
+
r1 = session.run(
|
|
1848
|
+
"MATCH (c:Chunk {arena: $arena}) DETACH DELETE c RETURN count(c) AS n",
|
|
1849
|
+
arena=arena,
|
|
1850
|
+
)
|
|
1851
|
+
deleted["l3_chunks"] = r1.single()["n"]
|
|
1852
|
+
r2 = session.run(
|
|
1853
|
+
"MATCH (e:Entity {arena: $arena}) DETACH DELETE e RETURN count(e) AS n",
|
|
1854
|
+
arena=arena,
|
|
1855
|
+
)
|
|
1856
|
+
deleted["l3_entities"] = r2.single()["n"]
|
|
1857
|
+
else: # confirm == "GLOBAL_WIPE", validated above
|
|
1858
|
+
r1 = session.run("MATCH (c:Chunk) DETACH DELETE c RETURN count(c) AS n")
|
|
1859
|
+
deleted["l3_chunks"] = r1.single()["n"]
|
|
1860
|
+
r2 = session.run("MATCH (e:Entity) DETACH DELETE e RETURN count(e) AS n")
|
|
1861
|
+
deleted["l3_entities"] = r2.single()["n"]
|
|
1568
1862
|
driver.close()
|
|
1569
1863
|
except Exception as e:
|
|
1570
1864
|
log.error(f"L3 forget failed: {e}")
|
|
1571
|
-
return {"status": "ok", "deleted": deleted, "arena": arena}
|
|
1865
|
+
return {"status": "ok", "deleted": deleted, "arena": arena, "global_wipe": confirm == "GLOBAL_WIPE"}
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
class AggregateInternalRequest(BaseModel):
|
|
1869
|
+
"""Aggregate (:Person)-[:COMMUNICATED]->(:Chunk) edges by group_by keys.
|
|
1870
|
+
|
|
1871
|
+
The relationships UI pre-#28 went through a metadata-filtered
|
|
1872
|
+
/search and grouped client-side, capped at the engine over-fetch
|
|
1873
|
+
ceiling. With typed-Person nodes in L3 we can run a single Cypher
|
|
1874
|
+
aggregate that scales to any volume — no over-fetch, no cap.
|
|
1875
|
+
|
|
1876
|
+
Required: arena (the tenant scope) plus enough metadata to identify
|
|
1877
|
+
the Person node we're rolling up. Today that means contact_email
|
|
1878
|
+
(the canonical Person key), but the shape leaves room for future
|
|
1879
|
+
Person identifiers (e.g. slack_user_id, hubspot_contact_id) without
|
|
1880
|
+
a wire change.
|
|
1881
|
+
"""
|
|
1882
|
+
|
|
1883
|
+
arena: str
|
|
1884
|
+
contact_email: Optional[str] = None
|
|
1885
|
+
contact_name: Optional[str] = None
|
|
1886
|
+
# Group by these properties on the COMMUNICATED edge. Only the
|
|
1887
|
+
# relationship-page-supported keys are honoured; unknown keys are
|
|
1888
|
+
# silently dropped (no useful aggregate shape for them).
|
|
1889
|
+
group_by: List[str] = ["channel"]
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
class AggregateBucket(BaseModel):
|
|
1893
|
+
keys: Dict[str, Optional[str]]
|
|
1894
|
+
count: int
|
|
1895
|
+
inbound: int
|
|
1896
|
+
outbound: int
|
|
1897
|
+
last_seen: Optional[str] = None
|
|
1898
|
+
first_seen: Optional[str] = None
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
class AggregateInternalResponse(BaseModel):
|
|
1902
|
+
arena: str
|
|
1903
|
+
total: int
|
|
1904
|
+
last_seen: Optional[str] = None
|
|
1905
|
+
buckets: List[AggregateBucket]
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
# Whitelist of group_by keys we know how to project. Cypher
|
|
1909
|
+
# parameter-substitution doesn't work on property names, so we
|
|
1910
|
+
# template the keys into the query — this whitelist is the safety
|
|
1911
|
+
# rail that keeps the templating from accepting arbitrary input.
|
|
1912
|
+
_AGGREGATE_GROUP_BY_KEYS = {"channel", "direction"}
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
@app.post("/aggregate-internal", response_model=AggregateInternalResponse)
|
|
1916
|
+
async def aggregate_internal(req: AggregateInternalRequest) -> AggregateInternalResponse:
|
|
1917
|
+
"""Aggregate Person→Chunk COMMUNICATED edges by edge properties.
|
|
1918
|
+
|
|
1919
|
+
Returns one bucket per (group_by key combination) with count,
|
|
1920
|
+
inbound/outbound split, and time bounds. The Person match is
|
|
1921
|
+
arena-scoped (mandatory) and additionally filtered by whatever
|
|
1922
|
+
Person identifier the caller supplies.
|
|
1923
|
+
|
|
1924
|
+
No fallback to chunk scanning — if the typed-Person nodes don't
|
|
1925
|
+
exist for this contact, the response is `total: 0` with no
|
|
1926
|
+
buckets, and the caller falls back to whatever it had before.
|
|
1927
|
+
That's intentional: the over-fetch path is in TES (#273); this
|
|
1928
|
+
endpoint is the scaling answer that doesn't have one.
|
|
1929
|
+
"""
|
|
1930
|
+
arena = (req.arena or "").strip()
|
|
1931
|
+
if not arena:
|
|
1932
|
+
raise HTTPException(status_code=400, detail="arena is required")
|
|
1933
|
+
contact_email = (req.contact_email or "").strip().lower()
|
|
1934
|
+
contact_name = (req.contact_name or "").strip()
|
|
1935
|
+
if not contact_email and not contact_name:
|
|
1936
|
+
raise HTTPException(
|
|
1937
|
+
status_code=400,
|
|
1938
|
+
detail="provide contact_email and/or contact_name to identify the Person",
|
|
1939
|
+
)
|
|
1940
|
+
|
|
1941
|
+
# Filter group_by to the supported keys; preserve order so a caller
|
|
1942
|
+
# asking for ["direction", "channel"] gets buckets keyed in that
|
|
1943
|
+
# order on the response.
|
|
1944
|
+
seen: set[str] = set()
|
|
1945
|
+
safe_group_by: List[str] = []
|
|
1946
|
+
for k in req.group_by or []:
|
|
1947
|
+
if k in _AGGREGATE_GROUP_BY_KEYS and k not in seen:
|
|
1948
|
+
seen.add(k)
|
|
1949
|
+
safe_group_by.append(k)
|
|
1950
|
+
|
|
1951
|
+
try:
|
|
1952
|
+
driver = GraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH)
|
|
1953
|
+
except Exception as e:
|
|
1954
|
+
raise HTTPException(status_code=500, detail=f"neo4j connect: {e}")
|
|
1955
|
+
|
|
1956
|
+
try:
|
|
1957
|
+
with driver.session() as session:
|
|
1958
|
+
# Fast path: read from the ChannelStat denormalisation
|
|
1959
|
+
# whenever the caller has an email and is grouping by
|
|
1960
|
+
# channel. ChannelStats are written by /index-internal-batch
|
|
1961
|
+
# on every store with contact_email metadata, so any tenant
|
|
1962
|
+
# with new ingest gets O(channels) reads instead of an
|
|
1963
|
+
# edge walk over every COMMUNICATED relationship.
|
|
1964
|
+
#
|
|
1965
|
+
# Conditions for the fast path:
|
|
1966
|
+
# - contact_email set (stats are email-keyed; name-only
|
|
1967
|
+
# contacts fall through to the edge walk).
|
|
1968
|
+
# - group_by is exactly ["channel"] OR no group_by (single
|
|
1969
|
+
# bucket). Other group_by combinations (e.g. with
|
|
1970
|
+
# direction) need the edge granularity the stats
|
|
1971
|
+
# don't carry.
|
|
1972
|
+
fast_path_eligible = bool(contact_email) and (
|
|
1973
|
+
not safe_group_by or safe_group_by == ["channel"]
|
|
1974
|
+
)
|
|
1975
|
+
if fast_path_eligible:
|
|
1976
|
+
stats_rows = list(session.run(
|
|
1977
|
+
"MATCH (s:ChannelStat {arena: $arena, person_email: $email})\n"
|
|
1978
|
+
"RETURN s.channel AS channel,\n"
|
|
1979
|
+
" s.count AS count,\n"
|
|
1980
|
+
" s.inbound AS inbound,\n"
|
|
1981
|
+
" s.outbound AS outbound,\n"
|
|
1982
|
+
" s.last_seen AS last_seen,\n"
|
|
1983
|
+
" s.first_seen AS first_seen\n"
|
|
1984
|
+
"ORDER BY s.count DESC\n",
|
|
1985
|
+
arena=arena, email=contact_email,
|
|
1986
|
+
))
|
|
1987
|
+
if stats_rows:
|
|
1988
|
+
# Build buckets directly. When group_by=[] we
|
|
1989
|
+
# collapse to a single overall bucket; otherwise
|
|
1990
|
+
# one bucket per channel.
|
|
1991
|
+
if safe_group_by == ["channel"]:
|
|
1992
|
+
buckets = [
|
|
1993
|
+
AggregateBucket(
|
|
1994
|
+
keys={"channel": rec["channel"]},
|
|
1995
|
+
count=int(rec["count"] or 0),
|
|
1996
|
+
inbound=int(rec["inbound"] or 0),
|
|
1997
|
+
outbound=int(rec["outbound"] or 0),
|
|
1998
|
+
last_seen=str(rec["last_seen"]) if rec["last_seen"] else None,
|
|
1999
|
+
first_seen=str(rec["first_seen"]) if rec["first_seen"] else None,
|
|
2000
|
+
)
|
|
2001
|
+
for rec in stats_rows
|
|
2002
|
+
]
|
|
2003
|
+
total = sum(b.count for b in buckets)
|
|
2004
|
+
latest = None
|
|
2005
|
+
for b in buckets:
|
|
2006
|
+
if b.last_seen and (latest is None or b.last_seen > latest):
|
|
2007
|
+
latest = b.last_seen
|
|
2008
|
+
else:
|
|
2009
|
+
# Single global bucket — sum across channels.
|
|
2010
|
+
total = sum(int(rec["count"] or 0) for rec in stats_rows)
|
|
2011
|
+
inbound = sum(int(rec["inbound"] or 0) for rec in stats_rows)
|
|
2012
|
+
outbound = sum(int(rec["outbound"] or 0) for rec in stats_rows)
|
|
2013
|
+
last_seens = [rec["last_seen"] for rec in stats_rows if rec["last_seen"]]
|
|
2014
|
+
first_seens = [rec["first_seen"] for rec in stats_rows if rec["first_seen"]]
|
|
2015
|
+
latest = max((str(x) for x in last_seens), default=None)
|
|
2016
|
+
earliest = min((str(x) for x in first_seens), default=None)
|
|
2017
|
+
buckets = [AggregateBucket(
|
|
2018
|
+
keys={},
|
|
2019
|
+
count=total,
|
|
2020
|
+
inbound=inbound,
|
|
2021
|
+
outbound=outbound,
|
|
2022
|
+
last_seen=latest,
|
|
2023
|
+
first_seen=earliest,
|
|
2024
|
+
)]
|
|
2025
|
+
return AggregateInternalResponse(
|
|
2026
|
+
arena=arena,
|
|
2027
|
+
total=total,
|
|
2028
|
+
last_seen=latest,
|
|
2029
|
+
buckets=buckets,
|
|
2030
|
+
)
|
|
2031
|
+
# else: stats absent (older tenant pre-rollup, or this
|
|
2032
|
+
# contact has no email-keyed Person yet) → fall through
|
|
2033
|
+
# to the edge-walk path.
|
|
2034
|
+
|
|
2035
|
+
# Edge-walk path (original Cypher). Used when:
|
|
2036
|
+
# - caller has only contact_name (no email-keyed stats)
|
|
2037
|
+
# - caller asked for a group_by we don't denormalise (e.g.
|
|
2038
|
+
# direction)
|
|
2039
|
+
# - tenant predates the rollup writer (no stats nodes yet)
|
|
2040
|
+
# Both paths return the same response shape, so callers
|
|
2041
|
+
# don't need to know which served them.
|
|
2042
|
+
#
|
|
2043
|
+
# Build the Person match. We want either email-keyed or
|
|
2044
|
+
# name-keyed Person nodes; when both are supplied we OR
|
|
2045
|
+
# them so a caller can hit either alias. Both branches
|
|
2046
|
+
# arena-scope the Person.
|
|
2047
|
+
person_clauses: List[str] = []
|
|
2048
|
+
params: Dict[str, Any] = {"arena": arena}
|
|
2049
|
+
if contact_email:
|
|
2050
|
+
person_clauses.append("(p.email = $contact_email)")
|
|
2051
|
+
params["contact_email"] = contact_email
|
|
2052
|
+
if contact_name:
|
|
2053
|
+
person_clauses.append("(p.name = $contact_name)")
|
|
2054
|
+
params["contact_name"] = contact_name
|
|
2055
|
+
person_filter = " OR ".join(person_clauses)
|
|
2056
|
+
|
|
2057
|
+
# group_by keys go into the WITH clause. Cypher doesn't
|
|
2058
|
+
# support property-name parameters, so we template them
|
|
2059
|
+
# in — the whitelist above is the safety rail against
|
|
2060
|
+
# injection. Built up separately rather than via f-string
|
|
2061
|
+
# so the static MATCH clause stays a plain string and the
|
|
2062
|
+
# arena-safety lint can parse it cleanly.
|
|
2063
|
+
with_keys = ", ".join(f"r.{k} AS {k}" for k in safe_group_by)
|
|
2064
|
+
return_keys = ", ".join(safe_group_by)
|
|
2065
|
+
|
|
2066
|
+
# Static base — arena scope on both Person and Chunk so the
|
|
2067
|
+
# lint catches any future copy-paste that forgets it.
|
|
2068
|
+
base = (
|
|
2069
|
+
"MATCH (p:Person {arena: $arena})-[r:COMMUNICATED]->(c:Chunk {arena: $arena})\n"
|
|
2070
|
+
"WHERE " + person_filter + "\n"
|
|
2071
|
+
)
|
|
2072
|
+
agg_select = (
|
|
2073
|
+
"count(*) AS count,\n"
|
|
2074
|
+
"sum(CASE WHEN _direction = 'inbound' THEN 1 ELSE 0 END) AS inbound,\n"
|
|
2075
|
+
"sum(CASE WHEN _direction = 'outbound' THEN 1 ELSE 0 END) AS outbound,\n"
|
|
2076
|
+
"max(_occurred_at) AS last_seen,\n"
|
|
2077
|
+
"min(_occurred_at) AS first_seen\n"
|
|
2078
|
+
)
|
|
2079
|
+
|
|
2080
|
+
if safe_group_by:
|
|
2081
|
+
cypher = (
|
|
2082
|
+
base
|
|
2083
|
+
+ f"WITH {with_keys}, r.direction AS _direction, r.occurred_at AS _occurred_at\n"
|
|
2084
|
+
+ f"RETURN {return_keys},\n"
|
|
2085
|
+
+ agg_select
|
|
2086
|
+
+ "ORDER BY count DESC\n"
|
|
2087
|
+
)
|
|
2088
|
+
else:
|
|
2089
|
+
# No group_by → one global bucket (just the overall
|
|
2090
|
+
# totals for this Person). Useful for "total comms
|
|
2091
|
+
# with X" without per-channel breakdown.
|
|
2092
|
+
cypher = (
|
|
2093
|
+
base
|
|
2094
|
+
+ "WITH r.direction AS _direction, r.occurred_at AS _occurred_at\n"
|
|
2095
|
+
+ "RETURN " + agg_select
|
|
2096
|
+
)
|
|
2097
|
+
|
|
2098
|
+
buckets: List[AggregateBucket] = []
|
|
2099
|
+
total = 0
|
|
2100
|
+
latest: Optional[str] = None
|
|
2101
|
+
for rec in session.run(cypher, **params):
|
|
2102
|
+
count = int(rec["count"] or 0)
|
|
2103
|
+
total += count
|
|
2104
|
+
last_seen = rec["last_seen"]
|
|
2105
|
+
if last_seen and (latest is None or str(last_seen) > latest):
|
|
2106
|
+
latest = str(last_seen)
|
|
2107
|
+
bucket_keys: Dict[str, Optional[str]] = (
|
|
2108
|
+
{k: rec[k] for k in safe_group_by} if safe_group_by else {}
|
|
2109
|
+
)
|
|
2110
|
+
buckets.append(AggregateBucket(
|
|
2111
|
+
keys=bucket_keys,
|
|
2112
|
+
count=count,
|
|
2113
|
+
inbound=int(rec["inbound"] or 0),
|
|
2114
|
+
outbound=int(rec["outbound"] or 0),
|
|
2115
|
+
last_seen=str(last_seen) if last_seen else None,
|
|
2116
|
+
first_seen=str(rec["first_seen"]) if rec["first_seen"] else None,
|
|
2117
|
+
))
|
|
2118
|
+
return AggregateInternalResponse(
|
|
2119
|
+
arena=arena,
|
|
2120
|
+
total=total,
|
|
2121
|
+
last_seen=latest,
|
|
2122
|
+
buckets=buckets,
|
|
2123
|
+
)
|
|
2124
|
+
except HTTPException:
|
|
2125
|
+
raise
|
|
2126
|
+
except Exception as e:
|
|
2127
|
+
log.error(f"aggregate-internal failed: {e}")
|
|
2128
|
+
raise HTTPException(status_code=500, detail=f"aggregate failed: {e}")
|
|
2129
|
+
finally:
|
|
2130
|
+
driver.close()
|
|
1572
2131
|
|
|
1573
2132
|
|
|
1574
2133
|
@app.get("/index-internal-stats")
|