@pentatonic-ai/ai-agent-sdk 0.9.6 → 0.10.0
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/README.md +3 -3
- package/bin/cli.js +1 -1
- package/bin/commands/config.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/packages/doctor/src/checks/local-memory.js +2 -2
- package/packages/memory/README.md +2 -2
- package/packages/memory/openclaw-plugin/README.md +2 -2
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
- package/packages/memory/src/server.js +2 -2
- package/packages/memory-engine-v2/.env.example +30 -0
- package/packages/memory-engine-v2/README.md +125 -0
- package/packages/memory-engine-v2/compat/Dockerfile +11 -0
- package/packages/memory-engine-v2/compat/requirements.txt +6 -0
- package/packages/memory-engine-v2/compat/server.py +1047 -0
- package/packages/memory-engine-v2/docker-compose.aws.yml +78 -0
- package/packages/memory-engine-v2/docker-compose.yml +206 -0
- package/packages/memory-engine-v2/extractor-async/Dockerfile +14 -0
- package/packages/memory-engine-v2/extractor-async/confidence.py +62 -0
- package/packages/memory-engine-v2/extractor-async/noise_filter.py +144 -0
- package/packages/memory-engine-v2/extractor-async/requirements.txt +2 -0
- package/packages/memory-engine-v2/extractor-async/test_confidence.py +76 -0
- package/packages/memory-engine-v2/extractor-async/test_noise_filter.py +177 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +797 -0
- package/packages/memory-engine-v2/extractor-sync/Dockerfile +11 -0
- package/packages/memory-engine-v2/extractor-sync/requirements.txt +4 -0
- package/packages/memory-engine-v2/extractor-sync/server.py +424 -0
- package/packages/memory-engine-v2/org-model/migrations/001_init.sql +390 -0
- package/packages/memory-engine-v2/tests/e2e_smoke.py +356 -0
- package/packages/memory-engine-v2/tests/fixtures/generate_synthetic_corpus.py +758 -0
- package/packages/memory-engine/.env.example +0 -13
- package/packages/memory-engine/MIGRATION.md +0 -219
- package/packages/memory-engine/README.md +0 -145
- package/packages/memory-engine/bench/README.md +0 -99
- package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +0 -961
- package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +0 -937
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +0 -961
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +0 -883
- package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +0 -937
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +0 -1115
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +0 -1115
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +0 -819
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +0 -542
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +0 -1278
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +0 -894
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +0 -1018
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +0 -680
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +0 -1038
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +0 -693
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +0 -961
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +0 -727
- package/packages/memory-engine/compat/Dockerfile +0 -22
- package/packages/memory-engine/compat/server.py +0 -1255
- package/packages/memory-engine/docker-compose.test.yml +0 -59
- package/packages/memory-engine/docker-compose.yml +0 -255
- package/packages/memory-engine/engine/README.md +0 -52
- package/packages/memory-engine/engine/l2-hybridrag-proxy.py +0 -1543
- package/packages/memory-engine/engine/l5-comms-layer.py +0 -663
- package/packages/memory-engine/engine/l6-document-store.py +0 -1018
- package/packages/memory-engine/engine/services/_shared/__init__.py +0 -1
- package/packages/memory-engine/engine/services/_shared/embed_provider.py +0 -562
- package/packages/memory-engine/engine/services/l2/Dockerfile +0 -50
- package/packages/memory-engine/engine/services/l2/init_databases.py +0 -81
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +0 -2721
- package/packages/memory-engine/engine/services/l5/Dockerfile +0 -11
- package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +0 -808
- package/packages/memory-engine/engine/services/l6/Dockerfile +0 -30
- package/packages/memory-engine/engine/services/l6/l6-document-store.py +0 -1221
- package/packages/memory-engine/engine/services/nv-embed/Dockerfile +0 -28
- package/packages/memory-engine/engine/services/nv-embed/server.py +0 -152
- package/packages/memory-engine/pme_memory/__init__.py +0 -0
- package/packages/memory-engine/pme_memory/__main__.py +0 -129
- package/packages/memory-engine/pme_memory/artifacts.py +0 -95
- package/packages/memory-engine/pme_memory/embed.py +0 -74
- package/packages/memory-engine/pme_memory/health.py +0 -36
- package/packages/memory-engine/pme_memory/hygiene.py +0 -159
- package/packages/memory-engine/pme_memory/indexer.py +0 -200
- package/packages/memory-engine/pme_memory/needs.py +0 -55
- package/packages/memory-engine/pme_memory/provenance.py +0 -80
- package/packages/memory-engine/pme_memory/scoring.py +0 -168
- package/packages/memory-engine/pme_memory/search.py +0 -52
- package/packages/memory-engine/pme_memory/store.py +0 -86
- package/packages/memory-engine/pme_memory/synthesis.py +0 -114
- package/packages/memory-engine/pyproject.toml +0 -65
- package/packages/memory-engine/scripts/kg-extractor.py +0 -557
- package/packages/memory-engine/scripts/kg-preflexor-v2.py +0 -738
- package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +0 -128
- package/packages/memory-engine/tests/e2e_arena.sh +0 -259
- package/packages/memory-engine/tests/embed_stub/Dockerfile +0 -13
- package/packages/memory-engine/tests/embed_stub/server.py +0 -80
- package/packages/memory-engine/tests/test_aggregate.py +0 -333
- package/packages/memory-engine/tests/test_api_contract.sh +0 -57
- package/packages/memory-engine/tests/test_arena_safety.py +0 -232
- package/packages/memory-engine/tests/test_channel_stat_reader.py +0 -437
- package/packages/memory-engine/tests/test_channel_stat_rollups.py +0 -308
- package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +0 -48
- package/packages/memory-engine/tests/test_embed_provider.py +0 -693
- package/packages/memory-engine/tests/test_l2_qmd_vec_search.py +0 -280
- package/packages/memory-engine/tests/test_l3_arena_isolation.py +0 -412
- package/packages/memory-engine/tests/test_l6_module_load.py +0 -84
- package/packages/memory-engine/tests/test_people_list_reader.py +0 -432
|
@@ -1,2721 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Sequential HybridRAG Proxy — Graph-First Architecture
|
|
4
|
-
|
|
5
|
-
Based on BlackRock-NVIDIA paper methodology:
|
|
6
|
-
1. HybridRAG trigger (early detection)
|
|
7
|
-
2. Neo4j graph search (entities/relationships first)
|
|
8
|
-
3. QMD vector search (informed by graph context)
|
|
9
|
-
4. Result fusion with graph-priority scoring
|
|
10
|
-
|
|
11
|
-
Port: 8031 (replaces neo4j-qmd-proxy.py)
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
import argparse
|
|
15
|
-
import asyncio
|
|
16
|
-
import hashlib
|
|
17
|
-
import json
|
|
18
|
-
import logging
|
|
19
|
-
import os
|
|
20
|
-
import sqlite3
|
|
21
|
-
import struct
|
|
22
|
-
import sys
|
|
23
|
-
import time
|
|
24
|
-
from contextlib import asynccontextmanager
|
|
25
|
-
from datetime import datetime
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
from typing import Any, AsyncIterator, Dict, List, Optional, Set
|
|
28
|
-
|
|
29
|
-
import re
|
|
30
|
-
import httpx
|
|
31
|
-
import requests
|
|
32
|
-
from fastapi import FastAPI, HTTPException, Request
|
|
33
|
-
from neo4j import AsyncGraphDatabase
|
|
34
|
-
from neo4j.time import DateTime as Neo4jDateTime, Date as Neo4jDate
|
|
35
|
-
from pydantic import BaseModel
|
|
36
|
-
import uvicorn
|
|
37
|
-
|
|
38
|
-
try:
|
|
39
|
-
import sqlite_vec # 0.1.9 — native KNN MATCH over packed-f32 vec0 tables
|
|
40
|
-
except ImportError:
|
|
41
|
-
sqlite_vec = None # Caller logs loudly if helpers can't load the extension
|
|
42
|
-
|
|
43
|
-
# Shared embed client lives at engine/services/_shared/.
|
|
44
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
45
|
-
from _shared.embed_provider import EmbedClient # noqa: E402
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _serialize_neo4j_value(v: Any) -> Any:
|
|
49
|
-
"""Convert neo4j-specific types to JSON-serialisable equivalents.
|
|
50
|
-
|
|
51
|
-
Without this, FastAPI's pydantic serialiser raises
|
|
52
|
-
``PydanticSerializationError: Unable to serialize unknown type:
|
|
53
|
-
<class 'neo4j.time.DateTime'>`` when L3 graph results include nodes
|
|
54
|
-
with datetime properties (e.g. created_at on entity nodes).
|
|
55
|
-
"""
|
|
56
|
-
if isinstance(v, (Neo4jDateTime, Neo4jDate)):
|
|
57
|
-
return v.iso_format()
|
|
58
|
-
if isinstance(v, dict):
|
|
59
|
-
return {k: _serialize_neo4j_value(x) for k, x in v.items()}
|
|
60
|
-
if isinstance(v, (list, tuple)):
|
|
61
|
-
return [_serialize_neo4j_value(x) for x in v]
|
|
62
|
-
return v
|
|
63
|
-
|
|
64
|
-
# ---------------------------------------------------------------------------
|
|
65
|
-
# Config
|
|
66
|
-
# ---------------------------------------------------------------------------
|
|
67
|
-
|
|
68
|
-
WORKSPACE = Path(os.environ.get("PME_WORKSPACE", Path.home() / "pentatonic"))
|
|
69
|
-
|
|
70
|
-
NEO4J_URI = os.environ.get("PME_NEO4J_URI", "bolt://localhost:7687")
|
|
71
|
-
|
|
72
|
-
def _load_neo4j_password() -> str:
|
|
73
|
-
"""Resolve Neo4j password from env or secrets file."""
|
|
74
|
-
pw = os.environ.get("PME_NEO4J_PASSWORD", os.environ.get("NEO4J_PASSWORD"))
|
|
75
|
-
if pw:
|
|
76
|
-
return pw
|
|
77
|
-
for path in [
|
|
78
|
-
WORKSPACE / ".secrets.json",
|
|
79
|
-
Path.home() / ".pentatonic" / "workspace" / ".secrets.json",
|
|
80
|
-
Path.home() / ".pentatonic" / "secrets.json",
|
|
81
|
-
]:
|
|
82
|
-
if path.exists():
|
|
83
|
-
try:
|
|
84
|
-
data = json.loads(path.read_text())
|
|
85
|
-
for key in ("neo4j_password", "NEO4J_PASSWORD", "neo4jPassword"):
|
|
86
|
-
if key in data:
|
|
87
|
-
return data[key]
|
|
88
|
-
except (json.JSONDecodeError, OSError):
|
|
89
|
-
continue
|
|
90
|
-
return "password" # fallback default
|
|
91
|
-
|
|
92
|
-
NEO4J_AUTH = ("neo4j", _load_neo4j_password())
|
|
93
|
-
def _resolve_qmd_db() -> str:
|
|
94
|
-
"""Resolve QMD DB path, checking common locations."""
|
|
95
|
-
env = os.environ.get("PME_QMD_DB")
|
|
96
|
-
if env:
|
|
97
|
-
return env
|
|
98
|
-
for candidate in [
|
|
99
|
-
Path.home() / ".pentatonic" / "memory" / "main.sqlite",
|
|
100
|
-
Path.home() / ".openclaw" / "memory" / "main.sqlite",
|
|
101
|
-
]:
|
|
102
|
-
if candidate.exists():
|
|
103
|
-
return str(candidate)
|
|
104
|
-
return str(Path.home() / ".pentatonic" / "memory" / "main.sqlite")
|
|
105
|
-
|
|
106
|
-
QMD_DB_PATH = _resolve_qmd_db()
|
|
107
|
-
OLLAMA_URL = os.environ.get("PME_OLLAMA_URL", "http://localhost:11434/api/embeddings")
|
|
108
|
-
EMBEDDING_MODEL = os.environ.get("PME_EMBED_MODEL", "nomic-embed-text")
|
|
109
|
-
|
|
110
|
-
# Embedding dimension for the vec0 virtual table. Production gateway
|
|
111
|
-
# (lambda-gateway.pentatonic.com/v1/embed via pentatonic-gateway provider)
|
|
112
|
-
# returns NV-Embed-v2 4096-dim vectors. The vec0 schema requires the dim
|
|
113
|
-
# at DDL time and writers must match — keep this in lockstep with the
|
|
114
|
-
# gateway / EmbedClient config.
|
|
115
|
-
EMBED_DIM = int(os.environ.get("PME_EMBED_DIM", "4096"))
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _open_qmd_conn() -> sqlite3.Connection:
|
|
119
|
-
"""Open qmd.sqlite with sqlite-vec loaded.
|
|
120
|
-
|
|
121
|
-
Falls back to a plain sqlite3 connection if the extension can't load —
|
|
122
|
-
MATCH-form queries will then fail loudly at execute time, which is the
|
|
123
|
-
right signal (loud error > silent degradation back to Python cosine).
|
|
124
|
-
Callers that only need scalar columns (chunks.path, chunks.text) work
|
|
125
|
-
fine without the extension.
|
|
126
|
-
|
|
127
|
-
``check_same_thread=False`` is intentional: the async backfill yields
|
|
128
|
-
via ``asyncio.to_thread`` to keep /search responsive, which means the
|
|
129
|
-
connection is handed off between event-loop / thread-pool workers.
|
|
130
|
-
sqlite's default thread-safety check would otherwise reject the
|
|
131
|
-
cross-thread reuse even though only one worker touches it at a time.
|
|
132
|
-
"""
|
|
133
|
-
conn = sqlite3.connect(QMD_DB_PATH, timeout=10, check_same_thread=False)
|
|
134
|
-
if sqlite_vec is None:
|
|
135
|
-
log.error("sqlite_vec module not importable — qmd vec_index unavailable")
|
|
136
|
-
return conn
|
|
137
|
-
try:
|
|
138
|
-
conn.enable_load_extension(True)
|
|
139
|
-
sqlite_vec.load(conn)
|
|
140
|
-
conn.enable_load_extension(False)
|
|
141
|
-
except Exception as e:
|
|
142
|
-
log.error(f"sqlite-vec load failed: {e} — qmd search will be degraded")
|
|
143
|
-
return conn
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _ensure_vec_index(conn: sqlite3.Connection) -> None:
|
|
147
|
-
"""Create the vec0 KNN index if not already present. Idempotent.
|
|
148
|
-
|
|
149
|
-
`distance_metric=cosine` is non-default — sqlite-vec defaults to L2
|
|
150
|
-
(Euclidean). Probe confirmed cosine returns `1 - cos_sim` as the
|
|
151
|
-
distance. The id column is a regular INTEGER PRIMARY KEY so we can
|
|
152
|
-
JOIN back to `chunks` on the row's autoinc id.
|
|
153
|
-
"""
|
|
154
|
-
conn.execute(
|
|
155
|
-
f"""
|
|
156
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS vec_index USING vec0(
|
|
157
|
-
id INTEGER PRIMARY KEY,
|
|
158
|
-
embedding float[{EMBED_DIM}] distance_metric=cosine
|
|
159
|
-
)
|
|
160
|
-
"""
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
# NV-Embed-v2 service (primary, 4096-dim). URL/auth/path/body/response are
|
|
164
|
-
# managed by the shared EmbedClient; PME_EMBED_PROVIDER (default openai)
|
|
165
|
-
# selects auth scheme (Bearer vs X-API-Key) and request shape.
|
|
166
|
-
NV_EMBED_ENABLED = os.environ.get("PME_NV_EMBED_ENABLED", "true").lower() == "true"
|
|
167
|
-
|
|
168
|
-
_embed: EmbedClient | None = None
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def _embed_client() -> EmbedClient:
|
|
172
|
-
"""Lazily build the shared EmbedClient for L2."""
|
|
173
|
-
global _embed
|
|
174
|
-
if _embed is None:
|
|
175
|
-
_embed = EmbedClient.from_env(
|
|
176
|
-
prefix="PME_",
|
|
177
|
-
url_var="PME_NV_EMBED_URL",
|
|
178
|
-
key_var="PME_EMBED_API_KEY",
|
|
179
|
-
model_var="PME_NV_EMBED_MODEL",
|
|
180
|
-
default_url="http://localhost:8041/v1/embeddings",
|
|
181
|
-
)
|
|
182
|
-
return _embed
|
|
183
|
-
|
|
184
|
-
# Sequential processing weights - OPTIMIZED FOR QUALITY
|
|
185
|
-
GRAPH_PRIORITY_BOOST = 0.5 # Extra score for graph-derived results (↑ for better entity/relationship context)
|
|
186
|
-
VECTOR_BASE_WEIGHT = 0.5 # Base weight for vector results (↓ balanced for accuracy over speed)
|
|
187
|
-
|
|
188
|
-
# Memory tracking
|
|
189
|
-
TRACKER_FILE = WORKSPACE / "memory" / "memory-tracker.jsonl"
|
|
190
|
-
|
|
191
|
-
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
192
|
-
log = logging.getLogger("sequential-hybridrag")
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
# ---------------------------------------------------------------------------
|
|
196
|
-
# Async driver + HTTP client singletons
|
|
197
|
-
# ---------------------------------------------------------------------------
|
|
198
|
-
#
|
|
199
|
-
# v0.8.4: migrated from sync neo4j (`GraphDatabase`) to async
|
|
200
|
-
# (`AsyncGraphDatabase`) and from per-call drivers to one process-wide
|
|
201
|
-
# pool. Previously every handler created a fresh sync driver inline,
|
|
202
|
-
# which (a) blocked the event loop on connection establishment and
|
|
203
|
-
# every query, and (b) held one thread per in-flight request. Under
|
|
204
|
-
# sustained ingest the threadpool exhausted and /health itself timed
|
|
205
|
-
# out (the `l0/l1/l2: unreachable: ReadTimeout` we saw in prod).
|
|
206
|
-
#
|
|
207
|
-
# The async driver multiplexes many queries through bolt without
|
|
208
|
-
# blocking the event loop; one shared pool means connection
|
|
209
|
-
# establishment is amortised. fastapi lifespan handles open/close.
|
|
210
|
-
|
|
211
|
-
_neo4j_driver: "AsyncGraphDatabase | None" = None
|
|
212
|
-
_http_client: "httpx.AsyncClient | None" = None
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def get_neo4j_driver():
|
|
216
|
-
"""Lazy module-level singleton — created at first call (which the
|
|
217
|
-
lifespan handler does at startup). Sharing across requests is the
|
|
218
|
-
documented neo4j Python-driver pattern; the driver itself is
|
|
219
|
-
thread- and task-safe."""
|
|
220
|
-
global _neo4j_driver
|
|
221
|
-
if _neo4j_driver is None:
|
|
222
|
-
_neo4j_driver = AsyncGraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH)
|
|
223
|
-
return _neo4j_driver
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def get_http_client() -> httpx.AsyncClient:
|
|
227
|
-
"""Shared async HTTP client for L4/L5/L6 fan-out and embedding-proxy
|
|
228
|
-
pass-through. Reuses TCP connections via httpx's built-in pool."""
|
|
229
|
-
global _http_client
|
|
230
|
-
if _http_client is None:
|
|
231
|
-
_http_client = httpx.AsyncClient(timeout=30.0)
|
|
232
|
-
return _http_client
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
@asynccontextmanager
|
|
236
|
-
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
237
|
-
"""Open the neo4j driver + HTTP client at process startup, close on
|
|
238
|
-
shutdown. Without this, the first request pays driver-open latency
|
|
239
|
-
and the driver is never properly closed on SIGTERM (leaking conns).
|
|
240
|
-
|
|
241
|
-
Also schedules the vec_index backfill as a background task so the
|
|
242
|
-
proxy can start serving immediately while older chunks copy across
|
|
243
|
-
into the KNN index — first-time migration of ~450k rows takes
|
|
244
|
-
minutes and would otherwise block /health.
|
|
245
|
-
"""
|
|
246
|
-
global _neo4j_driver, _http_client
|
|
247
|
-
_neo4j_driver = AsyncGraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH)
|
|
248
|
-
_http_client = httpx.AsyncClient(timeout=30.0)
|
|
249
|
-
backfill_task = asyncio.create_task(_backfill_vec_index())
|
|
250
|
-
try:
|
|
251
|
-
yield
|
|
252
|
-
finally:
|
|
253
|
-
backfill_task.cancel()
|
|
254
|
-
try:
|
|
255
|
-
await backfill_task
|
|
256
|
-
except (asyncio.CancelledError, Exception):
|
|
257
|
-
pass
|
|
258
|
-
if _neo4j_driver is not None:
|
|
259
|
-
await _neo4j_driver.close()
|
|
260
|
-
_neo4j_driver = None
|
|
261
|
-
if _http_client is not None:
|
|
262
|
-
await _http_client.aclose()
|
|
263
|
-
_http_client = None
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
async def _backfill_vec_index() -> None:
|
|
267
|
-
"""One-time migration: copy existing chunks.embedding (JSON) into
|
|
268
|
-
vec_index (f32 bytes).
|
|
269
|
-
|
|
270
|
-
Idempotent. Runs at proxy startup if vec_index has fewer rows than
|
|
271
|
-
chunks. Async so it doesn't block /health — the proxy serves
|
|
272
|
-
requests in parallel and search degrades to partial-corpus results
|
|
273
|
-
until the backfill finishes (any chunk already mirrored into
|
|
274
|
-
vec_index is findable; the rest are invisible to search but still
|
|
275
|
-
in L0/L3/L5/L6).
|
|
276
|
-
|
|
277
|
-
At 450k rows + ~460 rows/s insert rate this takes ~16 min on a
|
|
278
|
-
cold prod instance. Subsequent restarts no-op cleanly.
|
|
279
|
-
"""
|
|
280
|
-
if sqlite_vec is None:
|
|
281
|
-
log.error("sqlite_vec module missing — backfill skipped, search will be degraded")
|
|
282
|
-
return
|
|
283
|
-
if not os.path.exists(QMD_DB_PATH):
|
|
284
|
-
log.info("vec_index backfill skipped — qmd.sqlite does not exist yet")
|
|
285
|
-
return
|
|
286
|
-
try:
|
|
287
|
-
conn = await asyncio.to_thread(_open_qmd_conn)
|
|
288
|
-
await asyncio.to_thread(_ensure_vec_index, conn)
|
|
289
|
-
chunks_n = conn.execute(
|
|
290
|
-
"SELECT count(*) FROM chunks WHERE embedding IS NOT NULL"
|
|
291
|
-
).fetchone()[0]
|
|
292
|
-
vec_n = conn.execute("SELECT count(*) FROM vec_index").fetchone()[0]
|
|
293
|
-
if vec_n >= chunks_n:
|
|
294
|
-
log.info(f"vec_index backfill skipped — already in sync ({vec_n}/{chunks_n})")
|
|
295
|
-
conn.close()
|
|
296
|
-
return
|
|
297
|
-
missing = chunks_n - vec_n
|
|
298
|
-
log.info(f"vec_index backfill starting — {missing} rows to copy")
|
|
299
|
-
cursor = conn.execute(
|
|
300
|
-
"""
|
|
301
|
-
SELECT c.id, c.embedding
|
|
302
|
-
FROM chunks c
|
|
303
|
-
LEFT JOIN vec_index v ON v.id = c.id
|
|
304
|
-
WHERE v.id IS NULL AND c.embedding IS NOT NULL
|
|
305
|
-
"""
|
|
306
|
-
)
|
|
307
|
-
BATCH = 500
|
|
308
|
-
copied = 0
|
|
309
|
-
while True:
|
|
310
|
-
batch = await asyncio.to_thread(cursor.fetchmany, BATCH)
|
|
311
|
-
if not batch:
|
|
312
|
-
break
|
|
313
|
-
def _insert_batch() -> int:
|
|
314
|
-
inserted = 0
|
|
315
|
-
with conn:
|
|
316
|
-
for cid, emb_json in batch:
|
|
317
|
-
try:
|
|
318
|
-
vec = json.loads(emb_json)
|
|
319
|
-
except Exception:
|
|
320
|
-
continue
|
|
321
|
-
if len(vec) != EMBED_DIM:
|
|
322
|
-
continue
|
|
323
|
-
conn.execute(
|
|
324
|
-
"INSERT INTO vec_index(id, embedding) VALUES (?, ?)",
|
|
325
|
-
(cid, struct.pack(f"{len(vec)}f", *vec)),
|
|
326
|
-
)
|
|
327
|
-
inserted += 1
|
|
328
|
-
return inserted
|
|
329
|
-
copied += await asyncio.to_thread(_insert_batch)
|
|
330
|
-
log.info(f"vec_index backfill progress: {copied}/{missing}")
|
|
331
|
-
# Yield generously so /search + writers aren't starved.
|
|
332
|
-
await asyncio.sleep(0)
|
|
333
|
-
log.info(f"vec_index backfill done — {copied} rows copied")
|
|
334
|
-
conn.close()
|
|
335
|
-
except asyncio.CancelledError:
|
|
336
|
-
log.info("vec_index backfill cancelled during shutdown")
|
|
337
|
-
raise
|
|
338
|
-
except Exception as e:
|
|
339
|
-
log.error(f"vec_index backfill failed: {e}")
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
app = FastAPI(title="Sequential HybridRAG Proxy", version="1.0.0", lifespan=lifespan)
|
|
343
|
-
|
|
344
|
-
# ---------------------------------------------------------------------------
|
|
345
|
-
# Memory Usage Tracking
|
|
346
|
-
# ---------------------------------------------------------------------------
|
|
347
|
-
|
|
348
|
-
def log_memory_usage(query: str, layers_hit: List[str], l1_hits: int = 0,
|
|
349
|
-
l3_hits: int = 0, l4_hits: int = 0, search_time_ms: float = 0.0,
|
|
350
|
-
entities_extracted: List[str] = None) -> None:
|
|
351
|
-
"""Log memory layer usage for evolution tracking."""
|
|
352
|
-
try:
|
|
353
|
-
TRACKER_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
354
|
-
|
|
355
|
-
# L2 HybridRAG is active whenever L3 or L4 are used
|
|
356
|
-
l2_active = 1 if (l3_hits > 0 or l4_hits > 0) else 0
|
|
357
|
-
|
|
358
|
-
event = {
|
|
359
|
-
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
360
|
-
"query": query[:100], # Truncate for privacy
|
|
361
|
-
"source": "sequential-hybridrag",
|
|
362
|
-
"layers_hit": layers_hit,
|
|
363
|
-
"l1_system_hits": l1_hits,
|
|
364
|
-
"l2_hybridrag_active": l2_active,
|
|
365
|
-
"l3_graph_hits": l3_hits,
|
|
366
|
-
"l4_vector_hits": l4_hits,
|
|
367
|
-
"total_hits": l1_hits + l3_hits + l4_hits,
|
|
368
|
-
"search_time_ms": round(search_time_ms, 1),
|
|
369
|
-
"entities_extracted": entities_extracted or [],
|
|
370
|
-
"entity_count": len(entities_extracted or [])
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
with open(TRACKER_FILE, "a", encoding="utf-8") as f:
|
|
374
|
-
f.write(json.dumps(event) + "\n")
|
|
375
|
-
|
|
376
|
-
except Exception as e:
|
|
377
|
-
log.warning(f"Memory tracking failed: {e}")
|
|
378
|
-
|
|
379
|
-
def get_layer_stats() -> Dict:
|
|
380
|
-
"""Get usage statistics by layer."""
|
|
381
|
-
if not TRACKER_FILE.exists():
|
|
382
|
-
return {"error": "No tracking data found"}
|
|
383
|
-
|
|
384
|
-
try:
|
|
385
|
-
layer_counts = {"L1": 0, "L2": 0, "L3": 0, "L4": 0}
|
|
386
|
-
total_queries = 0
|
|
387
|
-
recent_queries = 0
|
|
388
|
-
|
|
389
|
-
with open(TRACKER_FILE, "r", encoding="utf-8") as f:
|
|
390
|
-
for line in f:
|
|
391
|
-
try:
|
|
392
|
-
event = json.loads(line.strip())
|
|
393
|
-
if event.get("source") == "sequential-hybridrag":
|
|
394
|
-
total_queries += 1
|
|
395
|
-
|
|
396
|
-
# Count layer hits
|
|
397
|
-
layers = event.get("layers_hit", [])
|
|
398
|
-
if "system" in layers or event.get("l1_system_hits", 0) > 0:
|
|
399
|
-
layer_counts["L1"] += 1
|
|
400
|
-
|
|
401
|
-
# L2 HybridRAG orchestration
|
|
402
|
-
if event.get("l2_hybridrag_active", 0) > 0:
|
|
403
|
-
layer_counts["L2"] += 1
|
|
404
|
-
|
|
405
|
-
if "graph" in layers or event.get("l3_graph_hits", 0) > 0:
|
|
406
|
-
layer_counts["L3"] += 1
|
|
407
|
-
if "vector" in layers or event.get("l4_vector_hits", 0) > 0:
|
|
408
|
-
layer_counts["L4"] += 1
|
|
409
|
-
|
|
410
|
-
# Count recent (last 24h)
|
|
411
|
-
event_time = datetime.fromisoformat(event["timestamp"].replace("Z", "+00:00"))
|
|
412
|
-
if (datetime.now(event_time.tzinfo) - event_time).days < 1:
|
|
413
|
-
recent_queries += 1
|
|
414
|
-
except Exception as e:
|
|
415
|
-
logging.debug(f"Suppressed: {e}")
|
|
416
|
-
|
|
417
|
-
return {
|
|
418
|
-
"total_queries": total_queries,
|
|
419
|
-
"recent_24h": recent_queries,
|
|
420
|
-
"layer_usage": layer_counts,
|
|
421
|
-
"layer_percentages": {
|
|
422
|
-
f"L{i}": round(count / max(total_queries, 1) * 100, 1)
|
|
423
|
-
for i, count in enumerate(layer_counts.values(), 1)
|
|
424
|
-
} if total_queries > 0 else {}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
except Exception as e:
|
|
428
|
-
return {"error": str(e)}
|
|
429
|
-
|
|
430
|
-
# ---------------------------------------------------------------------------
|
|
431
|
-
# Models
|
|
432
|
-
# ---------------------------------------------------------------------------
|
|
433
|
-
|
|
434
|
-
class ChatMessage(BaseModel):
|
|
435
|
-
role: str
|
|
436
|
-
content: str
|
|
437
|
-
|
|
438
|
-
class ChatCompletionRequest(BaseModel):
|
|
439
|
-
messages: List[ChatMessage]
|
|
440
|
-
model: str = "gpt-3.5-turbo"
|
|
441
|
-
max_tokens: int = 1000
|
|
442
|
-
temperature: float = 0.1
|
|
443
|
-
# Optional tenant scope. When absent, the L3 graph layer returns no
|
|
444
|
-
# results (rather than walking the global graph) — other layers
|
|
445
|
-
# still respond, so the call succeeds but with reduced L3 context.
|
|
446
|
-
# Existing single-tenant callers (benchmarks, dev) keep working.
|
|
447
|
-
arena: Optional[str] = None
|
|
448
|
-
arenas: Optional[List[str]] = None
|
|
449
|
-
|
|
450
|
-
class EmbeddingRequest(BaseModel):
|
|
451
|
-
input: Any
|
|
452
|
-
model: str = EMBEDDING_MODEL
|
|
453
|
-
|
|
454
|
-
# ---------------------------------------------------------------------------
|
|
455
|
-
# HybridRAG Processing Functions
|
|
456
|
-
# ---------------------------------------------------------------------------
|
|
457
|
-
|
|
458
|
-
def extract_query_entities(query: str) -> List[str]:
|
|
459
|
-
"""Extract potential entities from query (early detection)."""
|
|
460
|
-
import re
|
|
461
|
-
# Strip punctuation from words
|
|
462
|
-
words = [re.sub(r'[^\w\s-]', '', w).strip() for w in query.split()]
|
|
463
|
-
words = [w for w in words if w]
|
|
464
|
-
potential_entities = []
|
|
465
|
-
stop_words = {'what', 'who', 'where', 'when', 'how', 'does', 'did', 'the', 'and', 'for', 'with', 'from', 'about', 'this', 'that'}
|
|
466
|
-
|
|
467
|
-
# Look for capitalized words (proper nouns)
|
|
468
|
-
for word in words:
|
|
469
|
-
if word.istitle() and len(word) > 2 and word.lower() not in stop_words:
|
|
470
|
-
potential_entities.append(word)
|
|
471
|
-
|
|
472
|
-
# Look for multi-word entities (title case phrases)
|
|
473
|
-
for i in range(len(words) - 1):
|
|
474
|
-
if words[i].istitle() and words[i+1].istitle() and words[i].lower() not in stop_words:
|
|
475
|
-
potential_entities.append(f"{words[i]} {words[i+1]}")
|
|
476
|
-
|
|
477
|
-
log.info(f"Extracted entities: {potential_entities}")
|
|
478
|
-
return potential_entities
|
|
479
|
-
|
|
480
|
-
async def _hebbian_strengthen(session, arenas: List[str], node_names: List[str], increment: float = 0.05) -> None:
|
|
481
|
-
"""Hebbian: strengthen edges between co-accessed nodes during query.
|
|
482
|
-
|
|
483
|
-
Scoped by arena so a search inside tenant A can't reinforce edges
|
|
484
|
-
inside tenant B's graph (which would happen via shared entity-name
|
|
485
|
-
nodes pre-arena). When `arenas` is empty (single-tenant local dev,
|
|
486
|
-
benchmarks) we no-op rather than risk a cross-tenant write.
|
|
487
|
-
"""
|
|
488
|
-
if len(node_names) < 2 or not arenas:
|
|
489
|
-
return
|
|
490
|
-
now = datetime.utcnow().isoformat() + "Z"
|
|
491
|
-
for i, n1 in enumerate(node_names):
|
|
492
|
-
for n2 in node_names[i+1:]:
|
|
493
|
-
try:
|
|
494
|
-
await session.run(
|
|
495
|
-
"""MATCH (a {name: $n1})-[r]-(b {name: $n2})
|
|
496
|
-
WHERE a.arena IN $arenas AND b.arena IN $arenas
|
|
497
|
-
SET r.weight = coalesce(r.weight, 1.0) + $inc,
|
|
498
|
-
r.last_accessed = $now""",
|
|
499
|
-
n1=n1, n2=n2, arenas=arenas, inc=increment, now=now
|
|
500
|
-
)
|
|
501
|
-
except Exception:
|
|
502
|
-
pass # non-critical
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
async def search_neo4j_sequential(query: str, entities: List[str], arenas: List[str], limit: int = 12) -> Dict:
|
|
506
|
-
"""Phase 1: Neo4j graph search with spreading activation + Hebbian.
|
|
507
|
-
|
|
508
|
-
`arenas` is the tenant-scope set the caller is authorised for —
|
|
509
|
-
typically [clientId] or [clientId, clientId:userId]. Every Cypher
|
|
510
|
-
clause filters on `n.arena IN $arenas`, so a search from tenant A
|
|
511
|
-
can never traverse into entity nodes belonging to tenant B even
|
|
512
|
-
when their names collide. Empty `arenas` short-circuits to no
|
|
513
|
-
results — that's safer than walking the entire graph in dev/test.
|
|
514
|
-
"""
|
|
515
|
-
if not arenas:
|
|
516
|
-
log.warning("search_neo4j_sequential called without arenas — returning empty results")
|
|
517
|
-
return {"results": [], "graph_entities": [], "entity_count": 0}
|
|
518
|
-
try:
|
|
519
|
-
driver = get_neo4j_driver()
|
|
520
|
-
results = []
|
|
521
|
-
graph_entities = set()
|
|
522
|
-
|
|
523
|
-
async with driver.session() as session:
|
|
524
|
-
# Search for specific entities — use weighted spreading activation
|
|
525
|
-
for entity in entities:
|
|
526
|
-
# Direct match first — arena-scoped on every node we touch.
|
|
527
|
-
cypher = """
|
|
528
|
-
MATCH (n)
|
|
529
|
-
WHERE n.name CONTAINS $entity AND n.arena IN $arenas
|
|
530
|
-
OPTIONAL MATCH (n)-[r]-(connected)
|
|
531
|
-
WHERE connected.arena IN $arenas
|
|
532
|
-
AND coalesce(r.weight, 1.0) >= 0.2
|
|
533
|
-
RETURN n, r, connected, $entity as search_entity,
|
|
534
|
-
coalesce(r.weight, 1.0) AS edge_weight
|
|
535
|
-
ORDER BY edge_weight DESC
|
|
536
|
-
LIMIT $limit
|
|
537
|
-
"""
|
|
538
|
-
|
|
539
|
-
result = await session.run(cypher, entity=entity, arenas=arenas, limit=8)
|
|
540
|
-
|
|
541
|
-
async for record in result:
|
|
542
|
-
node = _serialize_neo4j_value(dict(record["n"]))
|
|
543
|
-
rel = record["r"]
|
|
544
|
-
connected = record["connected"]
|
|
545
|
-
search_entity = record["search_entity"]
|
|
546
|
-
edge_weight = record["edge_weight"]
|
|
547
|
-
|
|
548
|
-
context = f"Entity: {node.get('name', 'Unknown')} (type: {node.get('type', 'Unknown')})"
|
|
549
|
-
if rel and connected:
|
|
550
|
-
rel_type = type(rel).__name__ if rel else "CONNECTED_TO"
|
|
551
|
-
connected_dict = _serialize_neo4j_value(dict(connected)) if connected else {}
|
|
552
|
-
context += f" → {rel_type} → {connected_dict.get('name', 'Unknown')}"
|
|
553
|
-
if edge_weight != 1.0:
|
|
554
|
-
context += f" [weight: {edge_weight:.2f}]"
|
|
555
|
-
|
|
556
|
-
if 'source_file' in node:
|
|
557
|
-
context += f"\nSource: {node['source_file']}"
|
|
558
|
-
|
|
559
|
-
name = node.get('name', '')
|
|
560
|
-
graph_entities.add(name)
|
|
561
|
-
|
|
562
|
-
# Score boosted by edge weight (spreading activation)
|
|
563
|
-
score = min(0.95, 0.7 + (edge_weight * 0.1))
|
|
564
|
-
|
|
565
|
-
results.append({
|
|
566
|
-
"path": f"neo4j://entity/{search_entity}",
|
|
567
|
-
"text": context,
|
|
568
|
-
"score": score,
|
|
569
|
-
"source": "graph",
|
|
570
|
-
"entity": search_entity,
|
|
571
|
-
"node_data": node
|
|
572
|
-
})
|
|
573
|
-
|
|
574
|
-
# 2-hop spreading activation for high-weight paths.
|
|
575
|
-
# Every node along the walk must be in-arena. Without
|
|
576
|
-
# the filter, an activation could walk into another
|
|
577
|
-
# tenant's graph via a name-collision on the start node.
|
|
578
|
-
if entity:
|
|
579
|
-
activation_results = await session.run("""
|
|
580
|
-
MATCH (start)-[r1]-(mid)-[r2]-(end)
|
|
581
|
-
WHERE start.name CONTAINS $entity
|
|
582
|
-
AND start.arena IN $arenas
|
|
583
|
-
AND mid.arena IN $arenas
|
|
584
|
-
AND end.arena IN $arenas
|
|
585
|
-
AND coalesce(r1.weight, 1.0) >= 0.5
|
|
586
|
-
AND coalesce(r2.weight, 1.0) >= 0.5
|
|
587
|
-
AND start <> end
|
|
588
|
-
RETURN end,
|
|
589
|
-
coalesce(r1.weight, 1.0) * coalesce(r2.weight, 1.0) AS activation,
|
|
590
|
-
mid.name AS via
|
|
591
|
-
ORDER BY activation DESC
|
|
592
|
-
LIMIT 5
|
|
593
|
-
""", entity=entity, arenas=arenas)
|
|
594
|
-
|
|
595
|
-
async for rec in activation_results:
|
|
596
|
-
end_node = _serialize_neo4j_value(dict(rec["end"])) if rec["end"] else {}
|
|
597
|
-
name = end_node.get("name", "")
|
|
598
|
-
if name and name not in graph_entities:
|
|
599
|
-
graph_entities.add(name)
|
|
600
|
-
results.append({
|
|
601
|
-
"path": f"neo4j://activation/{entity}/{name}",
|
|
602
|
-
"text": f"Activated: {name} (via {rec['via']}, activation: {rec['activation']:.3f})",
|
|
603
|
-
"score": min(0.85, 0.5 + rec["activation"] * 0.05),
|
|
604
|
-
"source": "graph",
|
|
605
|
-
"entity": entity,
|
|
606
|
-
"node_data": end_node
|
|
607
|
-
})
|
|
608
|
-
|
|
609
|
-
# General query search if no specific entities found —
|
|
610
|
-
# arena-gated so the fallback can't walk other tenants'
|
|
611
|
-
# nodes when the heuristic entity extractor returned nothing.
|
|
612
|
-
if not results:
|
|
613
|
-
general_words = [w for w in query.split() if len(w) > 3 and w.lower() not in ['what', 'who', 'where', 'when', 'how']]
|
|
614
|
-
|
|
615
|
-
for word in general_words[:2]:
|
|
616
|
-
cypher = """
|
|
617
|
-
MATCH (n)
|
|
618
|
-
WHERE n.arena IN $arenas
|
|
619
|
-
AND ANY(prop IN keys(n) WHERE n[prop] IS :: STRING AND n[prop] CONTAINS $term)
|
|
620
|
-
OPTIONAL MATCH (n)-[r]-(connected)
|
|
621
|
-
WHERE connected.arena IN $arenas
|
|
622
|
-
RETURN n, r, connected
|
|
623
|
-
LIMIT $limit
|
|
624
|
-
"""
|
|
625
|
-
|
|
626
|
-
result = await session.run(cypher, term=word, arenas=arenas, limit=4)
|
|
627
|
-
|
|
628
|
-
async for record in result:
|
|
629
|
-
node = _serialize_neo4j_value(dict(record["n"]))
|
|
630
|
-
context = f"Related: {node}"
|
|
631
|
-
graph_entities.add(node.get('name', ''))
|
|
632
|
-
|
|
633
|
-
results.append({
|
|
634
|
-
"path": f"neo4j://search/{word}",
|
|
635
|
-
"text": context,
|
|
636
|
-
"score": 0.7,
|
|
637
|
-
"source": "graph",
|
|
638
|
-
"entity": word,
|
|
639
|
-
"node_data": node
|
|
640
|
-
})
|
|
641
|
-
|
|
642
|
-
# Hebbian: strengthen edges between all accessed entities
|
|
643
|
-
await _hebbian_strengthen(session, arenas, list(graph_entities))
|
|
644
|
-
|
|
645
|
-
# Note: driver is a module-level singleton, do NOT close here.
|
|
646
|
-
|
|
647
|
-
return {
|
|
648
|
-
"results": results[:limit],
|
|
649
|
-
"graph_entities": list(graph_entities),
|
|
650
|
-
"entity_count": len(graph_entities)
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
except Exception as e:
|
|
654
|
-
log.error(f"Neo4j search failed: {e}")
|
|
655
|
-
return {"results": [], "graph_entities": [], "entity_count": 0}
|
|
656
|
-
|
|
657
|
-
def get_embedding(text: str) -> List[float]:
|
|
658
|
-
"""Get embedding — tries NV-Embed-v2 (4096-dim) first, falls back to Ollama."""
|
|
659
|
-
# Try NV-Embed-v2 service first via the shared EmbedClient (handles
|
|
660
|
-
# provider selection, auth scheme, path, and 401 auto-detect).
|
|
661
|
-
if NV_EMBED_ENABLED:
|
|
662
|
-
try:
|
|
663
|
-
return _embed_client().embed_one(text)
|
|
664
|
-
except Exception as e:
|
|
665
|
-
log.warning(f"NV-Embed-v2 failed, falling back to Ollama: {e}")
|
|
666
|
-
|
|
667
|
-
# Fallback to Ollama
|
|
668
|
-
try:
|
|
669
|
-
r = requests.post(OLLAMA_URL, json={"model": EMBEDDING_MODEL, "prompt": text}, timeout=30)
|
|
670
|
-
r.raise_for_status()
|
|
671
|
-
return r.json()["embedding"]
|
|
672
|
-
except Exception as e:
|
|
673
|
-
log.error(f"Embedding failed (both NV-Embed-v2 and Ollama): {e}")
|
|
674
|
-
return []
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
# ---------------------------------------------------------------------------
|
|
678
|
-
# HyDE — Hypothetical Document Embeddings
|
|
679
|
-
# ---------------------------------------------------------------------------
|
|
680
|
-
|
|
681
|
-
HYDE_MODEL = os.environ.get("PME_HYDE_MODEL", "qwen2.5:7b")
|
|
682
|
-
HYDE_ENABLED = os.environ.get("PME_HYDE_ENABLED", "true").lower() == "true"
|
|
683
|
-
|
|
684
|
-
def hyde_expand(query: str) -> str:
|
|
685
|
-
"""Generate a hypothetical answer to the query, then concatenate with the
|
|
686
|
-
original query for richer vector search embeddings.
|
|
687
|
-
Uses a small local LLM via Ollama /api/generate."""
|
|
688
|
-
if not HYDE_ENABLED:
|
|
689
|
-
return query
|
|
690
|
-
try:
|
|
691
|
-
r = requests.post(
|
|
692
|
-
"http://localhost:11434/api/generate",
|
|
693
|
-
json={
|
|
694
|
-
"model": HYDE_MODEL,
|
|
695
|
-
"prompt": f"Answer this question in one concise sentence:\n{query}",
|
|
696
|
-
"stream": False,
|
|
697
|
-
"options": {"temperature": 0.3, "num_predict": 60},
|
|
698
|
-
},
|
|
699
|
-
timeout=15,
|
|
700
|
-
)
|
|
701
|
-
r.raise_for_status()
|
|
702
|
-
hypo = r.json().get("response", "").strip()
|
|
703
|
-
if hypo:
|
|
704
|
-
log.info(f"HyDE expansion: '{query}' → +'{hypo[:80]}…'")
|
|
705
|
-
return f"{query} {hypo}"
|
|
706
|
-
except Exception as e:
|
|
707
|
-
log.warning(f"HyDE expansion failed (falling back to raw query): {e}")
|
|
708
|
-
return query
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
# ---------------------------------------------------------------------------
|
|
712
|
-
# Cross-Encoder Reranking
|
|
713
|
-
# ---------------------------------------------------------------------------
|
|
714
|
-
|
|
715
|
-
RERANK_ENABLED = os.environ.get("PME_RERANK_ENABLED", "true").lower() == "true"
|
|
716
|
-
RERANK_WINDOW = int(os.environ.get("PME_RERANK_WINDOW", "5"))
|
|
717
|
-
|
|
718
|
-
def _cosine_sim(a: List[float], b: List[float]) -> float:
|
|
719
|
-
"""Cosine similarity between two vectors (handles mismatched dims)."""
|
|
720
|
-
if len(a) != len(b):
|
|
721
|
-
# Truncate to shorter length — still meaningful for cosine
|
|
722
|
-
min_len = min(len(a), len(b))
|
|
723
|
-
a, b = a[:min_len], b[:min_len]
|
|
724
|
-
dot = sum(x * y for x, y in zip(a, b))
|
|
725
|
-
na = sum(x * x for x in a) ** 0.5
|
|
726
|
-
nb = sum(x * x for x in b) ** 0.5
|
|
727
|
-
return dot / (na * nb) if na and nb else 0.0
|
|
728
|
-
|
|
729
|
-
def cross_encoder_rerank(query: str, results: List[Dict], top_k: int = 16) -> List[Dict]:
|
|
730
|
-
"""Re-embed the query and top-N result texts, then sort by cosine
|
|
731
|
-
similarity. Acts as a lightweight cross-encoder reranker without needing
|
|
732
|
-
a dedicated reranking model."""
|
|
733
|
-
if not RERANK_ENABLED or len(results) <= top_k:
|
|
734
|
-
return results
|
|
735
|
-
|
|
736
|
-
query_emb = get_embedding(query)
|
|
737
|
-
if not query_emb:
|
|
738
|
-
return results[:top_k]
|
|
739
|
-
|
|
740
|
-
window = results[:RERANK_WINDOW]
|
|
741
|
-
scored = []
|
|
742
|
-
for r in window:
|
|
743
|
-
# Get text from 'text' or 'content' field, fallback to path
|
|
744
|
-
text_content = r.get("text") or r.get("content") or r.get("path", "")
|
|
745
|
-
if not text_content:
|
|
746
|
-
scored.append(r)
|
|
747
|
-
continue
|
|
748
|
-
r_emb = get_embedding(text_content[:512])
|
|
749
|
-
if r_emb:
|
|
750
|
-
sim = _cosine_sim(query_emb, r_emb)
|
|
751
|
-
# Blend original score (layer priority) with cosine similarity
|
|
752
|
-
blended = 0.6 * r["score"] + 0.4 * sim
|
|
753
|
-
scored.append({**r, "score": round(blended, 4), "_rerank_sim": round(sim, 4)})
|
|
754
|
-
else:
|
|
755
|
-
scored.append(r)
|
|
756
|
-
|
|
757
|
-
scored.sort(key=lambda x: x["score"], reverse=True)
|
|
758
|
-
# Append remaining results (outside rerank window) unchanged
|
|
759
|
-
remaining = results[RERANK_WINDOW:]
|
|
760
|
-
return scored[:top_k] + remaining
|
|
761
|
-
|
|
762
|
-
def search_qmd_informed(query: str, graph_context: Dict, limit: int = 12) -> List[Dict]:
|
|
763
|
-
"""Phase 2: QMD vector search via sqlite-vec MATCH.
|
|
764
|
-
|
|
765
|
-
Replaces the legacy Python cosine loop over JSON-serialised embeddings
|
|
766
|
-
(which also had an `ORDER BY id LIMIT 2000` bug — only the OLDEST
|
|
767
|
-
2000 rows were ever considered, so 99%+ of the corpus was invisible to
|
|
768
|
-
search at production scale). Now: native KNN over the vec0 index,
|
|
769
|
-
full-corpus top-k. Wall time at 450k rows: ~50ms native MATCH vs
|
|
770
|
-
~15s timeout previously.
|
|
771
|
-
"""
|
|
772
|
-
if not os.path.exists(QMD_DB_PATH):
|
|
773
|
-
return []
|
|
774
|
-
|
|
775
|
-
query_embedding = get_embedding(query)
|
|
776
|
-
if not query_embedding:
|
|
777
|
-
return []
|
|
778
|
-
|
|
779
|
-
enhanced_query = query
|
|
780
|
-
if graph_context["graph_entities"]:
|
|
781
|
-
enhanced_query += " " + " ".join(graph_context["graph_entities"][:3])
|
|
782
|
-
enhanced_embedding = get_embedding(enhanced_query) or query_embedding
|
|
783
|
-
|
|
784
|
-
if len(enhanced_embedding) != EMBED_DIM:
|
|
785
|
-
# Dim mismatch vs vec0 DDL — the MATCH would error inside sqlite-vec.
|
|
786
|
-
# Bail with a loud log; an embedding-model mismatch in prod is the
|
|
787
|
-
# likely root cause and silent degradation would hide it.
|
|
788
|
-
log.error(
|
|
789
|
-
f"QMD search: query dim {len(enhanced_embedding)} != vec_index dim "
|
|
790
|
-
f"{EMBED_DIM} — embedding model mismatch?"
|
|
791
|
-
)
|
|
792
|
-
return []
|
|
793
|
-
qbytes = struct.pack(f"{len(enhanced_embedding)}f", *enhanced_embedding)
|
|
794
|
-
|
|
795
|
-
try:
|
|
796
|
-
conn = _open_qmd_conn()
|
|
797
|
-
# Pull a candidate pool larger than `limit` so entity-boost
|
|
798
|
-
# re-ranking has material to work with — 4× limit, floor 50.
|
|
799
|
-
k_pool = max(limit * 4, 50)
|
|
800
|
-
rows = conn.execute(
|
|
801
|
-
"""
|
|
802
|
-
SELECT c.id, c.path, c.text, v.distance
|
|
803
|
-
FROM vec_index v
|
|
804
|
-
JOIN chunks c ON c.id = v.id
|
|
805
|
-
WHERE v.embedding MATCH ? AND k = ?
|
|
806
|
-
ORDER BY v.distance
|
|
807
|
-
""",
|
|
808
|
-
(qbytes, k_pool),
|
|
809
|
-
).fetchall()
|
|
810
|
-
conn.close()
|
|
811
|
-
|
|
812
|
-
results = []
|
|
813
|
-
for row_id, path, text, distance in rows:
|
|
814
|
-
# vec0 distance_metric=cosine returns `1 - cos_sim` —
|
|
815
|
-
# invert to align with the rest of the codebase's `similarity`
|
|
816
|
-
# convention (1.0 = identical, 0.0 = orthogonal).
|
|
817
|
-
similarity = 1.0 - distance
|
|
818
|
-
if similarity <= 0.2:
|
|
819
|
-
continue
|
|
820
|
-
entity_boost = 0
|
|
821
|
-
path_lower = (path or "").lower()
|
|
822
|
-
text_lower = (text or "").lower()
|
|
823
|
-
for entity in graph_context["graph_entities"]:
|
|
824
|
-
el = entity.lower()
|
|
825
|
-
if el in path_lower or el in text_lower:
|
|
826
|
-
entity_boost = GRAPH_PRIORITY_BOOST
|
|
827
|
-
break
|
|
828
|
-
final_score = (similarity * VECTOR_BASE_WEIGHT) + entity_boost
|
|
829
|
-
results.append({
|
|
830
|
-
"path": path,
|
|
831
|
-
"text": (text or "")[:600],
|
|
832
|
-
"score": final_score,
|
|
833
|
-
"source": "vector",
|
|
834
|
-
"base_similarity": similarity,
|
|
835
|
-
"entity_boost": entity_boost,
|
|
836
|
-
})
|
|
837
|
-
results.sort(key=lambda x: x["score"], reverse=True)
|
|
838
|
-
return results[:limit]
|
|
839
|
-
|
|
840
|
-
except Exception as e:
|
|
841
|
-
log.error(f"QMD search failed: {e}")
|
|
842
|
-
return []
|
|
843
|
-
|
|
844
|
-
def search_core_memory_files(query: str, limit: int = 8) -> List[Dict]:
|
|
845
|
-
"""L1 System Files - Section-aware search with synonym expansion (v2)."""
|
|
846
|
-
core_files = [
|
|
847
|
-
"MEMORY.md",
|
|
848
|
-
"plans.md",
|
|
849
|
-
"SESSION-STATE.md",
|
|
850
|
-
"SOUL.md",
|
|
851
|
-
"USER.md",
|
|
852
|
-
f"memory/daily/{time.strftime('%Y-%m-%d')}.md", # Today's notes
|
|
853
|
-
f"memory/daily/{time.strftime('%Y-%m-%d', time.localtime(time.time() - 86400))}.md" # Yesterday
|
|
854
|
-
]
|
|
855
|
-
|
|
856
|
-
# Synonym expansion for common query terms
|
|
857
|
-
SYNONYMS = {
|
|
858
|
-
'birthday': ['birthday', 'born', 'birth', 'date'],
|
|
859
|
-
'car': ['car', 'vehicle', 'inster', 'hyundai'],
|
|
860
|
-
'wife': ['wife', 'spouse', 'partner', 'family'],
|
|
861
|
-
'budget': ['budget', 'cap', 'cost', 'monthly', 'spending'],
|
|
862
|
-
'port': ['port', 'listen', 'service', 'endpoint'],
|
|
863
|
-
'neo4j': ['neo4j', 'graph', 'knowledge'],
|
|
864
|
-
'tes': ['tes', 'thing', 'event', 'system'], # example: add domain-specific terms
|
|
865
|
-
'phone': ['phone', 'mobile', 'number', 'contact'],
|
|
866
|
-
'password': ['password', 'secret', 'credential'],
|
|
867
|
-
'home': ['home', 'address', 'live', 'residence'],
|
|
868
|
-
'hobbies': ['hobbies', 'hobby', 'interests', 'leisure'],
|
|
869
|
-
'hobby': ['hobbies', 'hobby', 'interests', 'leisure'],
|
|
870
|
-
'interests': ['hobbies', 'hobby', 'interests', 'leisure'],
|
|
871
|
-
'patent': ['patent', 'deadline', 'filing'], # example: legal terms
|
|
872
|
-
'deadline': ['deadline', 'patent', 'due', 'expiry'],
|
|
873
|
-
'dimensions': ['dimensions', 'embed', 'vector', 'dim', '4096'],
|
|
874
|
-
'embed': ['embed', 'embedding', 'dimensions', 'vector', 'nv'],
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
stop_words = {'what', 'where', 'when', 'how', 'who', 'which', 'does', 'the',
|
|
878
|
-
'is', 'are', 'was', 'were', 'for', 'and', 'with', 'has', 'have'}
|
|
879
|
-
|
|
880
|
-
query_lower = query.lower()
|
|
881
|
-
raw_keywords = [w for w in re.findall(r'\w+', query_lower) if len(w) > 2 and w not in stop_words]
|
|
882
|
-
|
|
883
|
-
# Expand with synonyms
|
|
884
|
-
expanded = set(raw_keywords)
|
|
885
|
-
for k in raw_keywords:
|
|
886
|
-
if k in SYNONYMS:
|
|
887
|
-
expanded.update(SYNONYMS[k])
|
|
888
|
-
keywords = list(expanded)
|
|
889
|
-
|
|
890
|
-
results = []
|
|
891
|
-
|
|
892
|
-
for file_path in core_files:
|
|
893
|
-
try:
|
|
894
|
-
full_path = WORKSPACE / file_path
|
|
895
|
-
if not full_path.exists():
|
|
896
|
-
continue
|
|
897
|
-
content = full_path.read_text(encoding='utf-8', errors='ignore')
|
|
898
|
-
|
|
899
|
-
# Split into sections by ## headers for granular matching
|
|
900
|
-
sections = re.split(r'\n(?=##\s)', content)
|
|
901
|
-
|
|
902
|
-
for section in sections:
|
|
903
|
-
lines = section.strip().split('\n')
|
|
904
|
-
header = lines[0] if lines and lines[0].startswith('#') else ""
|
|
905
|
-
section_lower = section.lower()
|
|
906
|
-
header_lower = header.lower()
|
|
907
|
-
|
|
908
|
-
# Score 1: Header keyword match (3x weight — header match is very precise)
|
|
909
|
-
header_score = sum(k in header_lower for k in keywords) * 3
|
|
910
|
-
|
|
911
|
-
# Score 2: Content keyword hits
|
|
912
|
-
content_score = sum(section_lower.count(k) for k in keywords)
|
|
913
|
-
|
|
914
|
-
# Score 3: Exact answer patterns (IPs, ports, dates, reg plates)
|
|
915
|
-
exact_patterns = re.findall(
|
|
916
|
-
r'\b(?:\d{1,3}\.){3}\d{1,3}\b' # IP addresses
|
|
917
|
-
r'|\b\d{4,5}\b' # port numbers
|
|
918
|
-
r'|[A-Z]{2}\d{2}[A-Z]{3}' # UK reg plates
|
|
919
|
-
r'|\b\d{1,2}\s+(?:January|February|March|April|May|June|July|'
|
|
920
|
-
r'August|September|October|November|December)\b', # dates
|
|
921
|
-
section
|
|
922
|
-
)
|
|
923
|
-
exact_score = len(exact_patterns)
|
|
924
|
-
|
|
925
|
-
total_score = header_score + content_score + exact_score
|
|
926
|
-
|
|
927
|
-
if total_score > 0:
|
|
928
|
-
results.append({
|
|
929
|
-
"path": file_path,
|
|
930
|
-
"text": section[:600],
|
|
931
|
-
"score": 1.0, # HIGHEST PRIORITY
|
|
932
|
-
"source": "core_memory",
|
|
933
|
-
"keyword_hits": total_score,
|
|
934
|
-
"file_type": "core",
|
|
935
|
-
"layer": "L1_system",
|
|
936
|
-
})
|
|
937
|
-
|
|
938
|
-
except Exception as e:
|
|
939
|
-
log.warning(f"Failed to read core file {file_path}: {e}")
|
|
940
|
-
continue
|
|
941
|
-
|
|
942
|
-
# Sort by total score, deduplicate by path+header prefix
|
|
943
|
-
results.sort(key=lambda x: x["keyword_hits"], reverse=True)
|
|
944
|
-
seen = set()
|
|
945
|
-
deduped = []
|
|
946
|
-
for r in results:
|
|
947
|
-
key = r["text"][:50]
|
|
948
|
-
if key not in seen:
|
|
949
|
-
seen.add(key)
|
|
950
|
-
deduped.append(r)
|
|
951
|
-
|
|
952
|
-
return deduped[:limit]
|
|
953
|
-
|
|
954
|
-
def extract_relevant_snippet(content: str, keywords: List[str], max_chars: int = 400) -> str:
|
|
955
|
-
"""Extract most relevant snippet from content around keywords."""
|
|
956
|
-
content_lower = content.lower()
|
|
957
|
-
best_pos = 0
|
|
958
|
-
best_score = 0
|
|
959
|
-
|
|
960
|
-
# Find position with highest keyword density
|
|
961
|
-
for i in range(0, len(content), 100):
|
|
962
|
-
window = content_lower[i:i + max_chars]
|
|
963
|
-
score = sum(window.count(keyword) for keyword in keywords)
|
|
964
|
-
if score > best_score:
|
|
965
|
-
best_score = score
|
|
966
|
-
best_pos = i
|
|
967
|
-
|
|
968
|
-
# Extract snippet around best position
|
|
969
|
-
start = max(0, best_pos - 50)
|
|
970
|
-
end = min(len(content), best_pos + max_chars + 50)
|
|
971
|
-
snippet = content[start:end].strip()
|
|
972
|
-
|
|
973
|
-
# Clean up to sentence boundaries if possible
|
|
974
|
-
if '. ' in snippet:
|
|
975
|
-
sentences = snippet.split('. ')
|
|
976
|
-
# Keep middle sentences (most likely to be complete)
|
|
977
|
-
if len(sentences) > 2:
|
|
978
|
-
snippet = '. '.join(sentences[1:-1]) + '.'
|
|
979
|
-
|
|
980
|
-
return snippet
|
|
981
|
-
|
|
982
|
-
# ---------------------------------------------------------------------------
|
|
983
|
-
# L0: Native BM25 Workspace Memory Search
|
|
984
|
-
# ---------------------------------------------------------------------------
|
|
985
|
-
|
|
986
|
-
L0_MEMORY_DB = Path(os.environ.get(
|
|
987
|
-
"PME_MEMORY_DB",
|
|
988
|
-
str(Path.home() / ".pentatonic" / "memory" / "main.sqlite"),
|
|
989
|
-
))
|
|
990
|
-
|
|
991
|
-
def search_l0_bm25(query: str, limit: int = 6, arena: str = None,
|
|
992
|
-
arenas: List[str] = None) -> List[Dict]:
|
|
993
|
-
"""Search native BM25 index over workspace memory files.
|
|
994
|
-
|
|
995
|
-
Covers chunks from daily notes, memory files, people profiles,
|
|
996
|
-
infrastructure docs, project files — corpus that L3-L6 don't index.
|
|
997
|
-
Sub-millisecond local SQLite reads, zero network overhead.
|
|
998
|
-
|
|
999
|
-
arena / arenas: when set, filter to paths under bench/<arena>/.
|
|
1000
|
-
Multi-arena queries (e.g. tenant-wide + user-scoped in one search)
|
|
1001
|
-
use OR'd path-prefix LIKE clauses. `arenas` wins when both are
|
|
1002
|
-
supplied; `arena` is treated as a one-element list for back-compat.
|
|
1003
|
-
"""
|
|
1004
|
-
if not L0_MEMORY_DB.exists():
|
|
1005
|
-
return []
|
|
1006
|
-
try:
|
|
1007
|
-
# Tokenize query for FTS5 match
|
|
1008
|
-
tokens = query.lower().split()
|
|
1009
|
-
meaningful = [t for t in tokens if len(t) > 2 and t not in {
|
|
1010
|
-
"the", "and", "for", "with", "that", "this", "from", "what",
|
|
1011
|
-
"how", "does", "have", "has", "are", "was", "were", "been",
|
|
1012
|
-
}]
|
|
1013
|
-
if not meaningful:
|
|
1014
|
-
return []
|
|
1015
|
-
fts_query = " OR ".join(f'"{t}"' for t in meaningful)
|
|
1016
|
-
|
|
1017
|
-
# Normalize single+multi arena inputs into one list.
|
|
1018
|
-
arena_list = list(arenas) if arenas else ([arena] if arena else [])
|
|
1019
|
-
|
|
1020
|
-
conn = sqlite3.connect(str(L0_MEMORY_DB), timeout=2)
|
|
1021
|
-
conn.execute("PRAGMA journal_mode=WAL")
|
|
1022
|
-
sql = """
|
|
1023
|
-
SELECT path, text, bm25(chunks_fts) as rank
|
|
1024
|
-
FROM chunks_fts
|
|
1025
|
-
WHERE chunks_fts MATCH ?
|
|
1026
|
-
AND path NOT LIKE '%/snapshots/%'
|
|
1027
|
-
AND path NOT LIKE '%/archive/%'
|
|
1028
|
-
AND path NOT LIKE '%-backup-%'
|
|
1029
|
-
"""
|
|
1030
|
-
params: list = [fts_query]
|
|
1031
|
-
if arena_list:
|
|
1032
|
-
clauses = " OR ".join(["path LIKE ?"] * len(arena_list))
|
|
1033
|
-
sql += f" AND ({clauses})"
|
|
1034
|
-
params.extend([f"bench/{a}/%" for a in arena_list])
|
|
1035
|
-
sql += " ORDER BY rank ASC LIMIT ?"
|
|
1036
|
-
params.append(limit * 2)
|
|
1037
|
-
rows = conn.execute(sql, params).fetchall()
|
|
1038
|
-
conn.close()
|
|
1039
|
-
|
|
1040
|
-
results = []
|
|
1041
|
-
seen_paths = set()
|
|
1042
|
-
for path, text, rank in rows:
|
|
1043
|
-
if path in seen_paths:
|
|
1044
|
-
continue
|
|
1045
|
-
seen_paths.add(path)
|
|
1046
|
-
relevance = -rank if rank < 0 else 0.001
|
|
1047
|
-
score = min(relevance / (1 + relevance) * 0.85, 0.75)
|
|
1048
|
-
# Parse arena from path (bench/<arena>/...) so downstream
|
|
1049
|
-
# consumers can read it directly without parsing again.
|
|
1050
|
-
row_arena = ""
|
|
1051
|
-
if path.startswith("bench/"):
|
|
1052
|
-
parts = path.split("/", 2)
|
|
1053
|
-
if len(parts) >= 3:
|
|
1054
|
-
row_arena = parts[1]
|
|
1055
|
-
results.append({
|
|
1056
|
-
"path": f"L0/{path}",
|
|
1057
|
-
"snippet": text[:500],
|
|
1058
|
-
"score": round(score, 4),
|
|
1059
|
-
"layer": "L0_workspace_bm25",
|
|
1060
|
-
"source": path,
|
|
1061
|
-
"arena": row_arena,
|
|
1062
|
-
})
|
|
1063
|
-
if len(results) >= limit:
|
|
1064
|
-
break
|
|
1065
|
-
return results
|
|
1066
|
-
except Exception as e:
|
|
1067
|
-
log.debug(f"L0 BM25 search error: {e}")
|
|
1068
|
-
return []
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
# ---------------------------------------------------------------------------
|
|
1072
|
-
# L5: Communications Context Search
|
|
1073
|
-
# ---------------------------------------------------------------------------
|
|
1074
|
-
|
|
1075
|
-
L5_API_URL = os.environ.get("PME_L5_URL", "http://127.0.0.1:8034")
|
|
1076
|
-
|
|
1077
|
-
async def search_l5_communications(query: str, limit: int = 6, arena: str = None,
|
|
1078
|
-
arenas: List[str] = None) -> List[Dict]:
|
|
1079
|
-
"""Search L5 Communications Context via L5 API (emails, chats, calendar).
|
|
1080
|
-
|
|
1081
|
-
arena / arenas (optional): forwarded to L5; filters Milvus by the
|
|
1082
|
-
arena dynamic field. Multi-arena calls become a Milvus
|
|
1083
|
-
`arena IN ["X","Y"]` filter expression on the L5 side.
|
|
1084
|
-
"""
|
|
1085
|
-
try:
|
|
1086
|
-
# Build a list of (key, value) tuples so multi-valued query
|
|
1087
|
-
# params (?arenas=A&arenas=B) wire-shape correctly.
|
|
1088
|
-
arena_list = list(arenas) if arenas else ([arena] if arena else [])
|
|
1089
|
-
params: list = [("q", query), ("limit", str(limit))]
|
|
1090
|
-
for a in arena_list:
|
|
1091
|
-
params.append(("arenas", a))
|
|
1092
|
-
resp = await get_http_client().get(
|
|
1093
|
-
f"{L5_API_URL}/search",
|
|
1094
|
-
params=params,
|
|
1095
|
-
timeout=10,
|
|
1096
|
-
)
|
|
1097
|
-
if resp.status_code != 200:
|
|
1098
|
-
return []
|
|
1099
|
-
data = resp.json()
|
|
1100
|
-
results = []
|
|
1101
|
-
for hit in data.get("results", []):
|
|
1102
|
-
source = hit.get("source", "")
|
|
1103
|
-
score = hit.get("score", 0)
|
|
1104
|
-
# Scale Milvus cosine similarity to HybridRAG range
|
|
1105
|
-
# Milvus returns 0.4-0.7 for relevant results; boost to compete with other layers
|
|
1106
|
-
scaled_score = round(min((score - 0.3) * 2.0 + 0.5, 0.82), 4)
|
|
1107
|
-
if scaled_score < 0.4:
|
|
1108
|
-
continue # skip low relevance
|
|
1109
|
-
contact = hit.get("contact", "")
|
|
1110
|
-
channel = hit.get("channel", "")
|
|
1111
|
-
hit_id = hit.get("id", "")
|
|
1112
|
-
# Use record id as path label so the shim can attach
|
|
1113
|
-
# metadata via _META_CACHE; falls back to source label
|
|
1114
|
-
# for legacy records that have no id.
|
|
1115
|
-
path_label = hit_id or f"L5/{source}"
|
|
1116
|
-
if not hit_id and contact:
|
|
1117
|
-
path_label = f"L5/{channel}/{contact}"
|
|
1118
|
-
results.append({
|
|
1119
|
-
"id": hit_id,
|
|
1120
|
-
"path": path_label,
|
|
1121
|
-
"snippet": hit.get("text", "")[:500],
|
|
1122
|
-
"score": scaled_score,
|
|
1123
|
-
"layer": "L5_communications",
|
|
1124
|
-
"source": source,
|
|
1125
|
-
"collection": hit.get("collection", ""),
|
|
1126
|
-
"timestamp": hit.get("timestamp", ""),
|
|
1127
|
-
"arena": hit.get("arena", ""),
|
|
1128
|
-
})
|
|
1129
|
-
return results
|
|
1130
|
-
except Exception as e:
|
|
1131
|
-
log.debug(f"L5 search error: {e}")
|
|
1132
|
-
return []
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
# L6: Document Store Search
|
|
1136
|
-
L6_URL = os.environ.get("PME_L6_URL", "http://localhost:8037")
|
|
1137
|
-
|
|
1138
|
-
async def search_l6_documents(query: str, limit: int = 6, arena: str = None,
|
|
1139
|
-
arenas: List[str] = None) -> List[Dict]:
|
|
1140
|
-
"""Search L6 Document Store (research, legal, financial, project docs).
|
|
1141
|
-
|
|
1142
|
-
arena / arenas (optional): forwarded to L6 — L6 supports multi-arena
|
|
1143
|
-
natively (see l6-document-store.py search_vector / search_fts).
|
|
1144
|
-
"""
|
|
1145
|
-
try:
|
|
1146
|
-
arena_list = list(arenas) if arenas else ([arena] if arena else [])
|
|
1147
|
-
params: list = [
|
|
1148
|
-
("q", query),
|
|
1149
|
-
("method", "hybrid"),
|
|
1150
|
-
("limit", str(limit)),
|
|
1151
|
-
("rerank", "true"),
|
|
1152
|
-
]
|
|
1153
|
-
for a in arena_list:
|
|
1154
|
-
params.append(("arenas", a))
|
|
1155
|
-
resp = await get_http_client().get(
|
|
1156
|
-
f"{L6_URL}/search",
|
|
1157
|
-
params=params,
|
|
1158
|
-
timeout=10,
|
|
1159
|
-
)
|
|
1160
|
-
if resp.status_code != 200:
|
|
1161
|
-
return []
|
|
1162
|
-
data = resp.json()
|
|
1163
|
-
results = []
|
|
1164
|
-
for hit in data.get("results", []):
|
|
1165
|
-
source = hit.get("source_file", "")
|
|
1166
|
-
heading = hit.get("heading", "")
|
|
1167
|
-
arena = hit.get("arena", "")
|
|
1168
|
-
rrf = hit.get("rrf_score", 0)
|
|
1169
|
-
reranker = hit.get("reranker_score", None)
|
|
1170
|
-
|
|
1171
|
-
# Scale score: RRF max is ~0.033, normalize to 0.0-0.82 range
|
|
1172
|
-
# (below L1's 1.0, comparable to L3/L4/L5)
|
|
1173
|
-
score = min(rrf * 25, 0.82)
|
|
1174
|
-
if reranker is not None and reranker > 0:
|
|
1175
|
-
score = min(score + 0.05, 0.85)
|
|
1176
|
-
|
|
1177
|
-
path_label = f"L6/{arena}/{Path(source).name}" if arena else f"L6/{Path(source).name}"
|
|
1178
|
-
if heading:
|
|
1179
|
-
path_label += f": {heading[:50]}"
|
|
1180
|
-
|
|
1181
|
-
snippet = hit.get("text", "")[:500]
|
|
1182
|
-
entities = hit.get("entities", [])
|
|
1183
|
-
if entities:
|
|
1184
|
-
snippet += f"\n[Entities: {', '.join(entities[:5])}]"
|
|
1185
|
-
|
|
1186
|
-
results.append({
|
|
1187
|
-
"path": f"l6:{Path(source).stem}:{hit.get('chunk_index', 0)}",
|
|
1188
|
-
"snippet": snippet,
|
|
1189
|
-
"score": round(score, 4),
|
|
1190
|
-
"layer": "L6_documents",
|
|
1191
|
-
"source": source,
|
|
1192
|
-
"arena": arena,
|
|
1193
|
-
"doc_type": hit.get("doc_type", ""),
|
|
1194
|
-
"engines": hit.get("engines", []),
|
|
1195
|
-
})
|
|
1196
|
-
return results
|
|
1197
|
-
except Exception as e:
|
|
1198
|
-
log.debug(f"L6 search error: {e}")
|
|
1199
|
-
return []
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
async def sequential_hybridrag_search(query: str, limit: int = 16,
|
|
1203
|
-
arena: str = None,
|
|
1204
|
-
arenas: List[str] = None) -> List[Dict]:
|
|
1205
|
-
"""Main HybridRAG processing: L0 BM25 → L1 System Files → L2 HybridRAG (L3 Graph + L4 Vector + L5 Comms + L6 Docs).
|
|
1206
|
-
|
|
1207
|
-
arena / arenas (optional): tenant + user scope. Multi-arena lets a
|
|
1208
|
-
user's search span tenant-wide rows + their own user-scoped rows in
|
|
1209
|
-
a single hybrid pass. Forwarded to L0, L5, L6 native filters; L4
|
|
1210
|
-
and L3 still rely on the compat shim post-filter.
|
|
1211
|
-
|
|
1212
|
-
Async since v0.8.4: independent layer fan-out runs concurrently via
|
|
1213
|
-
asyncio.gather; sync workers (sqlite, file I/O, PyTorch reranker)
|
|
1214
|
-
are dispatched to threads via asyncio.to_thread to keep the event
|
|
1215
|
-
loop responsive under sustained ingest.
|
|
1216
|
-
"""
|
|
1217
|
-
arena_list = list(arenas) if arenas else ([arena] if arena else [])
|
|
1218
|
-
start_time = time.time()
|
|
1219
|
-
log.info(f"Starting sequential HybridRAG search for: '{query}' arenas={arena_list!r}")
|
|
1220
|
-
|
|
1221
|
-
# L0 (sqlite) + L1 (file I/O) + entity extraction run in parallel.
|
|
1222
|
-
# Each is sync — offloaded to the threadpool so the event loop stays
|
|
1223
|
-
# free for the in-flight L3/L5/L6 calls.
|
|
1224
|
-
l0_results, system_results, entities = await asyncio.gather(
|
|
1225
|
-
asyncio.to_thread(search_l0_bm25, query, 6, None, arena_list),
|
|
1226
|
-
asyncio.to_thread(search_core_memory_files, query, 4),
|
|
1227
|
-
asyncio.to_thread(extract_query_entities, query),
|
|
1228
|
-
)
|
|
1229
|
-
log.info(f"L0 BM25 workspace: {len(l0_results)} results")
|
|
1230
|
-
log.info(f"L1 System files: {len(system_results)} results")
|
|
1231
|
-
|
|
1232
|
-
# L3: Graph search (now native async via AsyncGraphDatabase).
|
|
1233
|
-
graph_context = await search_neo4j_sequential(query, entities, arena_list, limit=8)
|
|
1234
|
-
log.info(f"L3 Graph search: {len(graph_context['results'])} results, {graph_context['entity_count']} entities")
|
|
1235
|
-
|
|
1236
|
-
# HyDE expansion is sync (LLM call), offload to thread.
|
|
1237
|
-
hyde_query = await asyncio.to_thread(hyde_expand, query)
|
|
1238
|
-
|
|
1239
|
-
# L4/L5/L6 fan out concurrently — L4 (sqlite) via to_thread, L5/L6
|
|
1240
|
-
# native async via httpx.AsyncClient.
|
|
1241
|
-
vector_results, l5_results, l6_results = await asyncio.gather(
|
|
1242
|
-
asyncio.to_thread(search_qmd_informed, hyde_query, graph_context, 8),
|
|
1243
|
-
search_l5_communications(hyde_query, limit=6, arenas=arena_list),
|
|
1244
|
-
search_l6_documents(hyde_query, limit=6, arenas=arena_list),
|
|
1245
|
-
)
|
|
1246
|
-
log.info(f"L4 Vector search: {len(vector_results)} results (HyDE={'on' if hyde_query != query else 'off'})")
|
|
1247
|
-
log.info(f"L5 Communications: {len(l5_results)} results")
|
|
1248
|
-
log.info(f"L6 Documents: {len(l6_results)} results")
|
|
1249
|
-
|
|
1250
|
-
# L2: HybridRAG fusion (combines all layers with L1 priority)
|
|
1251
|
-
all_results = l0_results + system_results + graph_context["results"] + vector_results + l5_results + l6_results
|
|
1252
|
-
|
|
1253
|
-
# Remove duplicates by path
|
|
1254
|
-
seen_paths = set()
|
|
1255
|
-
deduplicated = []
|
|
1256
|
-
for result in all_results:
|
|
1257
|
-
if result["path"] not in seen_paths:
|
|
1258
|
-
deduplicated.append(result)
|
|
1259
|
-
seen_paths.add(result["path"])
|
|
1260
|
-
|
|
1261
|
-
# Sort by layer priority: L1 System (1.0) > L3 Graph (0.9) > L4 Vector (0.7+)
|
|
1262
|
-
deduplicated.sort(key=lambda x: x["score"], reverse=True)
|
|
1263
|
-
|
|
1264
|
-
# Cross-encoder reranking: re-embed top results and blend scores.
|
|
1265
|
-
# PyTorch CrossEncoder.predict is sync — offload to thread.
|
|
1266
|
-
deduplicated = await asyncio.to_thread(cross_encoder_rerank, query, deduplicated, limit)
|
|
1267
|
-
|
|
1268
|
-
# Track layer usage for evolution
|
|
1269
|
-
search_time_ms = (time.time() - start_time) * 1000
|
|
1270
|
-
layers_used = []
|
|
1271
|
-
if len(l0_results) > 0:
|
|
1272
|
-
layers_used.append("workspace_bm25")
|
|
1273
|
-
if len(system_results) > 0:
|
|
1274
|
-
layers_used.append("system")
|
|
1275
|
-
if len(graph_context["results"]) > 0:
|
|
1276
|
-
layers_used.append("graph")
|
|
1277
|
-
if len(vector_results) > 0:
|
|
1278
|
-
layers_used.append("vector")
|
|
1279
|
-
if len(l5_results) > 0:
|
|
1280
|
-
layers_used.append("communications")
|
|
1281
|
-
if len(l6_results) > 0:
|
|
1282
|
-
layers_used.append("documents")
|
|
1283
|
-
|
|
1284
|
-
log_memory_usage(
|
|
1285
|
-
query=query,
|
|
1286
|
-
layers_hit=layers_used,
|
|
1287
|
-
l1_hits=len(system_results),
|
|
1288
|
-
l3_hits=len(graph_context["results"]),
|
|
1289
|
-
l4_hits=len(vector_results),
|
|
1290
|
-
search_time_ms=search_time_ms,
|
|
1291
|
-
entities_extracted=entities
|
|
1292
|
-
)
|
|
1293
|
-
|
|
1294
|
-
log.info(f"L2 HybridRAG final: {len(deduplicated[:limit])} total results")
|
|
1295
|
-
return deduplicated[:limit]
|
|
1296
|
-
|
|
1297
|
-
# ---------------------------------------------------------------------------
|
|
1298
|
-
# API Endpoints
|
|
1299
|
-
# ---------------------------------------------------------------------------
|
|
1300
|
-
|
|
1301
|
-
@app.post("/v1/search")
|
|
1302
|
-
async def search_endpoint(request: Request) -> dict:
|
|
1303
|
-
"""Direct L1→L3→L4 cascade search. Returns structured results for programmatic use."""
|
|
1304
|
-
try:
|
|
1305
|
-
body = await request.json()
|
|
1306
|
-
query = body.get("query", "")
|
|
1307
|
-
limit = body.get("limit", 16)
|
|
1308
|
-
arena = body.get("arena") or None
|
|
1309
|
-
arenas = body.get("arenas") or None
|
|
1310
|
-
if not query:
|
|
1311
|
-
raise HTTPException(status_code=400, detail="query is required")
|
|
1312
|
-
|
|
1313
|
-
results = await sequential_hybridrag_search(query, limit=limit, arena=arena, arenas=arenas)
|
|
1314
|
-
|
|
1315
|
-
# Also return raw graph entities for context enrichment.
|
|
1316
|
-
# Same arena scope as the cascade search above — without it
|
|
1317
|
-
# the entities returned could include cross-tenant rows.
|
|
1318
|
-
arena_list = list(arenas) if arenas else ([arena] if arena else [])
|
|
1319
|
-
entities = extract_query_entities(query)
|
|
1320
|
-
graph_context = await search_neo4j_sequential(query, entities, arena_list, limit=8)
|
|
1321
|
-
|
|
1322
|
-
return {
|
|
1323
|
-
"results": results,
|
|
1324
|
-
"entities": entities,
|
|
1325
|
-
"graph_nodes": graph_context.get("entity_count", 0),
|
|
1326
|
-
"graph_entities": graph_context.get("graph_entities", []),
|
|
1327
|
-
"layers_active": {
|
|
1328
|
-
"L1_system": True,
|
|
1329
|
-
"L3_graph": True,
|
|
1330
|
-
"L4_vector": True,
|
|
1331
|
-
"L5_communications": True
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
except HTTPException:
|
|
1335
|
-
raise
|
|
1336
|
-
except Exception as e:
|
|
1337
|
-
log.error(f"Search endpoint error: {e}")
|
|
1338
|
-
raise HTTPException(status_code=500, detail=str(e))
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
@app.get("/v1/models")
|
|
1342
|
-
async def list_models() -> dict:
|
|
1343
|
-
"""OpenAI-compatible model listing."""
|
|
1344
|
-
return {
|
|
1345
|
-
"object": "list",
|
|
1346
|
-
"data": [
|
|
1347
|
-
{"id": "gpt-3.5-turbo", "object": "model", "owned_by": "sequential-hybridrag"}
|
|
1348
|
-
]
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
@app.post("/v1/embeddings")
|
|
1352
|
-
async def create_embeddings(request: EmbeddingRequest) -> dict:
|
|
1353
|
-
"""Pass-through to NV-Embed-v2 (4096-dim). Batch-native — forwards the full
|
|
1354
|
-
input list in a single HTTP call instead of looping one-at-a-time.
|
|
1355
|
-
|
|
1356
|
-
Returns OpenAI-shaped response regardless of upstream provider, so
|
|
1357
|
-
callers (including L4 search and external clients) get a consistent
|
|
1358
|
-
contract from this proxy."""
|
|
1359
|
-
try:
|
|
1360
|
-
inputs = [request.input] if isinstance(request.input, str) else request.input
|
|
1361
|
-
embeddings = await _embed_client().embed_batch_async(inputs)
|
|
1362
|
-
return {
|
|
1363
|
-
"object": "list",
|
|
1364
|
-
"model": request.model or "nv-embed-v2",
|
|
1365
|
-
"data": [
|
|
1366
|
-
{"object": "embedding", "embedding": e, "index": i}
|
|
1367
|
-
for i, e in enumerate(embeddings)
|
|
1368
|
-
],
|
|
1369
|
-
"usage": {"prompt_tokens": 0, "total_tokens": 0},
|
|
1370
|
-
}
|
|
1371
|
-
except Exception as e:
|
|
1372
|
-
raise HTTPException(status_code=500, detail=str(e))
|
|
1373
|
-
|
|
1374
|
-
@app.post("/v1/chat/completions")
|
|
1375
|
-
async def chat_completions(request: ChatCompletionRequest) -> dict:
|
|
1376
|
-
"""Sequential HybridRAG memory search."""
|
|
1377
|
-
try:
|
|
1378
|
-
# Extract query from last user message
|
|
1379
|
-
user_messages = [m for m in request.messages if m.role == "user"]
|
|
1380
|
-
if not user_messages:
|
|
1381
|
-
raise HTTPException(status_code=400, detail="No user message found")
|
|
1382
|
-
|
|
1383
|
-
query = user_messages[-1].content
|
|
1384
|
-
|
|
1385
|
-
# Perform sequential HybridRAG search — pass through tenant
|
|
1386
|
-
# scope from the request so L3 graph traversal stays inside the
|
|
1387
|
-
# caller's arena. The search function short-circuits L3 to
|
|
1388
|
-
# empty when no arenas are supplied; callers that need L3 must
|
|
1389
|
-
# pass `arena` or `arenas` on the request body.
|
|
1390
|
-
start_time = time.time()
|
|
1391
|
-
results = await sequential_hybridrag_search(
|
|
1392
|
-
query, limit=16, arena=request.arena, arenas=request.arenas,
|
|
1393
|
-
)
|
|
1394
|
-
search_time = time.time() - start_time
|
|
1395
|
-
|
|
1396
|
-
# Format results with correct layer structure
|
|
1397
|
-
context_parts = []
|
|
1398
|
-
system_count = sum(1 for r in results if r["source"] == "core_memory")
|
|
1399
|
-
graph_count = sum(1 for r in results if r["source"] == "graph")
|
|
1400
|
-
vector_count = sum(1 for r in results if r["source"] == "vector")
|
|
1401
|
-
|
|
1402
|
-
context_parts.append(f"# HybridRAG Results (L1 System → L2 HybridRAG → L3 Graph → L4 Vector)")
|
|
1403
|
-
context_parts.append(f"Query: {query}")
|
|
1404
|
-
context_parts.append(f"Results: {system_count} system + {graph_count} graph + {vector_count} vector = {len(results)} total")
|
|
1405
|
-
context_parts.append(f"Search time: {search_time:.3f}s")
|
|
1406
|
-
context_parts.append("")
|
|
1407
|
-
|
|
1408
|
-
current_tier = None
|
|
1409
|
-
for i, result in enumerate(results):
|
|
1410
|
-
# Group by layer for clarity
|
|
1411
|
-
source = result['source']
|
|
1412
|
-
if source != current_tier:
|
|
1413
|
-
if source == "core_memory":
|
|
1414
|
-
context_parts.append(f"## L1 SYSTEM FILES (Highest Priority)")
|
|
1415
|
-
elif source == "graph":
|
|
1416
|
-
context_parts.append(f"## L3 GRAPH SEARCH (via L2 HybridRAG)")
|
|
1417
|
-
elif source == "vector":
|
|
1418
|
-
context_parts.append(f"## L4 VECTOR SEARCH (via L2 HybridRAG)")
|
|
1419
|
-
context_parts.append("")
|
|
1420
|
-
current_tier = source
|
|
1421
|
-
|
|
1422
|
-
context_parts.append(f"**{result['path']}** (score: {result['score']:.3f})")
|
|
1423
|
-
context_parts.append("")
|
|
1424
|
-
context_parts.append(result['text'][:800])
|
|
1425
|
-
context_parts.append("")
|
|
1426
|
-
|
|
1427
|
-
response_content = "\n".join(context_parts) if context_parts else "No relevant context found."
|
|
1428
|
-
|
|
1429
|
-
return {
|
|
1430
|
-
"id": f"seq-hybridrag-{int(time.time())}",
|
|
1431
|
-
"object": "chat.completion",
|
|
1432
|
-
"choices": [{
|
|
1433
|
-
"message": {
|
|
1434
|
-
"role": "assistant",
|
|
1435
|
-
"content": response_content
|
|
1436
|
-
},
|
|
1437
|
-
"index": 0,
|
|
1438
|
-
"finish_reason": "stop"
|
|
1439
|
-
}],
|
|
1440
|
-
"usage": {"total_tokens": len(response_content)},
|
|
1441
|
-
"model": request.model
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
except Exception as e:
|
|
1445
|
-
log.error(f"HybridRAG search failed: {e}")
|
|
1446
|
-
raise HTTPException(status_code=500, detail=str(e))
|
|
1447
|
-
|
|
1448
|
-
@app.get("/contradictions/{node_name}")
|
|
1449
|
-
async def check_contradictions(node_name: str, arena: Optional[str] = None) -> dict:
|
|
1450
|
-
"""Detect contradictions around a named node.
|
|
1451
|
-
|
|
1452
|
-
`arena` is required to scope the lookup to one tenant's graph. The
|
|
1453
|
-
endpoint returns a 400 when called without it — silently spanning
|
|
1454
|
-
the entire graph here would leak entity names across tenants via
|
|
1455
|
-
the `node_name` lookup.
|
|
1456
|
-
"""
|
|
1457
|
-
if not arena:
|
|
1458
|
-
raise HTTPException(
|
|
1459
|
-
status_code=400,
|
|
1460
|
-
detail="arena query parameter is required to scope contradiction lookup",
|
|
1461
|
-
)
|
|
1462
|
-
try:
|
|
1463
|
-
driver = get_neo4j_driver()
|
|
1464
|
-
contradictions = []
|
|
1465
|
-
async with driver.session() as session:
|
|
1466
|
-
# Find the node — must be in the caller's arena.
|
|
1467
|
-
result = await session.run(
|
|
1468
|
-
"""MATCH (n) WHERE toLower(n.name) = toLower($name) AND n.arena = $arena
|
|
1469
|
-
RETURN elementId(n) AS id""",
|
|
1470
|
-
name=node_name, arena=arena,
|
|
1471
|
-
)
|
|
1472
|
-
node = await result.single()
|
|
1473
|
-
if not node:
|
|
1474
|
-
return {"node": node_name, "contradictions": [], "error": "Node not found"}
|
|
1475
|
-
nid = node["id"]
|
|
1476
|
-
|
|
1477
|
-
# Explicit CONTRADICTS — both endpoints must be in the same arena.
|
|
1478
|
-
result = await session.run(
|
|
1479
|
-
"""MATCH (a)-[r:CONTRADICTS]-(b)
|
|
1480
|
-
WHERE elementId(a) = $nid AND b.arena = $arena
|
|
1481
|
-
RETURN a.name AS a, b.name AS b, r.reason AS reason""",
|
|
1482
|
-
nid=nid, arena=arena,
|
|
1483
|
-
)
|
|
1484
|
-
async for rec in result:
|
|
1485
|
-
contradictions.append({"type": "explicit", "a": rec["a"], "b": rec["b"], "reason": rec["reason"]})
|
|
1486
|
-
|
|
1487
|
-
# Property conflicts via shared neighbour — every node along
|
|
1488
|
-
# the (a)--(shared)--(b) path filtered by arena so a shared
|
|
1489
|
-
# neighbour from another tenant can't trigger a false-positive
|
|
1490
|
-
# conflict in this tenant's view.
|
|
1491
|
-
result = await session.run(
|
|
1492
|
-
"""MATCH (a)--(shared)--(b)
|
|
1493
|
-
WHERE elementId(a) = $nid AND a <> b
|
|
1494
|
-
AND shared.arena = $arena AND b.arena = $arena
|
|
1495
|
-
WITH a, b, shared, properties(a) AS pa, properties(b) AS pb
|
|
1496
|
-
WITH a, b, shared,
|
|
1497
|
-
[k IN keys(pa) WHERE k IN keys(pb) AND pa[k] <> pb[k]
|
|
1498
|
-
AND NOT k IN ['last_accessed','embedding','created_at','updated_at','id','weight']] AS ck
|
|
1499
|
-
WHERE size(ck) > 0
|
|
1500
|
-
RETURN a.name AS a, b.name AS b, shared.name AS via, ck
|
|
1501
|
-
LIMIT 10""", nid=nid, arena=arena,
|
|
1502
|
-
)
|
|
1503
|
-
async for rec in result:
|
|
1504
|
-
contradictions.append({
|
|
1505
|
-
"type": "property_conflict", "a": rec["a"], "b": rec["b"],
|
|
1506
|
-
"via": rec["via"], "conflicting_keys": rec["ck"]
|
|
1507
|
-
})
|
|
1508
|
-
return {"node": node_name, "contradictions": contradictions, "count": len(contradictions)}
|
|
1509
|
-
except Exception as e:
|
|
1510
|
-
raise HTTPException(status_code=500, detail=str(e))
|
|
1511
|
-
|
|
1512
|
-
async def _check_l5_health() -> bool:
|
|
1513
|
-
"""Quick check if L5 Communications API is responding."""
|
|
1514
|
-
try:
|
|
1515
|
-
resp = await get_http_client().get(f"{L5_API_URL}/health", timeout=3)
|
|
1516
|
-
return resp.status_code == 200
|
|
1517
|
-
except Exception:
|
|
1518
|
-
return False
|
|
1519
|
-
|
|
1520
|
-
async def _check_l6_health() -> bool:
|
|
1521
|
-
"""Quick check if L6 Document Store is responding."""
|
|
1522
|
-
try:
|
|
1523
|
-
resp = await get_http_client().get(f"{L6_URL}/health", timeout=3)
|
|
1524
|
-
return resp.status_code == 200 and resp.json().get("status") in ("ok", "degraded")
|
|
1525
|
-
except Exception:
|
|
1526
|
-
return False
|
|
1527
|
-
|
|
1528
|
-
@app.get("/health")
|
|
1529
|
-
async def health() -> dict:
|
|
1530
|
-
"""System health check.
|
|
1531
|
-
|
|
1532
|
-
Reports "ok" iff every layer L2 directly owns is healthy: L0 BM25
|
|
1533
|
-
(SQLite FTS5 file), L4 QMD vector store (sqlite file), and the
|
|
1534
|
-
Neo4j connection. L5/L6 reachability is reported informationally
|
|
1535
|
-
only — the compat shim probes them directly. Ollama is no longer
|
|
1536
|
-
a hard dependency anywhere; the engine uses the configured
|
|
1537
|
-
NV_EMBED_URL via _embed_post helpers in each layer.
|
|
1538
|
-
"""
|
|
1539
|
-
qmd_healthy = os.path.exists(QMD_DB_PATH)
|
|
1540
|
-
l0_healthy = L0_MEMORY_DB.exists()
|
|
1541
|
-
|
|
1542
|
-
neo4j_healthy = False
|
|
1543
|
-
try:
|
|
1544
|
-
driver = get_neo4j_driver()
|
|
1545
|
-
async with driver.session() as session:
|
|
1546
|
-
await session.run("RETURN 1")
|
|
1547
|
-
neo4j_healthy = True
|
|
1548
|
-
except Exception as e:
|
|
1549
|
-
logging.debug(f"Suppressed: {e}")
|
|
1550
|
-
|
|
1551
|
-
l5_reachable = await _check_l5_health()
|
|
1552
|
-
l6_reachable = await _check_l6_health()
|
|
1553
|
-
|
|
1554
|
-
# Top-level status: degrade only on layers L2 is the sole gatekeeper for.
|
|
1555
|
-
# L5/L6 are independent services probed by the compat shim.
|
|
1556
|
-
must_be_ok = [l0_healthy, qmd_healthy, neo4j_healthy]
|
|
1557
|
-
overall = "ok" if all(must_be_ok) else "degraded"
|
|
1558
|
-
|
|
1559
|
-
return {
|
|
1560
|
-
"status": overall,
|
|
1561
|
-
"proxy": "healthy",
|
|
1562
|
-
"architecture": "sequential-hybridrag-proper-layers",
|
|
1563
|
-
"layers": {
|
|
1564
|
-
"L0_workspace_bm25": {"status": "healthy" if l0_healthy else "unavailable", "backend": "sqlite-fts5"},
|
|
1565
|
-
"L1_system_files": {"status": "healthy", "description": "MEMORY.md, plans.md, daily notes"},
|
|
1566
|
-
"L2_hybridrag": {"status": "healthy", "description": "Orchestrates L3+L4 fusion"},
|
|
1567
|
-
"L3_graph_search": {"status": "healthy" if neo4j_healthy else "unavailable", "backend": "neo4j"},
|
|
1568
|
-
"L4_vector_search": {"status": "healthy" if qmd_healthy else "unavailable", "backend": "qmd"},
|
|
1569
|
-
"L5_communications": {"status": "healthy" if l5_reachable else "unavailable", "backend": "milvus"},
|
|
1570
|
-
"L6_document_store": {"status": "healthy" if l6_reachable else "unavailable", "backend": "milvus+fts5+reranker", "port": 8037},
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
@app.get("/stats")
|
|
1575
|
-
async def layer_statistics() -> Any:
|
|
1576
|
-
"""Memory layer usage statistics for evolution tracking."""
|
|
1577
|
-
return get_layer_stats()
|
|
1578
|
-
|
|
1579
|
-
# ---------------------------------------------------------------------------
|
|
1580
|
-
# Internal write endpoints (L0 BM25 + L4 QMD + L3 KG)
|
|
1581
|
-
#
|
|
1582
|
-
# These let the compat shim populate the three layers that L2 reads from
|
|
1583
|
-
# directly (rather than via HTTP sidecars). Without these the L2-via-shim
|
|
1584
|
-
# path runs with empty L0/L4-qmd/L3 and RRF fusion is polluted by zero-
|
|
1585
|
-
# result rank lists.
|
|
1586
|
-
# ---------------------------------------------------------------------------
|
|
1587
|
-
|
|
1588
|
-
_ENTITY_STOP = {
|
|
1589
|
-
'what', 'who', 'where', 'when', 'how', 'does', 'did', 'the', 'and',
|
|
1590
|
-
'for', 'with', 'from', 'about', 'this', 'that', 'have', 'has', 'are',
|
|
1591
|
-
'was', 'were', 'been', 'will', 'would', 'could', 'should', 'into',
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
def _extract_entities_for_kg(text: str, max_entities: int = 32) -> List[str]:
|
|
1595
|
-
"""Mirror of extract_query_entities, but applied to ingest content.
|
|
1596
|
-
|
|
1597
|
-
Picks single-word title-case tokens + bigrams of consecutive title-case
|
|
1598
|
-
tokens. Same heuristic as query-side so node names and search terms line
|
|
1599
|
-
up. Caps at max_entities to keep ingest fast.
|
|
1600
|
-
"""
|
|
1601
|
-
if not text:
|
|
1602
|
-
return []
|
|
1603
|
-
words = [re.sub(r'[^\w\s-]', '', w).strip() for w in text.split()]
|
|
1604
|
-
words = [w for w in words if w]
|
|
1605
|
-
found: List[str] = []
|
|
1606
|
-
seen: Set[str] = set()
|
|
1607
|
-
# Single-word title-case
|
|
1608
|
-
for w in words:
|
|
1609
|
-
if w.istitle() and len(w) > 2 and w.lower() not in _ENTITY_STOP:
|
|
1610
|
-
key = w.lower()
|
|
1611
|
-
if key not in seen:
|
|
1612
|
-
found.append(w)
|
|
1613
|
-
seen.add(key)
|
|
1614
|
-
# Bigrams of consecutive title-case
|
|
1615
|
-
for i in range(len(words) - 1):
|
|
1616
|
-
a, b = words[i], words[i + 1]
|
|
1617
|
-
if (a.istitle() and b.istitle()
|
|
1618
|
-
and a.lower() not in _ENTITY_STOP
|
|
1619
|
-
and b.lower() not in _ENTITY_STOP
|
|
1620
|
-
and len(a) > 1 and len(b) > 1):
|
|
1621
|
-
phrase = f"{a} {b}"
|
|
1622
|
-
key = phrase.lower()
|
|
1623
|
-
if key not in seen:
|
|
1624
|
-
found.append(phrase)
|
|
1625
|
-
seen.add(key)
|
|
1626
|
-
if len(found) >= max_entities:
|
|
1627
|
-
break
|
|
1628
|
-
return found[:max_entities]
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
async def _embed_batch_local(texts: List[str]) -> List[List[float]]:
|
|
1632
|
-
"""Batch embed via the shared EmbedClient (async). Returns vectors in input order."""
|
|
1633
|
-
if not texts:
|
|
1634
|
-
return []
|
|
1635
|
-
try:
|
|
1636
|
-
return await _embed_client().embed_batch_async(texts)
|
|
1637
|
-
except Exception as e:
|
|
1638
|
-
log.warning(f"NV-Embed batch failed: {e}; trying singletons")
|
|
1639
|
-
# Singleton fallback stays sync (each one-shot embed is small);
|
|
1640
|
-
# offload to thread so we don't block the loop.
|
|
1641
|
-
return await asyncio.gather(
|
|
1642
|
-
*(asyncio.to_thread(get_embedding, t) for t in texts)
|
|
1643
|
-
)
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
class IndexInternalBatchRequest(BaseModel):
|
|
1647
|
-
records: List[Dict[str, Any]] # [{"id": str, "content": str, "metadata": dict}, ...]
|
|
1648
|
-
arena: Optional[str] = "general"
|
|
1649
|
-
# When supplied (parallel to `records`), skip the L4-QMD embed call
|
|
1650
|
-
# and use these vectors directly. Compat shim populates this when
|
|
1651
|
-
# shared-embed mode is on so we don't duplicate embed work across
|
|
1652
|
-
# layers. Length must match records — defensive bail-out below if
|
|
1653
|
-
# it doesn't.
|
|
1654
|
-
embeddings: Optional[List[List[float]]] = None
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
@app.post("/index-internal-batch")
|
|
1658
|
-
async def index_internal_batch(req: IndexInternalBatchRequest) -> dict:
|
|
1659
|
-
"""Populate L0 BM25 + L4 QMD vec + L3 Neo4j KG from one ingest call.
|
|
1660
|
-
|
|
1661
|
-
Called by the compat shim on /store-batch so the L2 7-layer fusion
|
|
1662
|
-
has real data in every layer (not just L5/L6). Sequential within the
|
|
1663
|
-
handler since all three writes are local (SQLite + bolt to a sibling
|
|
1664
|
-
container).
|
|
1665
|
-
"""
|
|
1666
|
-
t0 = time.time()
|
|
1667
|
-
records = [r for r in (req.records or [])
|
|
1668
|
-
if (r.get("content") or r.get("text"))]
|
|
1669
|
-
if not records:
|
|
1670
|
-
return {"status": "ok", "inserted": 0, "l0": 0, "l4_qmd": 0, "l3_entities": 0,
|
|
1671
|
-
"l3_chunks": 0, "duration_ms": 0.0}
|
|
1672
|
-
|
|
1673
|
-
# Normalise
|
|
1674
|
-
norm = []
|
|
1675
|
-
for r in records:
|
|
1676
|
-
content = r.get("content") or r.get("text") or ""
|
|
1677
|
-
rid = (r.get("id")
|
|
1678
|
-
or hashlib.sha1(content.encode()).hexdigest()[:32])
|
|
1679
|
-
meta = r.get("metadata") or {}
|
|
1680
|
-
path = meta.get("path") or meta.get("doc_id") or rid
|
|
1681
|
-
norm.append({"id": str(rid), "content": content, "path": str(path),
|
|
1682
|
-
"metadata": meta})
|
|
1683
|
-
|
|
1684
|
-
now_iso = datetime.utcnow().isoformat() + "Z"
|
|
1685
|
-
arena = req.arena or "general"
|
|
1686
|
-
|
|
1687
|
-
# ---- L0 BM25 (workspace.db) -----------------------------------------
|
|
1688
|
-
l0_inserted = 0
|
|
1689
|
-
try:
|
|
1690
|
-
l0_db = Path(os.environ.get("PME_MEMORY_DB", str(L0_MEMORY_DB)))
|
|
1691
|
-
l0_db.parent.mkdir(parents=True, exist_ok=True)
|
|
1692
|
-
conn = sqlite3.connect(str(l0_db), timeout=10)
|
|
1693
|
-
conn.execute("PRAGMA journal_mode=WAL")
|
|
1694
|
-
# Schema is created by init_databases.py at container start, but be
|
|
1695
|
-
# defensive in case L2 is run standalone.
|
|
1696
|
-
conn.execute("""
|
|
1697
|
-
CREATE TABLE IF NOT EXISTS chunks (
|
|
1698
|
-
id INTEGER PRIMARY KEY,
|
|
1699
|
-
path TEXT,
|
|
1700
|
-
text TEXT,
|
|
1701
|
-
file_type TEXT,
|
|
1702
|
-
chunk_index INTEGER,
|
|
1703
|
-
created_at TEXT
|
|
1704
|
-
)
|
|
1705
|
-
""")
|
|
1706
|
-
conn.execute("""
|
|
1707
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
1708
|
-
path, text, file_type,
|
|
1709
|
-
content='chunks',
|
|
1710
|
-
content_rowid='id'
|
|
1711
|
-
)
|
|
1712
|
-
""")
|
|
1713
|
-
conn.execute("""
|
|
1714
|
-
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
|
|
1715
|
-
INSERT INTO chunks_fts(rowid, path, text, file_type)
|
|
1716
|
-
VALUES (new.id, new.path, new.text, new.file_type);
|
|
1717
|
-
END
|
|
1718
|
-
""")
|
|
1719
|
-
for n in norm:
|
|
1720
|
-
cur = conn.execute(
|
|
1721
|
-
"INSERT INTO chunks (path, text, file_type, chunk_index, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
1722
|
-
(f"bench/{arena}/{n['path']}.md", n["content"], "md", 0, now_iso),
|
|
1723
|
-
)
|
|
1724
|
-
if cur.rowcount > 0:
|
|
1725
|
-
l0_inserted += 1
|
|
1726
|
-
conn.commit()
|
|
1727
|
-
conn.close()
|
|
1728
|
-
except Exception as e:
|
|
1729
|
-
log.error(f"L0 BM25 write failed: {e}")
|
|
1730
|
-
|
|
1731
|
-
# ---- L4 QMD vec (qmd.sqlite) ----------------------------------------
|
|
1732
|
-
l4_inserted = 0
|
|
1733
|
-
try:
|
|
1734
|
-
# Shared-embed shortcut: if the compat shim handed us pre-computed
|
|
1735
|
-
# vectors that line up with our normalised records, use them and
|
|
1736
|
-
# skip our own embed RPC. Fall back to per-layer embedding when
|
|
1737
|
-
# the vectors are absent or the lengths don't match (defensive).
|
|
1738
|
-
shared_embs = req.embeddings
|
|
1739
|
-
if (
|
|
1740
|
-
shared_embs is not None
|
|
1741
|
-
and len(shared_embs) == len(records)
|
|
1742
|
-
and len(records) == len(norm)
|
|
1743
|
-
):
|
|
1744
|
-
embeddings = shared_embs
|
|
1745
|
-
else:
|
|
1746
|
-
embeddings = await _embed_batch_local([n["content"] for n in norm])
|
|
1747
|
-
if len(embeddings) != len(norm):
|
|
1748
|
-
log.warning(f"L4 embed count mismatch: {len(embeddings)} != {len(norm)}")
|
|
1749
|
-
qmd_db = Path(QMD_DB_PATH)
|
|
1750
|
-
qmd_db.parent.mkdir(parents=True, exist_ok=True)
|
|
1751
|
-
# Open with sqlite-vec loaded so we can dual-write to vec_index
|
|
1752
|
-
# below. If extension load fails, vec_index inserts silently no-op
|
|
1753
|
-
# via the try/except — chunks (JSON) still gets the write so the
|
|
1754
|
-
# corpus stays whole; search just degrades to the old path.
|
|
1755
|
-
conn = _open_qmd_conn()
|
|
1756
|
-
conn.execute("PRAGMA journal_mode=WAL")
|
|
1757
|
-
conn.execute("""
|
|
1758
|
-
CREATE TABLE IF NOT EXISTS chunks (
|
|
1759
|
-
id INTEGER PRIMARY KEY,
|
|
1760
|
-
path TEXT,
|
|
1761
|
-
text TEXT,
|
|
1762
|
-
embedding TEXT,
|
|
1763
|
-
embedding_model TEXT,
|
|
1764
|
-
embedding_dim INTEGER,
|
|
1765
|
-
chunk_index INTEGER,
|
|
1766
|
-
created_at TEXT
|
|
1767
|
-
)
|
|
1768
|
-
""")
|
|
1769
|
-
try:
|
|
1770
|
-
_ensure_vec_index(conn)
|
|
1771
|
-
except Exception as e:
|
|
1772
|
-
log.error(f"vec_index DDL failed: {e} — falling back to chunks-only write")
|
|
1773
|
-
for n, vec in zip(norm, embeddings):
|
|
1774
|
-
if not vec:
|
|
1775
|
-
continue
|
|
1776
|
-
cur = conn.execute(
|
|
1777
|
-
"INSERT INTO chunks (path, text, embedding, embedding_model, embedding_dim, chunk_index, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
1778
|
-
(f"bench/{arena}/{n['path']}.md", n["content"],
|
|
1779
|
-
json.dumps(vec), "nv-embed-v2", len(vec), 0, now_iso),
|
|
1780
|
-
)
|
|
1781
|
-
# Mirror into the vec0 KNN index so search_qmd_informed can
|
|
1782
|
-
# MATCH on the f32-packed vector. Dim must match the vec0 DDL
|
|
1783
|
-
# (EMBED_DIM); skip rows where the embedding shape disagrees
|
|
1784
|
-
# so a single bad row doesn't poison the batch insert.
|
|
1785
|
-
if cur.lastrowid is not None and len(vec) == EMBED_DIM:
|
|
1786
|
-
try:
|
|
1787
|
-
conn.execute(
|
|
1788
|
-
"INSERT INTO vec_index(id, embedding) VALUES (?, ?)",
|
|
1789
|
-
(cur.lastrowid, struct.pack(f"{len(vec)}f", *vec)),
|
|
1790
|
-
)
|
|
1791
|
-
except Exception as e:
|
|
1792
|
-
# vec_index dual-write is defensive — the JSON column
|
|
1793
|
-
# in chunks is still the source of truth until the
|
|
1794
|
-
# backfill task confirms vec_index is in sync.
|
|
1795
|
-
log.debug(f"vec_index insert skipped for row {cur.lastrowid}: {e}")
|
|
1796
|
-
l4_inserted += 1
|
|
1797
|
-
conn.commit()
|
|
1798
|
-
conn.close()
|
|
1799
|
-
except Exception as e:
|
|
1800
|
-
log.error(f"L4 QMD write failed: {e}")
|
|
1801
|
-
|
|
1802
|
-
# ---- L3 Neo4j KG ----------------------------------------------------
|
|
1803
|
-
# Every node and edge written here is arena-scoped. Two paths:
|
|
1804
|
-
#
|
|
1805
|
-
# 1. Heuristic Concept extraction — title-case + bigrams over the
|
|
1806
|
-
# chunk body, same as before. Concepts MERGE on (arena, name)
|
|
1807
|
-
# so two tenants can independently mint a "Pricing" concept
|
|
1808
|
-
# without colliding.
|
|
1809
|
-
#
|
|
1810
|
-
# 2. Metadata-driven Person extraction — when the chunk's metadata
|
|
1811
|
-
# carries contact_email / contact_name (Pip emits these from
|
|
1812
|
-
# its ingest pipeline; other clients can do the same), we MERGE
|
|
1813
|
-
# a typed (:Entity:Person) node and connect it to the chunk via
|
|
1814
|
-
# a (:COMMUNICATED) edge that carries channel + direction. This
|
|
1815
|
-
# is the path the relationships UI reads from — it's reliable
|
|
1816
|
-
# because the writer knows exactly who the person is, no NLP
|
|
1817
|
-
# guessing required.
|
|
1818
|
-
#
|
|
1819
|
-
# The compound (arena, name) MERGE guarantees no cross-tenant entity
|
|
1820
|
-
# collapse. Pre-existing unscoped entities (arena IS NULL) are left
|
|
1821
|
-
# alone; the wipe-legacy migration script handles them out of band.
|
|
1822
|
-
l3_entities = 0
|
|
1823
|
-
l3_chunks = 0
|
|
1824
|
-
try:
|
|
1825
|
-
driver = get_neo4j_driver()
|
|
1826
|
-
async with driver.session() as session:
|
|
1827
|
-
# Indexes — idempotent. The compound (arena, name) is the
|
|
1828
|
-
# right shape now that entities are arena-scoped; the legacy
|
|
1829
|
-
# entity_name index stays for the wipe-migration to work
|
|
1830
|
-
# against pre-arena rows, then can be dropped in a follow-up.
|
|
1831
|
-
try:
|
|
1832
|
-
await session.run("CREATE INDEX entity_arena_name IF NOT EXISTS FOR (n:Entity) ON (n.arena, n.name)")
|
|
1833
|
-
await session.run("CREATE INDEX person_arena_email IF NOT EXISTS FOR (n:Person) ON (n.arena, n.email)")
|
|
1834
|
-
await session.run("CREATE INDEX chunk_arena IF NOT EXISTS FOR (c:Chunk) ON (c.arena)")
|
|
1835
|
-
await session.run("CREATE INDEX chunk_id IF NOT EXISTS FOR (c:Chunk) ON (c.id)")
|
|
1836
|
-
# ChannelStat is the denormalised aggregate read by
|
|
1837
|
-
# /aggregate on the fast path. Compound index covers
|
|
1838
|
-
# the (arena, person_email) lookup that the reader
|
|
1839
|
-
# uses; the per-channel rows are returned in one
|
|
1840
|
-
# range scan.
|
|
1841
|
-
await session.run("CREATE INDEX channelstat_arena_email IF NOT EXISTS FOR (s:ChannelStat) ON (s.arena, s.person_email)")
|
|
1842
|
-
# UNIQUE constraint on the writer's MERGE key. Without
|
|
1843
|
-
# this, two concurrent index-internal-batch transactions
|
|
1844
|
-
# can both decide a ChannelStat doesn't exist and create
|
|
1845
|
-
# rival nodes — the index doesn't lock, the constraint
|
|
1846
|
-
# does. The constraint also implies an index on the
|
|
1847
|
-
# full key so the MERGE locks efficiently.
|
|
1848
|
-
await session.run("CREATE CONSTRAINT channelstat_unique IF NOT EXISTS FOR (s:ChannelStat) REQUIRE (s.arena, s.person_email, s.channel) IS UNIQUE")
|
|
1849
|
-
except Exception:
|
|
1850
|
-
pass
|
|
1851
|
-
for n in norm:
|
|
1852
|
-
heuristic_entities = _extract_entities_for_kg(n["content"])
|
|
1853
|
-
meta = n.get("metadata") or {}
|
|
1854
|
-
contact_email = meta.get("contact_email")
|
|
1855
|
-
contact_name = meta.get("contact_name")
|
|
1856
|
-
channel = meta.get("channel")
|
|
1857
|
-
direction = meta.get("direction")
|
|
1858
|
-
occurred_at = meta.get("timestamp") or meta.get("occurred_at") or now_iso
|
|
1859
|
-
# Skip the chunk only when there is genuinely nothing to
|
|
1860
|
-
# connect — heuristic entities AND no person metadata.
|
|
1861
|
-
if not heuristic_entities and not contact_email and not contact_name:
|
|
1862
|
-
continue
|
|
1863
|
-
# Create the chunk node — arena property is the
|
|
1864
|
-
# tenant-isolation anchor. Every read traverses through
|
|
1865
|
-
# this node, so getting the arena right here is the
|
|
1866
|
-
# single most important invariant of this whole block.
|
|
1867
|
-
await session.run(
|
|
1868
|
-
"""
|
|
1869
|
-
MERGE (c:Chunk {id: $cid})
|
|
1870
|
-
SET c.text = $text,
|
|
1871
|
-
c.path = $path,
|
|
1872
|
-
c.source_file = $path,
|
|
1873
|
-
c.arena = $arena,
|
|
1874
|
-
c.created_at = $now
|
|
1875
|
-
""",
|
|
1876
|
-
cid=n["id"], text=n["content"][:2000], path=n["path"],
|
|
1877
|
-
arena=arena, now=now_iso,
|
|
1878
|
-
)
|
|
1879
|
-
l3_chunks += 1
|
|
1880
|
-
|
|
1881
|
-
# Concept entities — heuristic, arena-scoped.
|
|
1882
|
-
for ent in heuristic_entities:
|
|
1883
|
-
await session.run(
|
|
1884
|
-
"""
|
|
1885
|
-
MERGE (e:Entity:Concept {arena: $arena, name: $name})
|
|
1886
|
-
ON CREATE SET e.type = 'Concept',
|
|
1887
|
-
e.created_at = $now,
|
|
1888
|
-
e.weight = 1.0
|
|
1889
|
-
WITH e
|
|
1890
|
-
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
1891
|
-
MERGE (e)-[r:MENTIONS]->(c)
|
|
1892
|
-
ON CREATE SET r.weight = 1.0, r.created_at = $now
|
|
1893
|
-
ON MATCH SET r.weight = coalesce(r.weight, 1.0) + 0.1
|
|
1894
|
-
""",
|
|
1895
|
-
arena=arena, name=ent, cid=n["id"], now=now_iso,
|
|
1896
|
-
)
|
|
1897
|
-
l3_entities += 1
|
|
1898
|
-
# Concept-concept co-occurrence — same arena on both
|
|
1899
|
-
# ends so cross-tenant CO_OCCURS edges can't form even
|
|
1900
|
-
# if two tenants happen to extract the same concept name.
|
|
1901
|
-
if len(heuristic_entities) >= 2:
|
|
1902
|
-
for i in range(len(heuristic_entities)):
|
|
1903
|
-
for j in range(i + 1, len(heuristic_entities)):
|
|
1904
|
-
await session.run(
|
|
1905
|
-
"""
|
|
1906
|
-
MATCH (a:Entity:Concept {arena: $arena, name: $a})
|
|
1907
|
-
MATCH (b:Entity:Concept {arena: $arena, name: $b})
|
|
1908
|
-
MERGE (a)-[r:CO_OCCURS]->(b)
|
|
1909
|
-
ON CREATE SET r.weight = 0.5, r.created_at = $now
|
|
1910
|
-
ON MATCH SET r.weight = coalesce(r.weight, 0.5) + 0.05
|
|
1911
|
-
""",
|
|
1912
|
-
arena=arena, a=heuristic_entities[i],
|
|
1913
|
-
b=heuristic_entities[j], now=now_iso,
|
|
1914
|
-
)
|
|
1915
|
-
|
|
1916
|
-
# Person entities — typed via writer-supplied metadata.
|
|
1917
|
-
# Email gets its own node (canonical id for a person);
|
|
1918
|
-
# name gets its own node (display surface). When both
|
|
1919
|
-
# are present they're linked via KNOWN_AS so a query
|
|
1920
|
-
# against either resolves the same person.
|
|
1921
|
-
person_email_node = None
|
|
1922
|
-
if isinstance(contact_email, str) and contact_email.strip():
|
|
1923
|
-
norm_email = contact_email.strip().lower()
|
|
1924
|
-
# Two-phase write: MERGE the Person + COMMUNICATED
|
|
1925
|
-
# edge, then update the ChannelStat aggregate IFF
|
|
1926
|
-
# the edge was just created. The `r._counted` flag
|
|
1927
|
-
# is the idempotency rail — set false on CREATE and
|
|
1928
|
-
# flipped to true after the stat update, so replays
|
|
1929
|
-
# of the same eventId never double-count even when
|
|
1930
|
-
# the chunk already exists.
|
|
1931
|
-
await session.run(
|
|
1932
|
-
"""
|
|
1933
|
-
MERGE (p:Entity:Person {arena: $arena, email: $email})
|
|
1934
|
-
ON CREATE SET p.created_at = $now,
|
|
1935
|
-
p.first_seen = $occurred_at,
|
|
1936
|
-
p.last_seen = $occurred_at
|
|
1937
|
-
ON MATCH SET p.last_seen = CASE
|
|
1938
|
-
WHEN $occurred_at > coalesce(p.last_seen, '')
|
|
1939
|
-
THEN $occurred_at
|
|
1940
|
-
ELSE p.last_seen END
|
|
1941
|
-
WITH p
|
|
1942
|
-
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
1943
|
-
MERGE (p)-[r:COMMUNICATED]->(c)
|
|
1944
|
-
ON CREATE SET r.channel = $channel,
|
|
1945
|
-
r.direction = $direction,
|
|
1946
|
-
r.occurred_at = $occurred_at,
|
|
1947
|
-
r.weight = 1.0,
|
|
1948
|
-
r._counted = false
|
|
1949
|
-
WITH p, r
|
|
1950
|
-
// ChannelStat denormalises Person-COMMUNICATED
|
|
1951
|
-
// edge counts so /aggregate becomes a property
|
|
1952
|
-
// read instead of a per-query Cypher walk over
|
|
1953
|
-
// every edge. Read path falls back to the edge
|
|
1954
|
-
// walk for older tenants whose stats haven't
|
|
1955
|
-
// been backfilled, so this is a forward-only
|
|
1956
|
-
// optimisation — no migration needed for stats
|
|
1957
|
-
// to start materialising.
|
|
1958
|
-
FOREACH (_ IN CASE WHEN r._counted = false THEN [1] ELSE [] END |
|
|
1959
|
-
MERGE (s:ChannelStat {arena: $arena, person_email: $email, channel: $channel})
|
|
1960
|
-
ON CREATE SET s.count = 0,
|
|
1961
|
-
s.inbound = 0,
|
|
1962
|
-
s.outbound = 0,
|
|
1963
|
-
s.first_seen = $occurred_at,
|
|
1964
|
-
s.last_seen = $occurred_at,
|
|
1965
|
-
s.created_at = $now
|
|
1966
|
-
SET s.count = s.count + 1,
|
|
1967
|
-
s.inbound = s.inbound + (CASE WHEN $direction = 'inbound' THEN 1 ELSE 0 END),
|
|
1968
|
-
s.outbound = s.outbound + (CASE WHEN $direction = 'outbound' THEN 1 ELSE 0 END),
|
|
1969
|
-
s.first_seen = CASE
|
|
1970
|
-
WHEN $occurred_at < coalesce(s.first_seen, $occurred_at)
|
|
1971
|
-
THEN $occurred_at
|
|
1972
|
-
ELSE s.first_seen END,
|
|
1973
|
-
s.last_seen = CASE
|
|
1974
|
-
WHEN $occurred_at > coalesce(s.last_seen, '')
|
|
1975
|
-
THEN $occurred_at
|
|
1976
|
-
ELSE s.last_seen END,
|
|
1977
|
-
s.updated_at = $now
|
|
1978
|
-
MERGE (p)-[:HAS_STAT]->(s)
|
|
1979
|
-
SET r._counted = true
|
|
1980
|
-
)
|
|
1981
|
-
""",
|
|
1982
|
-
arena=arena, email=norm_email, cid=n["id"],
|
|
1983
|
-
channel=channel, direction=direction,
|
|
1984
|
-
occurred_at=occurred_at, now=now_iso,
|
|
1985
|
-
)
|
|
1986
|
-
person_email_node = norm_email
|
|
1987
|
-
l3_entities += 1
|
|
1988
|
-
if isinstance(contact_name, str) and contact_name.strip():
|
|
1989
|
-
cname = contact_name.strip()
|
|
1990
|
-
await session.run(
|
|
1991
|
-
"""
|
|
1992
|
-
MERGE (p:Entity:Person {arena: $arena, name: $name})
|
|
1993
|
-
ON CREATE SET p.created_at = $now,
|
|
1994
|
-
p.first_seen = $occurred_at,
|
|
1995
|
-
p.last_seen = $occurred_at
|
|
1996
|
-
ON MATCH SET p.last_seen = CASE
|
|
1997
|
-
WHEN $occurred_at > coalesce(p.last_seen, '')
|
|
1998
|
-
THEN $occurred_at
|
|
1999
|
-
ELSE p.last_seen END
|
|
2000
|
-
WITH p
|
|
2001
|
-
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
2002
|
-
MERGE (p)-[r:COMMUNICATED]->(c)
|
|
2003
|
-
ON CREATE SET r.channel = $channel,
|
|
2004
|
-
r.direction = $direction,
|
|
2005
|
-
r.occurred_at = $occurred_at,
|
|
2006
|
-
r.weight = 1.0
|
|
2007
|
-
""",
|
|
2008
|
-
arena=arena, name=cname, cid=n["id"],
|
|
2009
|
-
channel=channel, direction=direction,
|
|
2010
|
-
occurred_at=occurred_at, now=now_iso,
|
|
2011
|
-
)
|
|
2012
|
-
l3_entities += 1
|
|
2013
|
-
# Link name→email node so the relationships query
|
|
2014
|
-
# can resolve either alias to the same person.
|
|
2015
|
-
if person_email_node:
|
|
2016
|
-
await session.run(
|
|
2017
|
-
"""
|
|
2018
|
-
MATCH (n:Person {arena: $arena, name: $name})
|
|
2019
|
-
MATCH (e:Person {arena: $arena, email: $email})
|
|
2020
|
-
MERGE (n)-[:KNOWN_AS]->(e)
|
|
2021
|
-
""",
|
|
2022
|
-
arena=arena, name=cname, email=person_email_node,
|
|
2023
|
-
)
|
|
2024
|
-
except Exception as e:
|
|
2025
|
-
log.error(f"L3 KG write failed: {e}")
|
|
2026
|
-
|
|
2027
|
-
dur_ms = (time.time() - t0) * 1000.0
|
|
2028
|
-
return {
|
|
2029
|
-
"status": "ok",
|
|
2030
|
-
"inserted": len(norm),
|
|
2031
|
-
"l0": l0_inserted,
|
|
2032
|
-
"l4_qmd": l4_inserted,
|
|
2033
|
-
"l3_entities": l3_entities,
|
|
2034
|
-
"l3_chunks": l3_chunks,
|
|
2035
|
-
"duration_ms": round(dur_ms, 1),
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
@app.post("/forget-internal")
|
|
2040
|
-
async def forget_internal(request: Request) -> dict:
|
|
2041
|
-
"""Wipe L0 + L4-qmd + L3.
|
|
2042
|
-
|
|
2043
|
-
Two modes:
|
|
2044
|
-
- Tenant-scoped (default, safe): pass `{"arena": "<tenant>"}` and
|
|
2045
|
-
only that tenant's rows are deleted. Used by tenant offboarding
|
|
2046
|
-
and by tests.
|
|
2047
|
-
- Global (unsafe): the bench harness needs to wipe everything
|
|
2048
|
-
between runs. Require an explicit `{"confirm": "GLOBAL_WIPE"}`
|
|
2049
|
-
flag — without it we refuse rather than nuke shared infra.
|
|
2050
|
-
|
|
2051
|
-
Pre-fix this endpoint silently ignored the arena param and always
|
|
2052
|
-
deleted globally. That meant a tenant offboarding script — or any
|
|
2053
|
-
caller that read the param-name and trusted it — would erase every
|
|
2054
|
-
other tenant's L3 graph and wipe the shared sqlite stores. Hence
|
|
2055
|
-
the explicit confirm gate now.
|
|
2056
|
-
"""
|
|
2057
|
-
try:
|
|
2058
|
-
body = await request.json()
|
|
2059
|
-
except Exception:
|
|
2060
|
-
body = {}
|
|
2061
|
-
arena = body.get("arena")
|
|
2062
|
-
confirm = body.get("confirm")
|
|
2063
|
-
if not arena and confirm != "GLOBAL_WIPE":
|
|
2064
|
-
raise HTTPException(
|
|
2065
|
-
status_code=400,
|
|
2066
|
-
detail="forget-internal requires either 'arena' (tenant-scoped) "
|
|
2067
|
-
"or 'confirm: GLOBAL_WIPE' (unsafe, deletes everything).",
|
|
2068
|
-
)
|
|
2069
|
-
deleted = {"l0": 0, "l4_qmd": 0, "l3_entities": 0, "l3_chunks": 0}
|
|
2070
|
-
|
|
2071
|
-
# ---- L0 BM25 (sqlite) ----------------------------------------------
|
|
2072
|
-
# The L0 chunks table doesn't carry an arena column today, so we
|
|
2073
|
-
# only support GLOBAL_WIPE here. Tenant-scoped L0 deletes are a
|
|
2074
|
-
# follow-up (needs schema migration to add `arena` to L0 rows).
|
|
2075
|
-
try:
|
|
2076
|
-
l0_db = Path(os.environ.get("PME_MEMORY_DB", str(L0_MEMORY_DB)))
|
|
2077
|
-
if l0_db.exists() and confirm == "GLOBAL_WIPE":
|
|
2078
|
-
conn = sqlite3.connect(str(l0_db), timeout=5)
|
|
2079
|
-
cur = conn.execute("DELETE FROM chunks")
|
|
2080
|
-
deleted["l0"] = cur.rowcount
|
|
2081
|
-
try:
|
|
2082
|
-
conn.execute("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')")
|
|
2083
|
-
except Exception:
|
|
2084
|
-
pass
|
|
2085
|
-
conn.commit(); conn.close()
|
|
2086
|
-
except Exception as e:
|
|
2087
|
-
log.error(f"L0 forget failed: {e}")
|
|
2088
|
-
|
|
2089
|
-
# ---- L4 sqlite-vec --------------------------------------------------
|
|
2090
|
-
# Same situation as L0 — no per-arena column on chunks. Global only
|
|
2091
|
-
# for now; tenant-scoped delete is a follow-up.
|
|
2092
|
-
try:
|
|
2093
|
-
if Path(QMD_DB_PATH).exists() and confirm == "GLOBAL_WIPE":
|
|
2094
|
-
conn = sqlite3.connect(QMD_DB_PATH, timeout=5)
|
|
2095
|
-
cur = conn.execute("DELETE FROM chunks")
|
|
2096
|
-
deleted["l4_qmd"] = cur.rowcount
|
|
2097
|
-
conn.commit(); conn.close()
|
|
2098
|
-
except Exception as e:
|
|
2099
|
-
log.error(f"L4 QMD forget failed: {e}")
|
|
2100
|
-
|
|
2101
|
-
# ---- L3 Neo4j -------------------------------------------------------
|
|
2102
|
-
# Neo4j chunks AND entities both carry arena now, so tenant-scoped
|
|
2103
|
-
# delete works correctly here even if L0/L4 still need a migration.
|
|
2104
|
-
try:
|
|
2105
|
-
driver = get_neo4j_driver()
|
|
2106
|
-
async with driver.session() as session:
|
|
2107
|
-
if arena:
|
|
2108
|
-
r1 = await session.run(
|
|
2109
|
-
"MATCH (c:Chunk {arena: $arena}) DETACH DELETE c RETURN count(c) AS n",
|
|
2110
|
-
arena=arena,
|
|
2111
|
-
)
|
|
2112
|
-
rec = await r1.single()
|
|
2113
|
-
deleted["l3_chunks"] = rec["n"]
|
|
2114
|
-
r2 = await session.run(
|
|
2115
|
-
"MATCH (e:Entity {arena: $arena}) DETACH DELETE e RETURN count(e) AS n",
|
|
2116
|
-
arena=arena,
|
|
2117
|
-
)
|
|
2118
|
-
rec = await r2.single()
|
|
2119
|
-
deleted["l3_entities"] = rec["n"]
|
|
2120
|
-
else: # confirm == "GLOBAL_WIPE", validated above
|
|
2121
|
-
r1 = await session.run("MATCH (c:Chunk) DETACH DELETE c RETURN count(c) AS n")
|
|
2122
|
-
rec = await r1.single()
|
|
2123
|
-
deleted["l3_chunks"] = rec["n"]
|
|
2124
|
-
r2 = await session.run("MATCH (e:Entity) DETACH DELETE e RETURN count(e) AS n")
|
|
2125
|
-
rec = await r2.single()
|
|
2126
|
-
deleted["l3_entities"] = rec["n"]
|
|
2127
|
-
except Exception as e:
|
|
2128
|
-
log.error(f"L3 forget failed: {e}")
|
|
2129
|
-
return {"status": "ok", "deleted": deleted, "arena": arena, "global_wipe": confirm == "GLOBAL_WIPE"}
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
class AggregateInternalRequest(BaseModel):
|
|
2133
|
-
"""Aggregate (:Person)-[:COMMUNICATED]->(:Chunk) edges by group_by keys.
|
|
2134
|
-
|
|
2135
|
-
The relationships UI pre-#28 went through a metadata-filtered
|
|
2136
|
-
/search and grouped client-side, capped at the engine over-fetch
|
|
2137
|
-
ceiling. With typed-Person nodes in L3 we can run a single Cypher
|
|
2138
|
-
aggregate that scales to any volume — no over-fetch, no cap.
|
|
2139
|
-
|
|
2140
|
-
Required: arena (the tenant scope) plus enough metadata to identify
|
|
2141
|
-
the Person node we're rolling up. Today that means contact_email
|
|
2142
|
-
(the canonical Person key), but the shape leaves room for future
|
|
2143
|
-
Person identifiers (e.g. slack_user_id, hubspot_contact_id) without
|
|
2144
|
-
a wire change.
|
|
2145
|
-
"""
|
|
2146
|
-
|
|
2147
|
-
arena: str
|
|
2148
|
-
contact_email: Optional[str] = None
|
|
2149
|
-
contact_name: Optional[str] = None
|
|
2150
|
-
# Group by these properties on the COMMUNICATED edge. Only the
|
|
2151
|
-
# relationship-page-supported keys are honoured; unknown keys are
|
|
2152
|
-
# silently dropped (no useful aggregate shape for them).
|
|
2153
|
-
group_by: List[str] = ["channel"]
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
class AggregateBucket(BaseModel):
|
|
2157
|
-
keys: Dict[str, Optional[str]]
|
|
2158
|
-
count: int
|
|
2159
|
-
inbound: int
|
|
2160
|
-
outbound: int
|
|
2161
|
-
last_seen: Optional[str] = None
|
|
2162
|
-
first_seen: Optional[str] = None
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
class AggregateInternalResponse(BaseModel):
|
|
2166
|
-
arena: str
|
|
2167
|
-
total: int
|
|
2168
|
-
last_seen: Optional[str] = None
|
|
2169
|
-
buckets: List[AggregateBucket]
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
# Whitelist of group_by keys we know how to project. Cypher
|
|
2173
|
-
# parameter-substitution doesn't work on property names, so we
|
|
2174
|
-
# template the keys into the query — this whitelist is the safety
|
|
2175
|
-
# rail that keeps the templating from accepting arbitrary input.
|
|
2176
|
-
_AGGREGATE_GROUP_BY_KEYS = {"channel", "direction"}
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
@app.post("/aggregate-internal", response_model=AggregateInternalResponse)
|
|
2180
|
-
async def aggregate_internal(req: AggregateInternalRequest) -> AggregateInternalResponse:
|
|
2181
|
-
"""Aggregate Person→Chunk COMMUNICATED edges by edge properties.
|
|
2182
|
-
|
|
2183
|
-
Returns one bucket per (group_by key combination) with count,
|
|
2184
|
-
inbound/outbound split, and time bounds. The Person match is
|
|
2185
|
-
arena-scoped (mandatory) and additionally filtered by whatever
|
|
2186
|
-
Person identifier the caller supplies.
|
|
2187
|
-
|
|
2188
|
-
No fallback to chunk scanning — if the typed-Person nodes don't
|
|
2189
|
-
exist for this contact, the response is `total: 0` with no
|
|
2190
|
-
buckets, and the caller falls back to whatever it had before.
|
|
2191
|
-
That's intentional: the over-fetch path is in TES (#273); this
|
|
2192
|
-
endpoint is the scaling answer that doesn't have one.
|
|
2193
|
-
"""
|
|
2194
|
-
arena = (req.arena or "").strip()
|
|
2195
|
-
if not arena:
|
|
2196
|
-
raise HTTPException(status_code=400, detail="arena is required")
|
|
2197
|
-
contact_email = (req.contact_email or "").strip().lower()
|
|
2198
|
-
contact_name = (req.contact_name or "").strip()
|
|
2199
|
-
if not contact_email and not contact_name:
|
|
2200
|
-
raise HTTPException(
|
|
2201
|
-
status_code=400,
|
|
2202
|
-
detail="provide contact_email and/or contact_name to identify the Person",
|
|
2203
|
-
)
|
|
2204
|
-
|
|
2205
|
-
# Filter group_by to the supported keys; preserve order so a caller
|
|
2206
|
-
# asking for ["direction", "channel"] gets buckets keyed in that
|
|
2207
|
-
# order on the response.
|
|
2208
|
-
seen: set[str] = set()
|
|
2209
|
-
safe_group_by: List[str] = []
|
|
2210
|
-
for k in req.group_by or []:
|
|
2211
|
-
if k in _AGGREGATE_GROUP_BY_KEYS and k not in seen:
|
|
2212
|
-
seen.add(k)
|
|
2213
|
-
safe_group_by.append(k)
|
|
2214
|
-
|
|
2215
|
-
driver = get_neo4j_driver()
|
|
2216
|
-
|
|
2217
|
-
try:
|
|
2218
|
-
async with driver.session() as session:
|
|
2219
|
-
# Fast path: read from the ChannelStat denormalisation
|
|
2220
|
-
# whenever the caller has an email and is grouping by
|
|
2221
|
-
# channel. ChannelStats are written by /index-internal-batch
|
|
2222
|
-
# on every store with contact_email metadata, so any tenant
|
|
2223
|
-
# with new ingest gets O(channels) reads instead of an
|
|
2224
|
-
# edge walk over every COMMUNICATED relationship.
|
|
2225
|
-
#
|
|
2226
|
-
# Conditions for the fast path:
|
|
2227
|
-
# - contact_email set (stats are email-keyed; name-only
|
|
2228
|
-
# contacts fall through to the edge walk).
|
|
2229
|
-
# - group_by is exactly ["channel"] OR no group_by (single
|
|
2230
|
-
# bucket). Other group_by combinations (e.g. with
|
|
2231
|
-
# direction) need the edge granularity the stats
|
|
2232
|
-
# don't carry.
|
|
2233
|
-
fast_path_eligible = bool(contact_email) and (
|
|
2234
|
-
not safe_group_by or safe_group_by == ["channel"]
|
|
2235
|
-
)
|
|
2236
|
-
if fast_path_eligible:
|
|
2237
|
-
_res = await session.run(
|
|
2238
|
-
"MATCH (s:ChannelStat {arena: $arena, person_email: $email})\n"
|
|
2239
|
-
"RETURN s.channel AS channel,\n"
|
|
2240
|
-
" s.count AS count,\n"
|
|
2241
|
-
" s.inbound AS inbound,\n"
|
|
2242
|
-
" s.outbound AS outbound,\n"
|
|
2243
|
-
" s.last_seen AS last_seen,\n"
|
|
2244
|
-
" s.first_seen AS first_seen\n"
|
|
2245
|
-
"ORDER BY s.count DESC\n",
|
|
2246
|
-
arena=arena, email=contact_email,
|
|
2247
|
-
)
|
|
2248
|
-
stats_rows = [rec async for rec in _res]
|
|
2249
|
-
if stats_rows:
|
|
2250
|
-
# Build buckets directly. When group_by=[] we
|
|
2251
|
-
# collapse to a single overall bucket; otherwise
|
|
2252
|
-
# one bucket per channel.
|
|
2253
|
-
if safe_group_by == ["channel"]:
|
|
2254
|
-
buckets = [
|
|
2255
|
-
AggregateBucket(
|
|
2256
|
-
keys={"channel": rec["channel"]},
|
|
2257
|
-
count=int(rec["count"] or 0),
|
|
2258
|
-
inbound=int(rec["inbound"] or 0),
|
|
2259
|
-
outbound=int(rec["outbound"] or 0),
|
|
2260
|
-
last_seen=str(rec["last_seen"]) if rec["last_seen"] else None,
|
|
2261
|
-
first_seen=str(rec["first_seen"]) if rec["first_seen"] else None,
|
|
2262
|
-
)
|
|
2263
|
-
for rec in stats_rows
|
|
2264
|
-
]
|
|
2265
|
-
total = sum(b.count for b in buckets)
|
|
2266
|
-
latest = None
|
|
2267
|
-
for b in buckets:
|
|
2268
|
-
if b.last_seen and (latest is None or b.last_seen > latest):
|
|
2269
|
-
latest = b.last_seen
|
|
2270
|
-
else:
|
|
2271
|
-
# Single global bucket — sum across channels.
|
|
2272
|
-
total = sum(int(rec["count"] or 0) for rec in stats_rows)
|
|
2273
|
-
inbound = sum(int(rec["inbound"] or 0) for rec in stats_rows)
|
|
2274
|
-
outbound = sum(int(rec["outbound"] or 0) for rec in stats_rows)
|
|
2275
|
-
last_seens = [rec["last_seen"] for rec in stats_rows if rec["last_seen"]]
|
|
2276
|
-
first_seens = [rec["first_seen"] for rec in stats_rows if rec["first_seen"]]
|
|
2277
|
-
latest = max((str(x) for x in last_seens), default=None)
|
|
2278
|
-
earliest = min((str(x) for x in first_seens), default=None)
|
|
2279
|
-
buckets = [AggregateBucket(
|
|
2280
|
-
keys={},
|
|
2281
|
-
count=total,
|
|
2282
|
-
inbound=inbound,
|
|
2283
|
-
outbound=outbound,
|
|
2284
|
-
last_seen=latest,
|
|
2285
|
-
first_seen=earliest,
|
|
2286
|
-
)]
|
|
2287
|
-
return AggregateInternalResponse(
|
|
2288
|
-
arena=arena,
|
|
2289
|
-
total=total,
|
|
2290
|
-
last_seen=latest,
|
|
2291
|
-
buckets=buckets,
|
|
2292
|
-
)
|
|
2293
|
-
# else: stats absent (older tenant pre-rollup, or this
|
|
2294
|
-
# contact has no email-keyed Person yet) → fall through
|
|
2295
|
-
# to the edge-walk path.
|
|
2296
|
-
|
|
2297
|
-
# Edge-walk path (original Cypher). Used when:
|
|
2298
|
-
# - caller has only contact_name (no email-keyed stats)
|
|
2299
|
-
# - caller asked for a group_by we don't denormalise (e.g.
|
|
2300
|
-
# direction)
|
|
2301
|
-
# - tenant predates the rollup writer (no stats nodes yet)
|
|
2302
|
-
# Both paths return the same response shape, so callers
|
|
2303
|
-
# don't need to know which served them.
|
|
2304
|
-
#
|
|
2305
|
-
# Build the Person match. We want either email-keyed or
|
|
2306
|
-
# name-keyed Person nodes; when both are supplied we OR
|
|
2307
|
-
# them so a caller can hit either alias. Both branches
|
|
2308
|
-
# arena-scope the Person.
|
|
2309
|
-
person_clauses: List[str] = []
|
|
2310
|
-
params: Dict[str, Any] = {"arena": arena}
|
|
2311
|
-
if contact_email:
|
|
2312
|
-
person_clauses.append("(p.email = $contact_email)")
|
|
2313
|
-
params["contact_email"] = contact_email
|
|
2314
|
-
if contact_name:
|
|
2315
|
-
person_clauses.append("(p.name = $contact_name)")
|
|
2316
|
-
params["contact_name"] = contact_name
|
|
2317
|
-
person_filter = " OR ".join(person_clauses)
|
|
2318
|
-
|
|
2319
|
-
# group_by keys go into the WITH clause. Cypher doesn't
|
|
2320
|
-
# support property-name parameters, so we template them
|
|
2321
|
-
# in — the whitelist above is the safety rail against
|
|
2322
|
-
# injection. Built up separately rather than via f-string
|
|
2323
|
-
# so the static MATCH clause stays a plain string and the
|
|
2324
|
-
# arena-safety lint can parse it cleanly.
|
|
2325
|
-
with_keys = ", ".join(f"r.{k} AS {k}" for k in safe_group_by)
|
|
2326
|
-
return_keys = ", ".join(safe_group_by)
|
|
2327
|
-
|
|
2328
|
-
# Static base — arena scope on both Person and Chunk so the
|
|
2329
|
-
# lint catches any future copy-paste that forgets it.
|
|
2330
|
-
base = (
|
|
2331
|
-
"MATCH (p:Person {arena: $arena})-[r:COMMUNICATED]->(c:Chunk {arena: $arena})\n"
|
|
2332
|
-
"WHERE " + person_filter + "\n"
|
|
2333
|
-
)
|
|
2334
|
-
agg_select = (
|
|
2335
|
-
"count(*) AS count,\n"
|
|
2336
|
-
"sum(CASE WHEN _direction = 'inbound' THEN 1 ELSE 0 END) AS inbound,\n"
|
|
2337
|
-
"sum(CASE WHEN _direction = 'outbound' THEN 1 ELSE 0 END) AS outbound,\n"
|
|
2338
|
-
"max(_occurred_at) AS last_seen,\n"
|
|
2339
|
-
"min(_occurred_at) AS first_seen\n"
|
|
2340
|
-
)
|
|
2341
|
-
|
|
2342
|
-
if safe_group_by:
|
|
2343
|
-
cypher = (
|
|
2344
|
-
base
|
|
2345
|
-
+ f"WITH {with_keys}, r.direction AS _direction, r.occurred_at AS _occurred_at\n"
|
|
2346
|
-
+ f"RETURN {return_keys},\n"
|
|
2347
|
-
+ agg_select
|
|
2348
|
-
+ "ORDER BY count DESC\n"
|
|
2349
|
-
)
|
|
2350
|
-
else:
|
|
2351
|
-
# No group_by → one global bucket (just the overall
|
|
2352
|
-
# totals for this Person). Useful for "total comms
|
|
2353
|
-
# with X" without per-channel breakdown.
|
|
2354
|
-
cypher = (
|
|
2355
|
-
base
|
|
2356
|
-
+ "WITH r.direction AS _direction, r.occurred_at AS _occurred_at\n"
|
|
2357
|
-
+ "RETURN " + agg_select
|
|
2358
|
-
)
|
|
2359
|
-
|
|
2360
|
-
buckets: List[AggregateBucket] = []
|
|
2361
|
-
total = 0
|
|
2362
|
-
latest: Optional[str] = None
|
|
2363
|
-
_res = await session.run(cypher, **params)
|
|
2364
|
-
async for rec in _res:
|
|
2365
|
-
count = int(rec["count"] or 0)
|
|
2366
|
-
total += count
|
|
2367
|
-
last_seen = rec["last_seen"]
|
|
2368
|
-
if last_seen and (latest is None or str(last_seen) > latest):
|
|
2369
|
-
latest = str(last_seen)
|
|
2370
|
-
bucket_keys: Dict[str, Optional[str]] = (
|
|
2371
|
-
{k: rec[k] for k in safe_group_by} if safe_group_by else {}
|
|
2372
|
-
)
|
|
2373
|
-
buckets.append(AggregateBucket(
|
|
2374
|
-
keys=bucket_keys,
|
|
2375
|
-
count=count,
|
|
2376
|
-
inbound=int(rec["inbound"] or 0),
|
|
2377
|
-
outbound=int(rec["outbound"] or 0),
|
|
2378
|
-
last_seen=str(last_seen) if last_seen else None,
|
|
2379
|
-
first_seen=str(rec["first_seen"]) if rec["first_seen"] else None,
|
|
2380
|
-
))
|
|
2381
|
-
return AggregateInternalResponse(
|
|
2382
|
-
arena=arena,
|
|
2383
|
-
total=total,
|
|
2384
|
-
last_seen=latest,
|
|
2385
|
-
buckets=buckets,
|
|
2386
|
-
)
|
|
2387
|
-
except HTTPException:
|
|
2388
|
-
raise
|
|
2389
|
-
except Exception as e:
|
|
2390
|
-
log.error(f"aggregate-internal failed: {e}")
|
|
2391
|
-
raise HTTPException(status_code=500, detail=f"aggregate failed: {e}")
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
# ── /people-list-internal ───────────────────────────────────────────────
|
|
2395
|
-
#
|
|
2396
|
-
# Corpus-level "all people in these arenas" projection backing the Pip
|
|
2397
|
-
# Relationships UI list page. Different aggregation level from
|
|
2398
|
-
# /aggregate-internal: that one returns buckets PER ONE PERSON; this one
|
|
2399
|
-
# returns one row PER PERSON across the whole arena, sorted by recency.
|
|
2400
|
-
#
|
|
2401
|
-
# Reads the ChannelStat denorm directly. ChannelStats are keyed on
|
|
2402
|
-
# (arena, person_email, channel) and maintained on every store by the
|
|
2403
|
-
# writer block above (~line 1645 onwards). Each person has one
|
|
2404
|
-
# ChannelStat row per channel they appear on. Aggregating over
|
|
2405
|
-
# person_email collapses to one row per person; the per-channel
|
|
2406
|
-
# breakdown rides along as a nested list for callers who want both.
|
|
2407
|
-
#
|
|
2408
|
-
# Falls back to an edge walk path only when ChannelStats are absent
|
|
2409
|
-
# for the arena (older tenant predating the rollup writer). In normal
|
|
2410
|
-
# operation the fast path serves every read.
|
|
2411
|
-
|
|
2412
|
-
class PeopleListInternalRequest(BaseModel):
|
|
2413
|
-
"""List all people communicated with across one or more arenas.
|
|
2414
|
-
|
|
2415
|
-
Multi-arena support exists so the Pip Relationships UI can answer
|
|
2416
|
-
"which colleagues do Philip AND Jeanne both know" without N round-
|
|
2417
|
-
trips. The endpoint is invariant to arena ordering — duplicates
|
|
2418
|
-
across arenas are collapsed by person_email.
|
|
2419
|
-
|
|
2420
|
-
`emails` is a batched-mode filter (return only these emails). Used
|
|
2421
|
-
by Pip's nightly health-recompute to fetch facets for a known set
|
|
2422
|
-
of contacts in one call.
|
|
2423
|
-
|
|
2424
|
-
`search` matches person_email and person_name as case-insensitive
|
|
2425
|
-
substrings. Empty/null → no filter. Server-side, so the caller
|
|
2426
|
-
doesn't have to overfetch.
|
|
2427
|
-
|
|
2428
|
-
Pagination is `limit`/`offset`. Default limit matches the Pip UI
|
|
2429
|
-
page size; large limits are fine since the underlying read is one
|
|
2430
|
-
Cypher query against an indexed denorm.
|
|
2431
|
-
"""
|
|
2432
|
-
|
|
2433
|
-
arenas: List[str]
|
|
2434
|
-
emails: Optional[List[str]] = None
|
|
2435
|
-
search: Optional[str] = None
|
|
2436
|
-
limit: int = 200
|
|
2437
|
-
offset: int = 0
|
|
2438
|
-
# Sort key. Whitelisted; unknown → ValidationError so callers can't
|
|
2439
|
-
# smuggle Cypher fragments into the ORDER BY.
|
|
2440
|
-
order_by: str = "last_seen_desc"
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
class ChannelStatBucket(BaseModel):
|
|
2444
|
-
"""Per-channel breakdown for one person, denormalised from ChannelStat."""
|
|
2445
|
-
|
|
2446
|
-
channel: str
|
|
2447
|
-
count: int
|
|
2448
|
-
inbound: int
|
|
2449
|
-
outbound: int
|
|
2450
|
-
last_seen: Optional[str] = None
|
|
2451
|
-
first_seen: Optional[str] = None
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
class PeopleListEntry(BaseModel):
|
|
2455
|
-
"""One person's rollup in the corpus-level list response."""
|
|
2456
|
-
|
|
2457
|
-
person_email: str
|
|
2458
|
-
person_name: Optional[str] = None
|
|
2459
|
-
total: int
|
|
2460
|
-
inbound: int
|
|
2461
|
-
outbound: int
|
|
2462
|
-
last_seen: Optional[str] = None
|
|
2463
|
-
first_seen: Optional[str] = None
|
|
2464
|
-
channels: List[ChannelStatBucket]
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
class PeopleListInternalResponse(BaseModel):
|
|
2468
|
-
"""Paginated people-list result.
|
|
2469
|
-
|
|
2470
|
-
`total_count` is the distinct-people count after filters, before
|
|
2471
|
-
pagination — so the caller can paint a "Showing 1-200 of 1206"
|
|
2472
|
-
header without a second round-trip. `has_more` is the derived
|
|
2473
|
-
pagination signal.
|
|
2474
|
-
"""
|
|
2475
|
-
|
|
2476
|
-
total_count: int
|
|
2477
|
-
has_more: bool
|
|
2478
|
-
items: List[PeopleListEntry]
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
# Whitelist for order_by to keep the ORDER BY clause injection-safe.
|
|
2482
|
-
# Values are templated into Cypher after `WITH row` so each one must
|
|
2483
|
-
# qualify properties with `row.` (or wrap in a function that does).
|
|
2484
|
-
_PEOPLE_LIST_ORDER_BY = {
|
|
2485
|
-
"last_seen_desc": "row.last_seen DESC",
|
|
2486
|
-
"last_seen_asc": "row.last_seen ASC",
|
|
2487
|
-
"total_desc": "row.total DESC",
|
|
2488
|
-
"total_asc": "row.total ASC",
|
|
2489
|
-
"name_asc": "coalesce(row.person_name, row.person_email) ASC",
|
|
2490
|
-
"name_desc": "coalesce(row.person_name, row.person_email) DESC",
|
|
2491
|
-
}
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
@app.post("/people-list-internal", response_model=PeopleListInternalResponse)
|
|
2495
|
-
async def people_list_internal(
|
|
2496
|
-
req: PeopleListInternalRequest,
|
|
2497
|
-
) -> PeopleListInternalResponse:
|
|
2498
|
-
"""Corpus-level "all people I've communicated with" projection.
|
|
2499
|
-
|
|
2500
|
-
Single Cypher pass over ChannelStat keyed on (arena, person_email).
|
|
2501
|
-
Returns paginated, filtered, sorted rows with per-channel
|
|
2502
|
-
breakdown for each person.
|
|
2503
|
-
|
|
2504
|
-
Authentication is enforced by the surrounding TES module
|
|
2505
|
-
(`authorizeClient` in resolvers.js); this endpoint itself trusts
|
|
2506
|
-
its caller by virtue of running inside the engine network.
|
|
2507
|
-
"""
|
|
2508
|
-
|
|
2509
|
-
arenas = [a.strip() for a in (req.arenas or []) if a and a.strip()]
|
|
2510
|
-
if not arenas:
|
|
2511
|
-
raise HTTPException(status_code=400, detail="at least one arena is required")
|
|
2512
|
-
|
|
2513
|
-
if req.order_by not in _PEOPLE_LIST_ORDER_BY:
|
|
2514
|
-
raise HTTPException(
|
|
2515
|
-
status_code=400,
|
|
2516
|
-
detail=f"order_by must be one of: {sorted(_PEOPLE_LIST_ORDER_BY)}",
|
|
2517
|
-
)
|
|
2518
|
-
order_clause = _PEOPLE_LIST_ORDER_BY[req.order_by]
|
|
2519
|
-
|
|
2520
|
-
limit = max(1, min(req.limit or 200, 2000))
|
|
2521
|
-
offset = max(0, req.offset or 0)
|
|
2522
|
-
emails_filter = (
|
|
2523
|
-
[e.strip().lower() for e in req.emails if e and e.strip()]
|
|
2524
|
-
if req.emails
|
|
2525
|
-
else None
|
|
2526
|
-
)
|
|
2527
|
-
search_pattern = (req.search or "").strip().lower() or None
|
|
2528
|
-
|
|
2529
|
-
driver = get_neo4j_driver()
|
|
2530
|
-
|
|
2531
|
-
try:
|
|
2532
|
-
async with driver.session() as session:
|
|
2533
|
-
# Fast path: read ChannelStat directly. Aggregation walks
|
|
2534
|
-
# the (arena, person_email) compound index — O(channels)
|
|
2535
|
-
# per person, one row per channel.
|
|
2536
|
-
#
|
|
2537
|
-
# Two-stage shape: first WITH collects per-person rollups
|
|
2538
|
-
# (sum across channels + collect the per-channel buckets);
|
|
2539
|
-
# second WITH applies the filter, sort, total, and pagination.
|
|
2540
|
-
params: Dict[str, Any] = {
|
|
2541
|
-
"arenas": arenas,
|
|
2542
|
-
"limit": limit,
|
|
2543
|
-
"offset": offset,
|
|
2544
|
-
}
|
|
2545
|
-
|
|
2546
|
-
# Build the filter clause. `arena IN $arenas` is the
|
|
2547
|
-
# multi-arena gate. `s.person_email IN $emails` is the
|
|
2548
|
-
# batched-mode filter; toLower handles caller-side case
|
|
2549
|
-
# drift. `search` matches against person_email or
|
|
2550
|
-
# (joined) person_name.
|
|
2551
|
-
email_filter_clause = ""
|
|
2552
|
-
if emails_filter:
|
|
2553
|
-
email_filter_clause = " AND s.person_email IN $emails"
|
|
2554
|
-
params["emails"] = emails_filter
|
|
2555
|
-
|
|
2556
|
-
if search_pattern:
|
|
2557
|
-
# Defer the whole search filter until after the Person
|
|
2558
|
-
# join — person_name only exists after the OPTIONAL
|
|
2559
|
-
# MATCH below. Pre-filtering ChannelStats on email
|
|
2560
|
-
# alone (the earlier two-step approach) silently dropped
|
|
2561
|
-
# name-only matches, e.g. email=ag@x.io / name="Alex
|
|
2562
|
-
# Tong" with search="alex" — the early WHERE failed and
|
|
2563
|
-
# the post-join filter never saw the row.
|
|
2564
|
-
params["search"] = search_pattern
|
|
2565
|
-
|
|
2566
|
-
cypher = (
|
|
2567
|
-
"MATCH (s:ChannelStat)\n"
|
|
2568
|
-
"WHERE s.arena IN $arenas"
|
|
2569
|
-
+ email_filter_clause
|
|
2570
|
-
+ "\n"
|
|
2571
|
-
"WITH s.person_email AS person_email,\n"
|
|
2572
|
-
" collect({channel: s.channel, count: s.count,\n"
|
|
2573
|
-
" inbound: s.inbound, outbound: s.outbound,\n"
|
|
2574
|
-
" last_seen: s.last_seen, first_seen: s.first_seen,\n"
|
|
2575
|
-
" arena: s.arena}) AS channels\n"
|
|
2576
|
-
# Resolve display name from the typed-Person node when
|
|
2577
|
-
# one exists. OPTIONAL so people who only have edge data
|
|
2578
|
-
# (no Person node yet) still show up — name falls back
|
|
2579
|
-
# to email at the caller.
|
|
2580
|
-
"OPTIONAL MATCH (p:Person {email: person_email})\n"
|
|
2581
|
-
"WHERE p.arena IN $arenas\n"
|
|
2582
|
-
"WITH person_email,\n"
|
|
2583
|
-
" channels,\n"
|
|
2584
|
-
" head(collect(DISTINCT p.name)) AS person_name\n"
|
|
2585
|
-
# Apply the search filter now that we have both the
|
|
2586
|
-
# joined name and the email available. `coalesce(name,
|
|
2587
|
-
# '')` keeps people without a Person node in the result
|
|
2588
|
-
# set when their email matches — they fall through the
|
|
2589
|
-
# name probe cleanly instead of bypassing the filter
|
|
2590
|
-
# via a `person_name IS NULL` short-circuit, which was
|
|
2591
|
-
# the prior bug (anyone without a Person node passed
|
|
2592
|
-
# search regardless of term).
|
|
2593
|
-
+ (
|
|
2594
|
-
"WHERE toLower(coalesce(person_name, '')) CONTAINS $search\n"
|
|
2595
|
-
" OR toLower(person_email) CONTAINS $search\n"
|
|
2596
|
-
if search_pattern
|
|
2597
|
-
else ""
|
|
2598
|
-
)
|
|
2599
|
-
+ "WITH person_email, person_name, channels,\n"
|
|
2600
|
-
" reduce(t = 0, x IN channels | t + coalesce(x.count, 0)) AS total,\n"
|
|
2601
|
-
" reduce(t = 0, x IN channels | t + coalesce(x.inbound, 0)) AS inbound,\n"
|
|
2602
|
-
" reduce(t = 0, x IN channels | t + coalesce(x.outbound, 0)) AS outbound,\n"
|
|
2603
|
-
" reduce(latest = '', x IN channels |\n"
|
|
2604
|
-
" CASE WHEN x.last_seen IS NOT NULL AND toString(x.last_seen) > latest\n"
|
|
2605
|
-
" THEN toString(x.last_seen) ELSE latest END) AS last_seen_raw,\n"
|
|
2606
|
-
" reduce(earliest = '', x IN channels |\n"
|
|
2607
|
-
" CASE WHEN x.first_seen IS NOT NULL AND (earliest = '' OR toString(x.first_seen) < earliest)\n"
|
|
2608
|
-
" THEN toString(x.first_seen) ELSE earliest END) AS first_seen_raw\n"
|
|
2609
|
-
"WITH person_email, person_name, channels, total, inbound, outbound,\n"
|
|
2610
|
-
" CASE WHEN last_seen_raw = '' THEN null ELSE last_seen_raw END AS last_seen,\n"
|
|
2611
|
-
" CASE WHEN first_seen_raw = '' THEN null ELSE first_seen_raw END AS first_seen\n"
|
|
2612
|
-
# collect-into-list and then split is the standard
|
|
2613
|
-
# Cypher trick for getting both the total count and a
|
|
2614
|
-
# paginated slice from one query.
|
|
2615
|
-
"WITH collect({person_email: person_email, person_name: person_name,\n"
|
|
2616
|
-
" channels: channels, total: total,\n"
|
|
2617
|
-
" inbound: inbound, outbound: outbound,\n"
|
|
2618
|
-
" last_seen: last_seen, first_seen: first_seen}) AS all_rows\n"
|
|
2619
|
-
"WITH all_rows, size(all_rows) AS total_count\n"
|
|
2620
|
-
"UNWIND all_rows AS row\n"
|
|
2621
|
-
f"WITH row, total_count ORDER BY {order_clause}\n"
|
|
2622
|
-
"SKIP $offset LIMIT $limit\n"
|
|
2623
|
-
"RETURN total_count, collect(row) AS page\n"
|
|
2624
|
-
)
|
|
2625
|
-
|
|
2626
|
-
res = await session.run(cypher, **params)
|
|
2627
|
-
record = await res.single()
|
|
2628
|
-
if record is None:
|
|
2629
|
-
return PeopleListInternalResponse(
|
|
2630
|
-
total_count=0, has_more=False, items=[]
|
|
2631
|
-
)
|
|
2632
|
-
|
|
2633
|
-
total_count = int(record["total_count"] or 0)
|
|
2634
|
-
page = record["page"] or []
|
|
2635
|
-
|
|
2636
|
-
items: List[PeopleListEntry] = []
|
|
2637
|
-
for row in page:
|
|
2638
|
-
channels = [
|
|
2639
|
-
ChannelStatBucket(
|
|
2640
|
-
channel=ch.get("channel") or "unknown",
|
|
2641
|
-
count=int(ch.get("count") or 0),
|
|
2642
|
-
inbound=int(ch.get("inbound") or 0),
|
|
2643
|
-
outbound=int(ch.get("outbound") or 0),
|
|
2644
|
-
last_seen=str(ch.get("last_seen")) if ch.get("last_seen") else None,
|
|
2645
|
-
first_seen=str(ch.get("first_seen")) if ch.get("first_seen") else None,
|
|
2646
|
-
)
|
|
2647
|
-
for ch in (row.get("channels") or [])
|
|
2648
|
-
]
|
|
2649
|
-
items.append(
|
|
2650
|
-
PeopleListEntry(
|
|
2651
|
-
person_email=row.get("person_email"),
|
|
2652
|
-
person_name=row.get("person_name"),
|
|
2653
|
-
total=int(row.get("total") or 0),
|
|
2654
|
-
inbound=int(row.get("inbound") or 0),
|
|
2655
|
-
outbound=int(row.get("outbound") or 0),
|
|
2656
|
-
last_seen=str(row.get("last_seen")) if row.get("last_seen") else None,
|
|
2657
|
-
first_seen=str(row.get("first_seen")) if row.get("first_seen") else None,
|
|
2658
|
-
channels=channels,
|
|
2659
|
-
)
|
|
2660
|
-
)
|
|
2661
|
-
|
|
2662
|
-
return PeopleListInternalResponse(
|
|
2663
|
-
total_count=total_count,
|
|
2664
|
-
has_more=(offset + len(items)) < total_count,
|
|
2665
|
-
items=items,
|
|
2666
|
-
)
|
|
2667
|
-
|
|
2668
|
-
except HTTPException:
|
|
2669
|
-
raise
|
|
2670
|
-
except Exception as e:
|
|
2671
|
-
log.error(f"people-list-internal failed: {e}")
|
|
2672
|
-
raise HTTPException(status_code=500, detail=f"people-list failed: {e}")
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
@app.get("/index-internal-stats")
|
|
2676
|
-
async def index_internal_stats() -> dict:
|
|
2677
|
-
"""Quick sanity check that the L0/L4-qmd/L3 stores are populated."""
|
|
2678
|
-
out = {"l0_chunks": 0, "l4_qmd_chunks": 0,
|
|
2679
|
-
"l3_chunks": 0, "l3_entities": 0}
|
|
2680
|
-
try:
|
|
2681
|
-
l0_db = Path(os.environ.get("PME_MEMORY_DB", str(L0_MEMORY_DB)))
|
|
2682
|
-
if l0_db.exists():
|
|
2683
|
-
conn = sqlite3.connect(str(l0_db), timeout=5)
|
|
2684
|
-
r = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()
|
|
2685
|
-
out["l0_chunks"] = r[0] if r else 0
|
|
2686
|
-
conn.close()
|
|
2687
|
-
except Exception as e:
|
|
2688
|
-
out["l0_error"] = str(e)
|
|
2689
|
-
try:
|
|
2690
|
-
if Path(QMD_DB_PATH).exists():
|
|
2691
|
-
conn = sqlite3.connect(QMD_DB_PATH, timeout=5)
|
|
2692
|
-
r = conn.execute("SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL").fetchone()
|
|
2693
|
-
out["l4_qmd_chunks"] = r[0] if r else 0
|
|
2694
|
-
conn.close()
|
|
2695
|
-
except Exception as e:
|
|
2696
|
-
out["l4_qmd_error"] = str(e)
|
|
2697
|
-
try:
|
|
2698
|
-
driver = get_neo4j_driver()
|
|
2699
|
-
async with driver.session() as session:
|
|
2700
|
-
res = await session.run("MATCH (c:Chunk) RETURN count(c) AS n")
|
|
2701
|
-
r = await res.single()
|
|
2702
|
-
out["l3_chunks"] = r["n"] if r else 0
|
|
2703
|
-
res = await session.run("MATCH (e:Entity) RETURN count(e) AS n")
|
|
2704
|
-
r = await res.single()
|
|
2705
|
-
out["l3_entities"] = r["n"] if r else 0
|
|
2706
|
-
except Exception as e:
|
|
2707
|
-
out["l3_error"] = str(e)
|
|
2708
|
-
return out
|
|
2709
|
-
|
|
2710
|
-
# ---------------------------------------------------------------------------
|
|
2711
|
-
# Main
|
|
2712
|
-
# ---------------------------------------------------------------------------
|
|
2713
|
-
|
|
2714
|
-
if __name__ == "__main__":
|
|
2715
|
-
parser = argparse.ArgumentParser()
|
|
2716
|
-
parser.add_argument("--port", type=int, default=8031)
|
|
2717
|
-
parser.add_argument("--host", type=str, default="127.0.0.1")
|
|
2718
|
-
args = parser.parse_args()
|
|
2719
|
-
|
|
2720
|
-
log.info(f"Starting Sequential HybridRAG Proxy (L1 System → L2 HybridRAG → L3 Graph → L4 Vector) on {args.host}:{args.port}")
|
|
2721
|
-
uvicorn.run(app, host=args.host, port=args.port)
|