@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.
Files changed (127) hide show
  1. package/README.md +3 -3
  2. package/bin/cli.js +1 -1
  3. package/bin/commands/config.js +1 -1
  4. package/dist/index.cjs +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +2 -2
  7. package/packages/doctor/src/checks/local-memory.js +2 -2
  8. package/packages/memory/README.md +2 -2
  9. package/packages/memory/openclaw-plugin/README.md +2 -2
  10. package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
  11. package/packages/memory/src/server.js +2 -2
  12. package/packages/memory-engine-v2/.env.example +30 -0
  13. package/packages/memory-engine-v2/README.md +125 -0
  14. package/packages/memory-engine-v2/compat/Dockerfile +11 -0
  15. package/packages/memory-engine-v2/compat/requirements.txt +6 -0
  16. package/packages/memory-engine-v2/compat/server.py +1047 -0
  17. package/packages/memory-engine-v2/docker-compose.aws.yml +78 -0
  18. package/packages/memory-engine-v2/docker-compose.yml +206 -0
  19. package/packages/memory-engine-v2/extractor-async/Dockerfile +14 -0
  20. package/packages/memory-engine-v2/extractor-async/confidence.py +62 -0
  21. package/packages/memory-engine-v2/extractor-async/noise_filter.py +144 -0
  22. package/packages/memory-engine-v2/extractor-async/requirements.txt +2 -0
  23. package/packages/memory-engine-v2/extractor-async/test_confidence.py +76 -0
  24. package/packages/memory-engine-v2/extractor-async/test_noise_filter.py +177 -0
  25. package/packages/memory-engine-v2/extractor-async/worker.py +797 -0
  26. package/packages/memory-engine-v2/extractor-sync/Dockerfile +11 -0
  27. package/packages/memory-engine-v2/extractor-sync/requirements.txt +4 -0
  28. package/packages/memory-engine-v2/extractor-sync/server.py +424 -0
  29. package/packages/memory-engine-v2/org-model/migrations/001_init.sql +390 -0
  30. package/packages/memory-engine-v2/tests/e2e_smoke.py +356 -0
  31. package/packages/memory-engine-v2/tests/fixtures/generate_synthetic_corpus.py +758 -0
  32. package/packages/memory-engine/.env.example +0 -13
  33. package/packages/memory-engine/MIGRATION.md +0 -219
  34. package/packages/memory-engine/README.md +0 -145
  35. package/packages/memory-engine/bench/README.md +0 -99
  36. package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +0 -1115
  37. package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +0 -819
  38. package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +0 -1278
  39. package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +0 -1018
  40. package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +0 -1038
  41. package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +0 -961
  42. package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +0 -1115
  43. package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +0 -819
  44. package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +0 -1278
  45. package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +0 -1018
  46. package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +0 -1038
  47. package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +0 -937
  48. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +0 -1115
  49. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +0 -819
  50. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +0 -1278
  51. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +0 -1018
  52. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +0 -1038
  53. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +0 -961
  54. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +0 -1115
  55. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +0 -819
  56. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +0 -1278
  57. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +0 -1018
  58. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +0 -1038
  59. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +0 -883
  60. package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +0 -1115
  61. package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +0 -819
  62. package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +0 -1278
  63. package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +0 -1018
  64. package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +0 -1038
  65. package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +0 -937
  66. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +0 -1115
  67. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +0 -1115
  68. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +0 -819
  69. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +0 -542
  70. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +0 -1278
  71. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +0 -894
  72. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +0 -1018
  73. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +0 -680
  74. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +0 -1038
  75. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +0 -693
  76. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +0 -961
  77. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +0 -727
  78. package/packages/memory-engine/compat/Dockerfile +0 -22
  79. package/packages/memory-engine/compat/server.py +0 -1255
  80. package/packages/memory-engine/docker-compose.test.yml +0 -59
  81. package/packages/memory-engine/docker-compose.yml +0 -255
  82. package/packages/memory-engine/engine/README.md +0 -52
  83. package/packages/memory-engine/engine/l2-hybridrag-proxy.py +0 -1543
  84. package/packages/memory-engine/engine/l5-comms-layer.py +0 -663
  85. package/packages/memory-engine/engine/l6-document-store.py +0 -1018
  86. package/packages/memory-engine/engine/services/_shared/__init__.py +0 -1
  87. package/packages/memory-engine/engine/services/_shared/embed_provider.py +0 -562
  88. package/packages/memory-engine/engine/services/l2/Dockerfile +0 -50
  89. package/packages/memory-engine/engine/services/l2/init_databases.py +0 -81
  90. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +0 -2721
  91. package/packages/memory-engine/engine/services/l5/Dockerfile +0 -11
  92. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +0 -808
  93. package/packages/memory-engine/engine/services/l6/Dockerfile +0 -30
  94. package/packages/memory-engine/engine/services/l6/l6-document-store.py +0 -1221
  95. package/packages/memory-engine/engine/services/nv-embed/Dockerfile +0 -28
  96. package/packages/memory-engine/engine/services/nv-embed/server.py +0 -152
  97. package/packages/memory-engine/pme_memory/__init__.py +0 -0
  98. package/packages/memory-engine/pme_memory/__main__.py +0 -129
  99. package/packages/memory-engine/pme_memory/artifacts.py +0 -95
  100. package/packages/memory-engine/pme_memory/embed.py +0 -74
  101. package/packages/memory-engine/pme_memory/health.py +0 -36
  102. package/packages/memory-engine/pme_memory/hygiene.py +0 -159
  103. package/packages/memory-engine/pme_memory/indexer.py +0 -200
  104. package/packages/memory-engine/pme_memory/needs.py +0 -55
  105. package/packages/memory-engine/pme_memory/provenance.py +0 -80
  106. package/packages/memory-engine/pme_memory/scoring.py +0 -168
  107. package/packages/memory-engine/pme_memory/search.py +0 -52
  108. package/packages/memory-engine/pme_memory/store.py +0 -86
  109. package/packages/memory-engine/pme_memory/synthesis.py +0 -114
  110. package/packages/memory-engine/pyproject.toml +0 -65
  111. package/packages/memory-engine/scripts/kg-extractor.py +0 -557
  112. package/packages/memory-engine/scripts/kg-preflexor-v2.py +0 -738
  113. package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +0 -128
  114. package/packages/memory-engine/tests/e2e_arena.sh +0 -259
  115. package/packages/memory-engine/tests/embed_stub/Dockerfile +0 -13
  116. package/packages/memory-engine/tests/embed_stub/server.py +0 -80
  117. package/packages/memory-engine/tests/test_aggregate.py +0 -333
  118. package/packages/memory-engine/tests/test_api_contract.sh +0 -57
  119. package/packages/memory-engine/tests/test_arena_safety.py +0 -232
  120. package/packages/memory-engine/tests/test_channel_stat_reader.py +0 -437
  121. package/packages/memory-engine/tests/test_channel_stat_rollups.py +0 -308
  122. package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +0 -48
  123. package/packages/memory-engine/tests/test_embed_provider.py +0 -693
  124. package/packages/memory-engine/tests/test_l2_qmd_vec_search.py +0 -280
  125. package/packages/memory-engine/tests/test_l3_arena_isolation.py +0 -412
  126. package/packages/memory-engine/tests/test_l6_module_load.py +0 -84
  127. 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)