@pentatonic-ai/ai-agent-sdk 0.7.4 → 0.7.5
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-engine/compat/server.py +110 -36
- package/packages/memory-engine/docker-compose.test.yml +60 -0
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +22 -12
- package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +14 -14
- package/packages/memory-engine/engine/services/l6/l6-document-store.py +20 -21
- package/packages/memory-engine/tests/e2e_arena.sh +144 -0
- package/packages/memory-engine/tests/embed_stub/Dockerfile +13 -0
- package/packages/memory-engine/tests/embed_stub/server.py +80 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pentatonic-ai/ai-agent-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -55,6 +55,13 @@ L5_MILVUS_URL = os.environ.get("L5_MILVUS_URL", "http://l5:8035")
|
|
|
55
55
|
L6_DOC_URL = os.environ.get("L6_DOC_URL", "http://l6:8037")
|
|
56
56
|
NV_EMBED_URL = os.environ.get("NV_EMBED_URL", "http://nv-embed:8041/v1/embeddings")
|
|
57
57
|
|
|
58
|
+
# Neo4j has no /health endpoint, so the shim probes the HTTP transactional
|
|
59
|
+
# API with a trivial RETURN 1 — that confirms Neo4j is actually answering
|
|
60
|
+
# Cypher, not just serving HTTP. Auth shape is the same as L2 / docker-compose:
|
|
61
|
+
# "user/pass" string. Default matches the local-dev compose default.
|
|
62
|
+
NEO4J_AUTH = os.environ.get("NEO4J_AUTH", "neo4j/local-dev-pw")
|
|
63
|
+
NEO4J_DB = os.environ.get("NEO4J_DB", "neo4j")
|
|
64
|
+
|
|
58
65
|
PORT = int(os.environ.get("PORT", "8099"))
|
|
59
66
|
CLIENT_ID = os.environ.get("CLIENT_ID", "default")
|
|
60
67
|
|
|
@@ -299,9 +306,82 @@ app = FastAPI(
|
|
|
299
306
|
)
|
|
300
307
|
|
|
301
308
|
|
|
309
|
+
def _interpret_body_status(body: Any) -> str | None:
|
|
310
|
+
"""Pull a layer's self-reported status out of its /health body.
|
|
311
|
+
|
|
312
|
+
Layers don't all use the same vocabulary — L4 says "ok"/"degraded",
|
|
313
|
+
L2 says "healthy"/"unavailable", some return nothing. Normalize to
|
|
314
|
+
"ok" or a short failure reason; None means the body didn't carry
|
|
315
|
+
a status field, in which case the HTTP code is the source of truth.
|
|
316
|
+
"""
|
|
317
|
+
if not isinstance(body, dict):
|
|
318
|
+
return None
|
|
319
|
+
raw = body.get("status")
|
|
320
|
+
if raw is None:
|
|
321
|
+
return None
|
|
322
|
+
s = str(raw).lower()
|
|
323
|
+
if s in ("ok", "healthy"):
|
|
324
|
+
return "ok"
|
|
325
|
+
err = body.get("error") or body.get("reason") or ""
|
|
326
|
+
return f"{s}: {str(err)[:80]}" if err else s
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
async def _probe(url: str) -> str:
|
|
330
|
+
"""Probe a layer /health endpoint and return a single-string verdict
|
|
331
|
+
that surfaces both transport-level failure and self-reported status."""
|
|
332
|
+
try:
|
|
333
|
+
r = await _client().get(url, timeout=3.0)
|
|
334
|
+
except Exception as exc:
|
|
335
|
+
return f"unreachable: {type(exc).__name__}"
|
|
336
|
+
if r.status_code != 200:
|
|
337
|
+
return f"http {r.status_code}"
|
|
338
|
+
try:
|
|
339
|
+
body_status = _interpret_body_status(r.json())
|
|
340
|
+
except Exception:
|
|
341
|
+
body_status = None
|
|
342
|
+
return body_status or "ok"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def _probe_l3() -> str:
|
|
346
|
+
"""Real Neo4j probe — POST a trivial Cypher via the HTTP transactional
|
|
347
|
+
API and require a 200 response. Confirms Neo4j is actually answering
|
|
348
|
+
queries, not just serving the Browser HTML on :7474.
|
|
349
|
+
"""
|
|
350
|
+
user, _, password = NEO4J_AUTH.partition("/")
|
|
351
|
+
url = f"{L3_KG_URL}/db/{NEO4J_DB}/tx/commit"
|
|
352
|
+
try:
|
|
353
|
+
r = await _client().post(
|
|
354
|
+
url,
|
|
355
|
+
json={"statements": [{"statement": "RETURN 1"}]},
|
|
356
|
+
auth=(user, password),
|
|
357
|
+
timeout=3.0,
|
|
358
|
+
)
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
return f"unreachable: {type(exc).__name__}"
|
|
361
|
+
if r.status_code != 200:
|
|
362
|
+
return f"http {r.status_code}"
|
|
363
|
+
try:
|
|
364
|
+
body = r.json()
|
|
365
|
+
# Neo4j tx/commit returns {"results":[...], "errors":[...]}.
|
|
366
|
+
# Any errors here means the DB is up but rejecting queries.
|
|
367
|
+
errs = body.get("errors") or []
|
|
368
|
+
if errs:
|
|
369
|
+
return f"cypher error: {str(errs[0])[:80]}"
|
|
370
|
+
except Exception:
|
|
371
|
+
return "non-json response"
|
|
372
|
+
return "ok"
|
|
373
|
+
|
|
374
|
+
|
|
302
375
|
@app.get("/health")
|
|
303
376
|
async def health():
|
|
304
|
-
"""Aggregate health across all 7 layers.
|
|
377
|
+
"""Aggregate health across all 7 layers.
|
|
378
|
+
|
|
379
|
+
Each layer's verdict is honest: it reflects whether the layer can
|
|
380
|
+
actually do its job, not just whether its HTTP server answers. The
|
|
381
|
+
shim reads the layer's body.status (when present) and degrades when
|
|
382
|
+
the layer self-reports a problem. L3 uses a real Cypher probe since
|
|
383
|
+
Neo4j has no /health route.
|
|
384
|
+
"""
|
|
305
385
|
out = {
|
|
306
386
|
"status": "ok",
|
|
307
387
|
"client": CLIENT_ID,
|
|
@@ -309,49 +389,43 @@ async def health():
|
|
|
309
389
|
"engine": "pentatonic-memory-engine",
|
|
310
390
|
"layers": {},
|
|
311
391
|
}
|
|
312
|
-
#
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
try:
|
|
326
|
-
r = await _client().get(url, timeout=3.0)
|
|
327
|
-
out["layers"][name] = "ok" if r.status_code == 200 else f"http {r.status_code}"
|
|
328
|
-
if r.status_code != 200:
|
|
329
|
-
failures += 1
|
|
330
|
-
except Exception:
|
|
331
|
-
out["layers"][name] = "unreachable"
|
|
332
|
-
failures += 1
|
|
392
|
+
# NV-Embed exposes /health alongside /v1/embeddings.
|
|
393
|
+
nv_embed_health = NV_EMBED_URL.replace("/v1/embeddings", "/health")
|
|
394
|
+
|
|
395
|
+
import asyncio
|
|
396
|
+
l2_v, l4_v, l5_v, l6_v, nv_v, l3_v = await asyncio.gather(
|
|
397
|
+
_probe(f"{L2_PROXY_URL}/health"),
|
|
398
|
+
_probe(f"{L4_VEC_URL}/health"),
|
|
399
|
+
_probe(f"{L5_MILVUS_URL}/health"),
|
|
400
|
+
_probe(f"{L6_DOC_URL}/health"),
|
|
401
|
+
_probe(nv_embed_health),
|
|
402
|
+
_probe_l3(),
|
|
403
|
+
)
|
|
404
|
+
|
|
333
405
|
# L0 BM25 (FTS5) and L1 (always-loaded core files) are both in-process
|
|
334
|
-
# inside the L2 proxy. They have no separate
|
|
335
|
-
#
|
|
336
|
-
|
|
337
|
-
l2_ok = raw_layers.get("l2") == "ok"
|
|
406
|
+
# inside the L2 proxy. They have no separate runtime; if L2 is healthy,
|
|
407
|
+
# both layers are usable. Tie their status to L2.
|
|
408
|
+
l2_ok = l2_v == "ok"
|
|
338
409
|
out["layers"] = {
|
|
339
|
-
"l0": "ok" if l2_ok else
|
|
340
|
-
"l1": "ok" if l2_ok else
|
|
341
|
-
"l2":
|
|
342
|
-
"l3":
|
|
343
|
-
"l4":
|
|
344
|
-
"l5":
|
|
345
|
-
"l6":
|
|
346
|
-
"nv_embed":
|
|
410
|
+
"l0": "ok" if l2_ok else l2_v,
|
|
411
|
+
"l1": "ok" if l2_ok else l2_v,
|
|
412
|
+
"l2": l2_v,
|
|
413
|
+
"l3": l3_v,
|
|
414
|
+
"l4": l4_v,
|
|
415
|
+
"l5": l5_v,
|
|
416
|
+
"l6": l6_v,
|
|
417
|
+
"nv_embed": nv_v,
|
|
347
418
|
}
|
|
419
|
+
failures = sum(1 for v in out["layers"].values() if v != "ok")
|
|
348
420
|
if failures:
|
|
349
421
|
out["status"] = "degraded" if failures < 3 else "down"
|
|
350
|
-
|
|
422
|
+
|
|
423
|
+
# Memory count: query L6 doc-store as authoritative.
|
|
351
424
|
try:
|
|
352
425
|
r = await _client().get(f"{L6_DOC_URL}/stats", timeout=3.0)
|
|
353
426
|
if r.status_code == 200:
|
|
354
|
-
|
|
427
|
+
stats = r.json()
|
|
428
|
+
out["memories"] = stats.get("total_chunks") or stats.get("fts_chunks") or 0
|
|
355
429
|
except Exception:
|
|
356
430
|
out["memories"] = None
|
|
357
431
|
return out
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# docker-compose.test.yml — overlay for hermetic CI runs.
|
|
2
|
+
#
|
|
3
|
+
# Replaces the nv-embed GPU service with a deterministic embedding
|
|
4
|
+
# stub that mimics both the OpenAI /v1/embeddings shape and the
|
|
5
|
+
# lambda-gateway /v1/embed shape. Lets CI exercise every layer's
|
|
6
|
+
# vector path without an actual model.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# docker compose -f docker-compose.yml -f docker-compose.test.yml \
|
|
10
|
+
# up -d --wait l3 l4 l5 l6 l2 compat embed-stub
|
|
11
|
+
#
|
|
12
|
+
# The base nv-embed service is intentionally NOT started in CI
|
|
13
|
+
# (requires a GPU). l4/l5/l6 are pointed at embed-stub via env.
|
|
14
|
+
|
|
15
|
+
services:
|
|
16
|
+
embed-stub:
|
|
17
|
+
build:
|
|
18
|
+
context: ./tests/embed_stub
|
|
19
|
+
dockerfile: Dockerfile
|
|
20
|
+
container_name: pme-embed-stub
|
|
21
|
+
networks:
|
|
22
|
+
- engine-net
|
|
23
|
+
environment:
|
|
24
|
+
EMBED_DIM: "4096"
|
|
25
|
+
healthcheck:
|
|
26
|
+
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8041/health',timeout=3)"]
|
|
27
|
+
interval: 5s
|
|
28
|
+
timeout: 3s
|
|
29
|
+
retries: 20
|
|
30
|
+
start_period: 5s
|
|
31
|
+
|
|
32
|
+
l4:
|
|
33
|
+
environment:
|
|
34
|
+
L4_NV_EMBED_URL: http://embed-stub:8041/v1/embeddings
|
|
35
|
+
L4_EMBED_API_KEY: ""
|
|
36
|
+
|
|
37
|
+
l5:
|
|
38
|
+
environment:
|
|
39
|
+
L5_NV_EMBED_URL: http://embed-stub:8041/v1/embeddings
|
|
40
|
+
L5_EMBED_API_KEY: ""
|
|
41
|
+
|
|
42
|
+
l6:
|
|
43
|
+
environment:
|
|
44
|
+
L6_NV_EMBED_URL: http://embed-stub:8041/v1/embeddings
|
|
45
|
+
L6_EMBED_API_KEY: ""
|
|
46
|
+
|
|
47
|
+
l2:
|
|
48
|
+
environment:
|
|
49
|
+
PME_NV_EMBED_URL: http://embed-stub:8041/v1/embeddings
|
|
50
|
+
|
|
51
|
+
compat:
|
|
52
|
+
environment:
|
|
53
|
+
NV_EMBED_URL: http://embed-stub:8041/v1/embeddings
|
|
54
|
+
depends_on:
|
|
55
|
+
embed-stub:
|
|
56
|
+
condition: service_healthy
|
|
57
|
+
l2: { condition: service_started }
|
|
58
|
+
l4: { condition: service_started }
|
|
59
|
+
l5: { condition: service_started }
|
|
60
|
+
l6: { condition: service_started }
|
|
@@ -1196,8 +1196,17 @@ def _check_l6_health() -> bool:
|
|
|
1196
1196
|
|
|
1197
1197
|
@app.get("/health")
|
|
1198
1198
|
async def health() -> dict:
|
|
1199
|
-
"""System health check.
|
|
1199
|
+
"""System health check.
|
|
1200
|
+
|
|
1201
|
+
Reports "ok" iff every layer L2 directly owns is healthy: L0 BM25
|
|
1202
|
+
(SQLite FTS5 file), L4 QMD vector store (sqlite file), and the
|
|
1203
|
+
Neo4j connection. L5/L6 reachability is reported informationally
|
|
1204
|
+
only — the compat shim probes them directly. Ollama is no longer
|
|
1205
|
+
a hard dependency anywhere; the engine uses the configured
|
|
1206
|
+
NV_EMBED_URL via _embed_post helpers in each layer.
|
|
1207
|
+
"""
|
|
1200
1208
|
qmd_healthy = os.path.exists(QMD_DB_PATH)
|
|
1209
|
+
l0_healthy = L0_MEMORY_DB.exists()
|
|
1201
1210
|
|
|
1202
1211
|
neo4j_healthy = False
|
|
1203
1212
|
try:
|
|
@@ -1209,25 +1218,26 @@ async def health() -> dict:
|
|
|
1209
1218
|
except Exception as e:
|
|
1210
1219
|
logging.debug(f"Suppressed: {e}")
|
|
1211
1220
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1221
|
+
l5_reachable = _check_l5_health()
|
|
1222
|
+
l6_reachable = _check_l6_health()
|
|
1223
|
+
|
|
1224
|
+
# Top-level status: degrade only on layers L2 is the sole gatekeeper for.
|
|
1225
|
+
# L5/L6 are independent services probed by the compat shim.
|
|
1226
|
+
must_be_ok = [l0_healthy, qmd_healthy, neo4j_healthy]
|
|
1227
|
+
overall = "ok" if all(must_be_ok) else "degraded"
|
|
1218
1228
|
|
|
1219
1229
|
return {
|
|
1230
|
+
"status": overall,
|
|
1220
1231
|
"proxy": "healthy",
|
|
1221
1232
|
"architecture": "sequential-hybridrag-proper-layers",
|
|
1222
1233
|
"layers": {
|
|
1223
|
-
"L0_workspace_bm25": {"status": "healthy" if
|
|
1234
|
+
"L0_workspace_bm25": {"status": "healthy" if l0_healthy else "unavailable", "backend": "sqlite-fts5"},
|
|
1224
1235
|
"L1_system_files": {"status": "healthy", "description": "MEMORY.md, plans.md, daily notes"},
|
|
1225
1236
|
"L2_hybridrag": {"status": "healthy", "description": "Orchestrates L3+L4 fusion"},
|
|
1226
1237
|
"L3_graph_search": {"status": "healthy" if neo4j_healthy else "unavailable", "backend": "neo4j"},
|
|
1227
|
-
"L4_vector_search": {"status": "healthy" if qmd_healthy else "unavailable", "backend": "qmd
|
|
1228
|
-
"L5_communications": {"status": "healthy" if
|
|
1229
|
-
"L6_document_store": {"status": "healthy" if
|
|
1230
|
-
"ollama_embeddings": {"status": "healthy" if ollama_healthy else "unavailable"}
|
|
1238
|
+
"L4_vector_search": {"status": "healthy" if qmd_healthy else "unavailable", "backend": "qmd"},
|
|
1239
|
+
"L5_communications": {"status": "healthy" if l5_reachable else "unavailable", "backend": "milvus"},
|
|
1240
|
+
"L6_document_store": {"status": "healthy" if l6_reachable else "unavailable", "backend": "milvus+fts5+reranker", "port": 8037},
|
|
1231
1241
|
}
|
|
1232
1242
|
}
|
|
1233
1243
|
|
|
@@ -506,28 +506,28 @@ def search(query: str, collection: str = None, limit: int = 10, arena: str = Non
|
|
|
506
506
|
# --- Health / Stats ---
|
|
507
507
|
|
|
508
508
|
def health():
|
|
509
|
-
"""Check L5 health.
|
|
509
|
+
"""Check L5 health.
|
|
510
|
+
|
|
511
|
+
Reports "ok" iff the Milvus client can list collections — that's
|
|
512
|
+
L5's actual data plane. Embeddings are intentionally NOT probed
|
|
513
|
+
here: that's a separate concern reported by the compat shim's
|
|
514
|
+
nv_embed entry. Probing an external embedding endpoint on every
|
|
515
|
+
/health adds latency and false negatives for layers that only
|
|
516
|
+
embed on demand.
|
|
517
|
+
"""
|
|
510
518
|
try:
|
|
511
519
|
client = get_client()
|
|
512
520
|
collections = ["chats", "emails", "contacts", "memory"]
|
|
513
|
-
|
|
521
|
+
out = {"status": "ok", "db_path": DB_PATH, "collections": {}}
|
|
514
522
|
for coll in collections:
|
|
515
523
|
if client.has_collection(coll):
|
|
516
524
|
stats = client.get_collection_stats(coll)
|
|
517
525
|
count = stats.get("row_count", 0)
|
|
518
|
-
|
|
526
|
+
out["collections"][coll] = {"exists": True, "count": count}
|
|
519
527
|
else:
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
# Check embeddings
|
|
524
|
-
try:
|
|
525
|
-
r = httpx.get("http://localhost:11434/api/tags", timeout=3)
|
|
526
|
-
models = [m["name"] for m in r.json().get("models", [])]
|
|
527
|
-
status["embeddings"] = EMBED_MODEL in str(models)
|
|
528
|
-
except Exception:
|
|
529
|
-
status["embeddings"] = False
|
|
530
|
-
return status
|
|
528
|
+
out["collections"][coll] = {"exists": False, "count": 0}
|
|
529
|
+
out["total_chunks"] = sum(c["count"] for c in out["collections"].values())
|
|
530
|
+
return out
|
|
531
531
|
except Exception as e:
|
|
532
532
|
return {"status": "error", "error": str(e)}
|
|
533
533
|
|
|
@@ -745,41 +745,40 @@ def get_stats() -> Dict:
|
|
|
745
745
|
|
|
746
746
|
|
|
747
747
|
def health() -> Dict:
|
|
748
|
-
"""Health check.
|
|
749
|
-
status = {"status": "ok", "milvus": "unknown", "fts": "unknown", "ollama": "unknown", "reranker": "unknown"}
|
|
748
|
+
"""Health check.
|
|
750
749
|
|
|
751
|
-
|
|
750
|
+
Reports "ok" iff Milvus and the FTS sidecar both answer. Embeddings
|
|
751
|
+
are NOT probed here — the compat shim's nv_embed entry covers that.
|
|
752
|
+
Ollama was a legacy fallback that is not used in any deployment, so
|
|
753
|
+
its previous probe was a false negative on prod.
|
|
754
|
+
"""
|
|
755
|
+
out = {"status": "ok", "milvus": "unknown", "fts": "unknown", "reranker": "unknown"}
|
|
756
|
+
|
|
757
|
+
# Milvus — vector store
|
|
752
758
|
try:
|
|
753
759
|
client = get_milvus()
|
|
754
760
|
colls = client.list_collections()
|
|
755
|
-
|
|
761
|
+
out["milvus"] = f"ok ({len(colls)} collections)"
|
|
756
762
|
except Exception as e:
|
|
757
|
-
|
|
758
|
-
|
|
763
|
+
out["milvus"] = f"error: {e}"
|
|
764
|
+
out["status"] = "degraded"
|
|
759
765
|
|
|
760
|
-
# FTS
|
|
766
|
+
# FTS — keyword fallback over the same chunk set
|
|
761
767
|
try:
|
|
762
768
|
conn = get_fts_db()
|
|
763
769
|
cnt = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
|
764
|
-
|
|
770
|
+
out["fts"] = f"ok ({cnt} chunks)"
|
|
765
771
|
conn.close()
|
|
766
772
|
except Exception as e:
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
# Ollama
|
|
771
|
-
try:
|
|
772
|
-
resp = _embed_client.get(f"{OLLAMA_URL}/api/tags", timeout=5)
|
|
773
|
-
status["ollama"] = "ok" if resp.status_code == 200 else f"http {resp.status_code}"
|
|
774
|
-
except Exception as e:
|
|
775
|
-
status["ollama"] = f"error: {e}"
|
|
776
|
-
status["status"] = "degraded"
|
|
773
|
+
out["fts"] = f"error: {e}"
|
|
774
|
+
out["status"] = "degraded"
|
|
777
775
|
|
|
778
|
-
# Reranker
|
|
776
|
+
# Reranker — informational; CPU fallback to RRF is acceptable, so
|
|
777
|
+
# don't degrade overall status when it's unavailable.
|
|
779
778
|
reranker = get_reranker()
|
|
780
|
-
|
|
779
|
+
out["reranker"] = "loaded" if reranker else "unavailable (CPU fallback to RRF)"
|
|
781
780
|
|
|
782
|
-
return
|
|
781
|
+
return out
|
|
783
782
|
|
|
784
783
|
# ---------------------------------------------------------------------------
|
|
785
784
|
# FastAPI Server
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# e2e_arena.sh — multi-tenant store/retrieve smoke test against a live
|
|
3
|
+
# memory-engine stack. Exercises /store, arena-scoped /search, and
|
|
4
|
+
# /forget end-to-end across L0/L4/L5/L6 + the compat shim.
|
|
5
|
+
#
|
|
6
|
+
# Run after `docker compose -f docker-compose.yml -f docker-compose.test.yml \
|
|
7
|
+
# up -d --wait l3 l4 l5 l6 l2 compat embed-stub`.
|
|
8
|
+
set -eu
|
|
9
|
+
|
|
10
|
+
BASE="${BASE:-http://localhost:8099}"
|
|
11
|
+
WAIT_HEALTH_SECS="${WAIT_HEALTH_SECS:-180}"
|
|
12
|
+
PASS=0
|
|
13
|
+
FAIL=0
|
|
14
|
+
|
|
15
|
+
ok() { echo " ✅ $1"; PASS=$((PASS+1)); }
|
|
16
|
+
fail() { echo " ❌ $1"; FAIL=$((FAIL+1)); }
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Wait for the compat shim to come up. Its /health aggregates layer
|
|
20
|
+
# health; we accept "ok" or "degraded" (l3 cosmetic 404 is known and
|
|
21
|
+
# doesn't block functional paths).
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
echo "=== waiting for $BASE/health (up to ${WAIT_HEALTH_SECS}s) ==="
|
|
25
|
+
deadline=$(( $(date +%s) + WAIT_HEALTH_SECS ))
|
|
26
|
+
while :; do
|
|
27
|
+
if H=$(curl -sf --max-time 5 "$BASE/health"); then
|
|
28
|
+
s=$(echo "$H" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))')
|
|
29
|
+
if [ "$s" = "ok" ] || [ "$s" = "degraded" ]; then
|
|
30
|
+
echo " health: $s"
|
|
31
|
+
break
|
|
32
|
+
fi
|
|
33
|
+
fi
|
|
34
|
+
if [ "$(date +%s)" -ge "$deadline" ]; then
|
|
35
|
+
echo " ❌ engine never became healthy"
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
sleep 3
|
|
39
|
+
done
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# /store — two arenas, two distinct documents per arena.
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
echo ""
|
|
46
|
+
echo "=== /store ==="
|
|
47
|
+
post() {
|
|
48
|
+
curl -sf -X POST "$BASE/store" \
|
|
49
|
+
-H "Content-Type: application/json" \
|
|
50
|
+
-d "$1"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
R1=$(post '{"content":"Alpha team owns project Atlas","metadata":{"arena":"e2e-tenant-a","kind":"note","probe":"e2e-arena"}}')
|
|
54
|
+
R2=$(post '{"content":"Alpha team owns project Borealis","metadata":{"arena":"e2e-tenant-b","kind":"note","probe":"e2e-arena"}}')
|
|
55
|
+
R3=$(post '{"content":"Bravo team owns project Cobalt","metadata":{"arena":"e2e-tenant-a","kind":"note","probe":"e2e-arena"}}')
|
|
56
|
+
R4=$(post '{"content":"Bravo team owns project Diamond","metadata":{"arena":"e2e-tenant-b","kind":"note","probe":"e2e-arena"}}')
|
|
57
|
+
|
|
58
|
+
[ -n "$R1" ] && [ -n "$R2" ] && [ -n "$R3" ] && [ -n "$R4" ] \
|
|
59
|
+
&& ok "stored 4 docs across 2 arenas" \
|
|
60
|
+
|| fail "store"
|
|
61
|
+
|
|
62
|
+
# Indexing is async on some layers — give the stack a brief settle.
|
|
63
|
+
sleep 4
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# /search — arena scoping. tenant-a should never see Borealis/Diamond,
|
|
67
|
+
# tenant-b should never see Atlas/Cobalt.
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
echo ""
|
|
71
|
+
echo "=== /search arena=e2e-tenant-a ==="
|
|
72
|
+
SA=$(curl -sf -X POST "$BASE/search" -H "Content-Type: application/json" \
|
|
73
|
+
-d '{"query":"team project","limit":20,"arena":"e2e-tenant-a"}')
|
|
74
|
+
echo " hits: $(echo "$SA" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("results",[])))')"
|
|
75
|
+
|
|
76
|
+
leak_a=$(echo "$SA" | python3 -c '
|
|
77
|
+
import json,sys
|
|
78
|
+
data=json.load(sys.stdin).get("results",[])
|
|
79
|
+
bad=[r for r in data if "Borealis" in r.get("content","") or "Diamond" in r.get("content","")]
|
|
80
|
+
print(len(bad))')
|
|
81
|
+
[ "$leak_a" = "0" ] && ok "tenant-a: no Borealis/Diamond leakage" \
|
|
82
|
+
|| fail "tenant-a leaked $leak_a tenant-b docs"
|
|
83
|
+
|
|
84
|
+
found_atlas=$(echo "$SA" | python3 -c '
|
|
85
|
+
import json,sys
|
|
86
|
+
data=json.load(sys.stdin).get("results",[])
|
|
87
|
+
print("yes" if any("Atlas" in r.get("content","") for r in data) else "no")')
|
|
88
|
+
[ "$found_atlas" = "yes" ] && ok "tenant-a: Atlas recovered" \
|
|
89
|
+
|| fail "tenant-a missing Atlas"
|
|
90
|
+
|
|
91
|
+
echo ""
|
|
92
|
+
echo "=== /search arena=e2e-tenant-b ==="
|
|
93
|
+
SB=$(curl -sf -X POST "$BASE/search" -H "Content-Type: application/json" \
|
|
94
|
+
-d '{"query":"team project","limit":20,"arena":"e2e-tenant-b"}')
|
|
95
|
+
echo " hits: $(echo "$SB" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("results",[])))')"
|
|
96
|
+
|
|
97
|
+
leak_b=$(echo "$SB" | python3 -c '
|
|
98
|
+
import json,sys
|
|
99
|
+
data=json.load(sys.stdin).get("results",[])
|
|
100
|
+
bad=[r for r in data if "Atlas" in r.get("content","") or "Cobalt" in r.get("content","")]
|
|
101
|
+
print(len(bad))')
|
|
102
|
+
[ "$leak_b" = "0" ] && ok "tenant-b: no Atlas/Cobalt leakage" \
|
|
103
|
+
|| fail "tenant-b leaked $leak_b tenant-a docs"
|
|
104
|
+
|
|
105
|
+
found_borealis=$(echo "$SB" | python3 -c '
|
|
106
|
+
import json,sys
|
|
107
|
+
data=json.load(sys.stdin).get("results",[])
|
|
108
|
+
print("yes" if any("Borealis" in r.get("content","") for r in data) else "no")')
|
|
109
|
+
[ "$found_borealis" = "yes" ] && ok "tenant-b: Borealis recovered" \
|
|
110
|
+
|| fail "tenant-b missing Borealis"
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# /search with metadata_filter — arena+probe combo should still scope.
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
echo ""
|
|
117
|
+
echo "=== /search metadata_filter probe=e2e-arena ==="
|
|
118
|
+
SF=$(curl -sf -X POST "$BASE/search" -H "Content-Type: application/json" \
|
|
119
|
+
-d '{"query":"team","limit":20,"arena":"e2e-tenant-a","metadata_filter":{"probe":"e2e-arena"}}')
|
|
120
|
+
all_match=$(echo "$SF" | python3 -c '
|
|
121
|
+
import json,sys
|
|
122
|
+
data=json.load(sys.stdin).get("results",[])
|
|
123
|
+
ok=all(r.get("metadata",{}).get("probe")=="e2e-arena" and r.get("metadata",{}).get("arena")=="e2e-tenant-a" for r in data)
|
|
124
|
+
print("yes" if ok and data else "no")')
|
|
125
|
+
[ "$all_match" = "yes" ] && ok "metadata_filter scopes to probe + arena" \
|
|
126
|
+
|| fail "metadata_filter let other rows through"
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# /forget — by metadata_contains. Cleans up so reruns are idempotent.
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
echo ""
|
|
133
|
+
echo "=== /forget probe=e2e-arena ==="
|
|
134
|
+
F=$(curl -sf -X POST "$BASE/forget" -H "Content-Type: application/json" \
|
|
135
|
+
-d '{"metadata_contains":{"probe":"e2e-arena"}}')
|
|
136
|
+
deleted=$(echo "$F" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("deleted",0))')
|
|
137
|
+
echo " deleted: $deleted"
|
|
138
|
+
[ "$deleted" -ge "1" ] && ok "/forget removed at least 1 row" || fail "/forget"
|
|
139
|
+
|
|
140
|
+
echo ""
|
|
141
|
+
echo "=== Result ==="
|
|
142
|
+
echo " PASS: $PASS"
|
|
143
|
+
echo " FAIL: $FAIL"
|
|
144
|
+
exit $FAIL
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
RUN pip install --no-cache-dir fastapi "uvicorn[standard]" pydantic
|
|
6
|
+
|
|
7
|
+
COPY server.py /app/server.py
|
|
8
|
+
|
|
9
|
+
ENV EMBED_DIM=4096
|
|
10
|
+
|
|
11
|
+
EXPOSE 8041
|
|
12
|
+
|
|
13
|
+
CMD ["python", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8041"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Deterministic embedding stub for hermetic CI runs.
|
|
2
|
+
|
|
3
|
+
Returns a fixed-dim vector per input string, derived from a hash so the
|
|
4
|
+
same text always maps to the same vector. Cosine similarity between two
|
|
5
|
+
embeddings equals 1.0 only for identical input strings, and decreases
|
|
6
|
+
roughly with edit distance — enough to exercise the engine's vector
|
|
7
|
+
search paths in CI without an actual embedding model.
|
|
8
|
+
|
|
9
|
+
Speaks both shapes the engine uses:
|
|
10
|
+
POST /v1/embeddings { input, model } -> { data:[{embedding:[...] }] }
|
|
11
|
+
POST /v1/embed { input, model } -> { embeddings:[[...]] }
|
|
12
|
+
|
|
13
|
+
Run:
|
|
14
|
+
EMBED_DIM=4096 uvicorn server:app --host 0.0.0.0 --port 8041
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from fastapi import FastAPI
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
|
|
26
|
+
EMBED_DIM = int(os.environ.get("EMBED_DIM", "4096"))
|
|
27
|
+
|
|
28
|
+
app = FastAPI(title="embed-stub")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EmbedRequest(BaseModel):
|
|
32
|
+
input: Any
|
|
33
|
+
model: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _vector_for(text: str) -> list[float]:
|
|
37
|
+
"""Deterministic vector: hash the text, expand to EMBED_DIM, L2-normalise."""
|
|
38
|
+
text = text or ""
|
|
39
|
+
seed = hashlib.sha256(text.encode("utf-8")).digest()
|
|
40
|
+
raw: list[int] = []
|
|
41
|
+
counter = 0
|
|
42
|
+
while len(raw) < EMBED_DIM:
|
|
43
|
+
chunk = hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()
|
|
44
|
+
raw.extend(chunk)
|
|
45
|
+
counter += 1
|
|
46
|
+
floats = [(b - 127.5) / 127.5 for b in raw[:EMBED_DIM]]
|
|
47
|
+
norm = math.sqrt(sum(x * x for x in floats)) or 1.0
|
|
48
|
+
return [x / norm for x in floats]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalise_inputs(inp: Any) -> list[str]:
|
|
52
|
+
if isinstance(inp, str):
|
|
53
|
+
return [inp]
|
|
54
|
+
if isinstance(inp, list):
|
|
55
|
+
return [str(x) for x in inp]
|
|
56
|
+
return [str(inp)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/health")
|
|
60
|
+
def health() -> dict:
|
|
61
|
+
return {"status": "ok", "dim": EMBED_DIM}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.post("/v1/embeddings")
|
|
65
|
+
def openai_embeddings(req: EmbedRequest) -> dict:
|
|
66
|
+
texts = _normalise_inputs(req.input)
|
|
67
|
+
return {
|
|
68
|
+
"object": "list",
|
|
69
|
+
"data": [
|
|
70
|
+
{"object": "embedding", "index": i, "embedding": _vector_for(t)}
|
|
71
|
+
for i, t in enumerate(texts)
|
|
72
|
+
],
|
|
73
|
+
"model": req.model or "embed-stub",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.post("/v1/embed")
|
|
78
|
+
def lambda_gateway_embed(req: EmbedRequest) -> dict:
|
|
79
|
+
texts = _normalise_inputs(req.input)
|
|
80
|
+
return {"embeddings": [_vector_for(t) for t in texts]}
|