synapse-memory-engine 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synapse_memory/__init__.py +31 -0
- synapse_memory/api/fastapi_server.py +1334 -0
- synapse_memory/cli/synapse_cli.py +112 -0
- synapse_memory/client.py +127 -0
- synapse_memory/core/__init__.py +50 -0
- synapse_memory/core/auth.py +1273 -0
- synapse_memory/core/decay_engine.py +123 -0
- synapse_memory/core/deduplicator.py +98 -0
- synapse_memory/core/embedder.py +119 -0
- synapse_memory/core/embedding_provider.py +241 -0
- synapse_memory/core/encryption.py +226 -0
- synapse_memory/core/hybrid_search.py +199 -0
- synapse_memory/core/knapsack_packer.py +103 -0
- synapse_memory/core/memory_manager.py +233 -0
- synapse_memory/core/migrations.py +241 -0
- synapse_memory/core/observability.py +48 -0
- synapse_memory/core/pgvector_db.py +1276 -0
- synapse_memory/core/pruner.py +68 -0
- synapse_memory/core/rate_limiter.py +180 -0
- synapse_memory/core/sqlite_db.py +564 -0
- synapse_memory/examples/langchain_integration.py +12 -0
- synapse_memory/examples/llamaindex_integration.py +12 -0
- synapse_memory/integrations/__init__.py +9 -0
- synapse_memory/integrations/langchain.py +209 -0
- synapse_memory/integrations/llamaindex.py +295 -0
- synapse_memory/main.py +112 -0
- synapse_memory/py.typed +0 -0
- synapse_memory_engine-1.3.0.dist-info/METADATA +75 -0
- synapse_memory_engine-1.3.0.dist-info/RECORD +32 -0
- synapse_memory_engine-1.3.0.dist-info/WHEEL +5 -0
- synapse_memory_engine-1.3.0.dist-info/entry_points.txt +4 -0
- synapse_memory_engine-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1334 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import datetime
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
from typing import List, Dict, Any, Optional, Tuple
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
import uvicorn
|
|
11
|
+
from fastapi import FastAPI, HTTPException, status, Header, Request, Depends
|
|
12
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
13
|
+
|
|
14
|
+
# Adjust path to enable absolute imports when running as standalone script
|
|
15
|
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
16
|
+
|
|
17
|
+
from synapse_memory.core.sqlite_db import SQLiteMemoryStore
|
|
18
|
+
from synapse_memory.core.pgvector_db import PGVectorMemoryStore
|
|
19
|
+
from synapse_memory.core.embedder import SynapseEmbedder
|
|
20
|
+
from synapse_memory.core.memory_manager import DEFAULT_MAX_RECORD_LIMIT, MemoryManager
|
|
21
|
+
from synapse_memory.core.knapsack_packer import KnapsackPacker
|
|
22
|
+
from synapse_memory.core.decay_engine import DecayEngine
|
|
23
|
+
from synapse_memory.core.hybrid_search import HybridSearch
|
|
24
|
+
from synapse_memory.core.auth import (
|
|
25
|
+
SecurityManager,
|
|
26
|
+
APIKeyRecord,
|
|
27
|
+
can_create_api_key,
|
|
28
|
+
is_platform_admin,
|
|
29
|
+
tenant_selection_allowed,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("SynapseGateway")
|
|
33
|
+
logging.basicConfig(level=logging.INFO)
|
|
34
|
+
|
|
35
|
+
# Detect Canonical Database Storage Backend
|
|
36
|
+
DATABASE_URL = os.environ.get("DATABASE_URL") or os.environ.get("POSTGRES_URL")
|
|
37
|
+
STORAGE_BACKEND = os.environ.get("SYNAPSE_STORAGE_BACKEND", "auto").lower()
|
|
38
|
+
# SQLite file location. Configurable so tests and multi-instance dev setups can
|
|
39
|
+
# isolate their state instead of always colliding on ./synapse_memory.db.
|
|
40
|
+
SQLITE_DB_PATH = os.environ.get("SYNAPSE_SQLITE_DB_PATH", "synapse_memory.db")
|
|
41
|
+
IS_PRODUCTION = os.environ.get("NODE_ENV", "").lower() == "production"
|
|
42
|
+
# Fail-closed by default whenever a DATABASE_URL is configured: silent in-memory
|
|
43
|
+
# fallback causes data loss and is unacceptable for teams. Explicit opt-out via
|
|
44
|
+
# SYNAPSE_ALLOW_DB_FALLBACK=true (dev only).
|
|
45
|
+
ALLOW_DB_FALLBACK = os.environ.get("SYNAPSE_ALLOW_DB_FALLBACK", "").lower() == "true"
|
|
46
|
+
FAIL_CLOSED_DB = bool(DATABASE_URL) and not ALLOW_DB_FALLBACK
|
|
47
|
+
# Gate demo/simulated routes (infrastructure fantasy metrics, licensing toy).
|
|
48
|
+
# Production deployments MUST set ENABLE_DEMO_ROUTES=false.
|
|
49
|
+
ENABLE_DEMO_ROUTES = os.environ.get("ENABLE_DEMO_ROUTES", "false" if IS_PRODUCTION else "true").lower() == "true"
|
|
50
|
+
# Schema ownership topology. true (default) = the engine creates/upgrades its own
|
|
51
|
+
# schema (single-role local/dev). false = "migrate-then-run": DDL + migrations are
|
|
52
|
+
# performed ahead of start-up by the migration role, and the engine connects as a
|
|
53
|
+
# least-privilege DML-only runtime role (scripts/provision_db_roles.py). Required
|
|
54
|
+
# when the runtime role is a NON-owner without BYPASSRLS, because such a role
|
|
55
|
+
# cannot execute DDL.
|
|
56
|
+
APPLY_DB_SCHEMA = os.environ.get("SYNAPSE_APPLY_DB_SCHEMA", "true").lower() == "true"
|
|
57
|
+
|
|
58
|
+
# Security & Distributed Identity Engine
|
|
59
|
+
# When SYNAPSE_APPLY_DB_SCHEMA=false (least-privilege production topology), the
|
|
60
|
+
# engine must not issue DDL; it only validates that auth tables exist and are
|
|
61
|
+
# readable by the runtime role.
|
|
62
|
+
security_manager = SecurityManager(
|
|
63
|
+
db_path=SQLITE_DB_PATH,
|
|
64
|
+
connection_string=DATABASE_URL if (DATABASE_URL and STORAGE_BACKEND in ["pgvector", "auto"]) else None,
|
|
65
|
+
fail_closed=FAIL_CLOSED_DB or (IS_PRODUCTION and bool(DATABASE_URL)),
|
|
66
|
+
validate_schema_only=not APPLY_DB_SCHEMA,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Instantiate active embedder to establish canonical vector dimension
|
|
70
|
+
embedder = SynapseEmbedder()
|
|
71
|
+
embedding_dimension = embedder.dimension
|
|
72
|
+
|
|
73
|
+
store: Any
|
|
74
|
+
if STORAGE_BACKEND == "pgvector":
|
|
75
|
+
try:
|
|
76
|
+
store = PGVectorMemoryStore(
|
|
77
|
+
connection_string=DATABASE_URL,
|
|
78
|
+
dimension=embedding_dimension,
|
|
79
|
+
fail_closed=True,
|
|
80
|
+
apply_schema=APPLY_DB_SCHEMA,
|
|
81
|
+
)
|
|
82
|
+
ACTIVE_BACKEND = "pgvector"
|
|
83
|
+
logger.info(f"Canonical Production Engine using PostgreSQL + pgvector ({embedding_dimension}-d HNSW) at {str(DATABASE_URL)[:20]}...")
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.exception(f"PostgreSQL startup failed in pgvector mode: {e}")
|
|
86
|
+
raise
|
|
87
|
+
elif STORAGE_BACKEND == "auto" and DATABASE_URL:
|
|
88
|
+
try:
|
|
89
|
+
store = PGVectorMemoryStore(
|
|
90
|
+
connection_string=DATABASE_URL,
|
|
91
|
+
dimension=embedding_dimension,
|
|
92
|
+
fail_closed=FAIL_CLOSED_DB or IS_PRODUCTION,
|
|
93
|
+
apply_schema=APPLY_DB_SCHEMA,
|
|
94
|
+
)
|
|
95
|
+
ACTIVE_BACKEND = "pgvector"
|
|
96
|
+
logger.info(f"Canonical Production Engine using PostgreSQL + pgvector ({embedding_dimension}-d HNSW) at {str(DATABASE_URL)[:20]}...")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
if FAIL_CLOSED_DB or IS_PRODUCTION:
|
|
99
|
+
logger.exception(f"PostgreSQL startup failed (fail-closed): {e}")
|
|
100
|
+
raise
|
|
101
|
+
logger.warning(f"Could not connect to PostgreSQL ({e}); initializing SQLiteMemoryStore.")
|
|
102
|
+
store = SQLiteMemoryStore(SQLITE_DB_PATH)
|
|
103
|
+
ACTIVE_BACKEND = "sqlite"
|
|
104
|
+
else:
|
|
105
|
+
store = SQLiteMemoryStore(SQLITE_DB_PATH)
|
|
106
|
+
ACTIVE_BACKEND = "sqlite"
|
|
107
|
+
|
|
108
|
+
manager = MemoryManager(store=store, embedder=embedder, max_record_limit=DEFAULT_MAX_RECORD_LIMIT)
|
|
109
|
+
logger.info(
|
|
110
|
+
f"MemoryManager capacity limit: {manager.max_record_limit} records "
|
|
111
|
+
f"(override with SYNAPSE_MAX_RECORD_LIMIT; historical default is 50)"
|
|
112
|
+
)
|
|
113
|
+
packer = KnapsackPacker(token_budget=min(int(os.environ.get("TOKEN_BUDGET_MAX", "4000")), KnapsackPacker.MAX_BUDGET))
|
|
114
|
+
decay = DecayEngine(category_configs={})
|
|
115
|
+
searcher = HybridSearch()
|
|
116
|
+
|
|
117
|
+
# --- Rate limiting: distributed (Redis) when configured, else in-process ---
|
|
118
|
+
# REDIS_URL enables a Redis sorted-set sliding window enforced across ALL
|
|
119
|
+
# replicas; without it (or when Redis is unreachable) we degrade to a
|
|
120
|
+
# thread-safe per-process window. See synapse_memory.core.rate_limiter.
|
|
121
|
+
from synapse_memory.core.rate_limiter import build_rate_limiter
|
|
122
|
+
|
|
123
|
+
RATE_LIMIT_PER_MIN = int(os.environ.get("RATE_LIMIT_PER_MIN", "120"))
|
|
124
|
+
RATE_LIMIT_WINDOW_SECONDS = float(os.environ.get("RATE_LIMIT_WINDOW_SECONDS", "60"))
|
|
125
|
+
rate_limiter = build_rate_limiter()
|
|
126
|
+
|
|
127
|
+
# --- Live operational metrics (no extra deps; scraped via /metrics) ---
|
|
128
|
+
_metrics_lock = threading.Lock()
|
|
129
|
+
# Request counts keyed by (route_template, status) with a max of 10000 distinct
|
|
130
|
+
# keys. Dynamic paths (e.g. /api/memories/<id>) are bucketed as "other" so
|
|
131
|
+
# request volume cannot exhaust memory (F-08).
|
|
132
|
+
_REQUEST_COUNTS: Dict[Tuple[str, str], int] = {}
|
|
133
|
+
# Latency samples keyed by route template (bounded by _REQUEST_COUNTS).
|
|
134
|
+
_LATENCY_SAMPLES: Dict[str, List[float]] = {}
|
|
135
|
+
# Key expiration for metrics: (timestamp, count) tuples; entries older than
|
|
136
|
+
# _METRICS_TTL_SECONDS are pruned on each scrape.
|
|
137
|
+
_METRICS_TTL: Dict[Tuple[str, str], Tuple[float, int]] = {}
|
|
138
|
+
_METRICS_TTL_SECONDS = 3600.0
|
|
139
|
+
# Max distinct route templates; excess paths get bucketed as "other".
|
|
140
|
+
_MAX_ROUTE_TEMPLATES = 10000
|
|
141
|
+
# Canonical route templates for dynamic paths (F-08)
|
|
142
|
+
_ROUTE_TEMPLATE_MAP = {
|
|
143
|
+
"/api/memories": "/api/memories",
|
|
144
|
+
"/api/memories/{memory_id}": "/api/memories/{memory_id}",
|
|
145
|
+
"/api/memories/list": "/api/memories",
|
|
146
|
+
"/api/memories/{memory_id}/feedback": "/api/memories/{memory_id}/feedback",
|
|
147
|
+
}
|
|
148
|
+
_rate_limit_rejections = 0
|
|
149
|
+
_rate_limit_checks = 0
|
|
150
|
+
_ingest_success = 0
|
|
151
|
+
_ingest_errors = 0
|
|
152
|
+
_retrieval_success = 0
|
|
153
|
+
_retrieval_errors = 0
|
|
154
|
+
# Auth/authorization failures. Exported as synapse_auth_failures_total in
|
|
155
|
+
# /metrics; incremented on the fail-closed path in _build_auth_dependency.
|
|
156
|
+
_auth_failures = 0
|
|
157
|
+
_last_candidate_count = 0
|
|
158
|
+
_last_packed_tokens = 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _record_request(path: str, status: int, latency_s: float) -> None:
|
|
162
|
+
"""Record request metrics with bounded cardinality (F-08).
|
|
163
|
+
|
|
164
|
+
Route templates are used as labels (e.g. /api/memories/{memory_id});
|
|
165
|
+
dynamic paths are bucketed as 'other'. Keys are pruned after TTL.
|
|
166
|
+
"""
|
|
167
|
+
with _metrics_lock:
|
|
168
|
+
# Prune expired keys
|
|
169
|
+
now = time.time()
|
|
170
|
+
expired_keys = [
|
|
171
|
+
k for k, (t, _) in _METRICS_TTL.items() if now - t > _METRICS_TTL_SECONDS
|
|
172
|
+
]
|
|
173
|
+
for key in expired_keys:
|
|
174
|
+
_REQUEST_COUNTS.pop(key, None)
|
|
175
|
+
_METRICS_TTL.pop(key, None)
|
|
176
|
+
|
|
177
|
+
# Bucket path: use route template if known, else 'other'
|
|
178
|
+
route_template = _ROUTE_TEMPLATE_MAP.get(path, "other")
|
|
179
|
+
|
|
180
|
+
key = (route_template, str(status))
|
|
181
|
+
if key not in _REQUEST_COUNTS and len(_REQUEST_COUNTS) >= _MAX_ROUTE_TEMPLATES:
|
|
182
|
+
# Hard ceiling on distinct series: over-cap paths fold into 'other'
|
|
183
|
+
# instead of growing the counter dict without bound.
|
|
184
|
+
key = ("other", str(status))
|
|
185
|
+
_REQUEST_COUNTS[key] = _REQUEST_COUNTS.get(key, 0) + 1
|
|
186
|
+
# Record freshness so the prune above can actually expire this series.
|
|
187
|
+
_METRICS_TTL[key] = (now, _REQUEST_COUNTS[key])
|
|
188
|
+
samples = _LATENCY_SAMPLES.setdefault(route_template, [])
|
|
189
|
+
samples.append(latency_s)
|
|
190
|
+
if len(samples) > 500:
|
|
191
|
+
del samples[: len(samples) - 500]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_rate_limit(key: str) -> None:
|
|
195
|
+
global _rate_limit_rejections, _rate_limit_checks
|
|
196
|
+
with _metrics_lock:
|
|
197
|
+
_rate_limit_checks += 1
|
|
198
|
+
should_prune = _rate_limit_checks % 256 == 0
|
|
199
|
+
# Opportunistically bound bucket growth: a spoofed/rotating key space must
|
|
200
|
+
# not accumulate buckets forever. No-op on the Redis limiter (keys PEXPIRE).
|
|
201
|
+
if should_prune:
|
|
202
|
+
prune = getattr(rate_limiter, "prune_idle", None)
|
|
203
|
+
if callable(prune):
|
|
204
|
+
try:
|
|
205
|
+
prune()
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
if rate_limiter.check(key, RATE_LIMIT_PER_MIN, RATE_LIMIT_WINDOW_SECONDS):
|
|
209
|
+
return
|
|
210
|
+
with _metrics_lock:
|
|
211
|
+
_rate_limit_rejections += 1
|
|
212
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded. Retry after 60s.")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _rate_limit_principal(request: Request) -> str:
|
|
216
|
+
"""Stable, non-reversible bucket identity for a request.
|
|
217
|
+
|
|
218
|
+
Buckets key on a truncated SHA-256 of the presented API key — never the raw
|
|
219
|
+
secret — so every request from the same credential shares one bucket and the
|
|
220
|
+
secret is never stored in limiter state. The client IP is folded in so a
|
|
221
|
+
caller rotating spoofed API-key values cannot mint unlimited buckets from a
|
|
222
|
+
single source address. Anonymous traffic falls back to the client IP alone.
|
|
223
|
+
"""
|
|
224
|
+
api_key = request.headers.get("X-Synapse-API-Key") or ""
|
|
225
|
+
client_ip = request.client.host if request.client else "anon"
|
|
226
|
+
if api_key:
|
|
227
|
+
digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16]
|
|
228
|
+
return f"{client_ip}:{digest}"
|
|
229
|
+
return f"ip:{client_ip}"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# Initialize FastAPI application (MUST precede any @app.middleware / route registration)
|
|
233
|
+
app = FastAPI(
|
|
234
|
+
title="SynapseMemory Enterprise Cognitive Gateway",
|
|
235
|
+
description="Sovereign Active Long-Term Memory & Context Budgeting REST API with Multi-Tenancy and Database-Level RLS.",
|
|
236
|
+
version="1.2.0"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.middleware("http")
|
|
241
|
+
async def add_request_id_and_rate_limit(request: Request, call_next): # type: ignore
|
|
242
|
+
import uuid as _uuid
|
|
243
|
+
|
|
244
|
+
request_id = request.headers.get("X-Request-ID") or f"req_{_uuid.uuid4().hex[:12]}"
|
|
245
|
+
start = time.time()
|
|
246
|
+
# Rate-limit only mutating + heavy retrieval endpoints
|
|
247
|
+
if request.url.path.startswith(("/ingest", "/api/memories", "/query", "/api/retrieval")):
|
|
248
|
+
try:
|
|
249
|
+
_check_rate_limit(f"{_rate_limit_principal(request)}:{request.url.path}")
|
|
250
|
+
except HTTPException as e:
|
|
251
|
+
from fastapi.responses import JSONResponse
|
|
252
|
+
|
|
253
|
+
_record_request(request.url.path, e.status_code, time.time() - start)
|
|
254
|
+
return JSONResponse(
|
|
255
|
+
status_code=e.status_code,
|
|
256
|
+
content={"success": False, "error": e.detail, "request_id": request_id},
|
|
257
|
+
)
|
|
258
|
+
# HTTPException raised inside endpoints is shaped by the registered handler
|
|
259
|
+
# below; the middleware only stamps the request id on successful responses.
|
|
260
|
+
response = await call_next(request)
|
|
261
|
+
response.headers["X-Request-ID"] = request_id
|
|
262
|
+
try:
|
|
263
|
+
_record_request(request.url.path, response.status_code, time.time() - start)
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
return response
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@app.exception_handler(HTTPException)
|
|
270
|
+
async def http_exception_handler(request: Request, exc: HTTPException): # type: ignore
|
|
271
|
+
"""Shape HTTPException into the canonical error envelope with request_id."""
|
|
272
|
+
import uuid as _uuid
|
|
273
|
+
from fastapi.responses import JSONResponse
|
|
274
|
+
|
|
275
|
+
request_id = request.headers.get("X-Request-ID") or f"req_{_uuid.uuid4().hex[:12]}"
|
|
276
|
+
return JSONResponse(
|
|
277
|
+
status_code=exc.status_code,
|
|
278
|
+
content={"success": False, "error": exc.detail, "request_id": request_id},
|
|
279
|
+
headers={"X-Request-ID": request_id},
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# Tighten CORS to production-appropriate origins
|
|
284
|
+
ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
|
|
285
|
+
app.add_middleware(
|
|
286
|
+
CORSMiddleware,
|
|
287
|
+
allow_origins=["*"] if os.environ.get("ALLOW_ALL_CORS") == "true" else ALLOWED_ORIGINS,
|
|
288
|
+
allow_credentials=True,
|
|
289
|
+
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
|
290
|
+
allow_headers=["*"],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@app.on_event("shutdown")
|
|
295
|
+
def _flush_audit_on_shutdown() -> None:
|
|
296
|
+
"""Flush buffered audit rows on graceful shutdown (F-13).
|
|
297
|
+
|
|
298
|
+
Without this, a clean container stop still loses the pending audit batch;
|
|
299
|
+
abrupt SIGKILL remains a documented residual risk (see SECURITY.md).
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
security_manager.close()
|
|
303
|
+
except Exception: # pragma: no cover - shutdown must never raise
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# Security Authentication Dependency & Scope Enforcement
|
|
308
|
+
def require_scope(scope: str):
|
|
309
|
+
async def dependency(
|
|
310
|
+
request: Request,
|
|
311
|
+
x_synapse_api_key: Optional[str] = Header(None, alias="X-Synapse-API-Key"),
|
|
312
|
+
authorization: Optional[str] = Header(None)
|
|
313
|
+
) -> APIKeyRecord:
|
|
314
|
+
token = x_synapse_api_key
|
|
315
|
+
if not token and authorization:
|
|
316
|
+
if authorization.lower().startswith("bearer "):
|
|
317
|
+
token = authorization[7:].strip()
|
|
318
|
+
else:
|
|
319
|
+
token = authorization.strip()
|
|
320
|
+
|
|
321
|
+
tenant_header = request.headers.get("X-Tenant-ID") or request.headers.get("x-tenant-id")
|
|
322
|
+
client_ip = request.client.host if request.client else None
|
|
323
|
+
|
|
324
|
+
# Dev-open bypass is ONLY for a completely unconfigured local setup:
|
|
325
|
+
# not in production AND no SYNAPSE_API_KEY env AND no keys provisioned.
|
|
326
|
+
# The moment any auth is configured, anonymous requests are denied (403).
|
|
327
|
+
# Short-circuited so authenticated requests never hit the keys table.
|
|
328
|
+
if not token:
|
|
329
|
+
auth_configured = bool(os.environ.get("SYNAPSE_API_KEY")) or len(security_manager.list_api_keys()) > 0
|
|
330
|
+
if not IS_PRODUCTION and not auth_configured:
|
|
331
|
+
return APIKeyRecord(
|
|
332
|
+
key_id="key_dev_open",
|
|
333
|
+
key_hash="",
|
|
334
|
+
name="Development Default",
|
|
335
|
+
organization_id="org_dev",
|
|
336
|
+
project_id="proj_dev",
|
|
337
|
+
tenant_id=tenant_header or "default",
|
|
338
|
+
scopes=["admin", "memories:read", "memories:write", "memories:delete"],
|
|
339
|
+
# Explicit platform_admin: this bypass exists only when NO
|
|
340
|
+
# auth is configured at all (local dev). It was previously
|
|
341
|
+
# role="admin", which now normalizes to workspace_admin.
|
|
342
|
+
role="platform_admin",
|
|
343
|
+
is_active=True,
|
|
344
|
+
expires_at=None,
|
|
345
|
+
created_at=time.time(),
|
|
346
|
+
last_used_at=time.time()
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
valid, record, reason = security_manager.validate_api_key(
|
|
350
|
+
token=token or "",
|
|
351
|
+
required_scope=scope,
|
|
352
|
+
requested_tenant_id=tenant_header,
|
|
353
|
+
route=request.url.path,
|
|
354
|
+
action=request.method,
|
|
355
|
+
client_ip=client_ip,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if not valid or record is None:
|
|
359
|
+
global _auth_failures
|
|
360
|
+
with _metrics_lock:
|
|
361
|
+
_auth_failures += 1
|
|
362
|
+
raise HTTPException(
|
|
363
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
364
|
+
detail="Authentication failed.",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return record
|
|
368
|
+
|
|
369
|
+
return dependency
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def resolve_tenant(
|
|
373
|
+
principal: APIKeyRecord,
|
|
374
|
+
requested_tenant: Optional[str]
|
|
375
|
+
) -> str:
|
|
376
|
+
"""Resolve the effective tenant for this request.
|
|
377
|
+
|
|
378
|
+
Cross-tenant selection is a platform_admin capability only (F-01). Neither
|
|
379
|
+
the raw stored role nor the legacy "admin" scope widens the boundary, so a
|
|
380
|
+
delegated workspace key can never operate on another tenant by asking.
|
|
381
|
+
"""
|
|
382
|
+
if is_platform_admin(principal):
|
|
383
|
+
if requested_tenant:
|
|
384
|
+
return requested_tenant
|
|
385
|
+
if principal.tenant_id not in ("*", ""):
|
|
386
|
+
return principal.tenant_id
|
|
387
|
+
raise HTTPException(
|
|
388
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
389
|
+
detail="Explicit tenant_id is required for admin cross-tenant operations."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if not tenant_selection_allowed(principal, requested_tenant):
|
|
393
|
+
raise HTTPException(
|
|
394
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
395
|
+
detail="Tenant mismatch."
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return principal.tenant_id or "default"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def envelope(data: Any, request: Optional[Request] = None) -> Dict[str, Any]:
|
|
402
|
+
"""Wrap a successful payload in the canonical response envelope.
|
|
403
|
+
|
|
404
|
+
{success: true, data: <payload>, request_id: <id>}
|
|
405
|
+
|
|
406
|
+
Every JSON endpoint returns this so clients (React dashboard, Python/JS SDKs,
|
|
407
|
+
curl) read `success`/`data` uniformly. request_id is echoed from the
|
|
408
|
+
inbound header when present so a caller can trace a request end-to-end.
|
|
409
|
+
"""
|
|
410
|
+
rid = ""
|
|
411
|
+
if request is not None:
|
|
412
|
+
rid = request.headers.get("X-Request-ID", "")
|
|
413
|
+
return {"success": True, "data": data, "request_id": rid}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# --- PYDANTIC SCHEMAS ---
|
|
417
|
+
|
|
418
|
+
class IngestRequest(BaseModel):
|
|
419
|
+
content: str = Field(..., min_length=1, max_length=32000, description="Raw text representation of the memory segment")
|
|
420
|
+
category: str = Field("interaction", min_length=1, max_length=64, pattern=r"^[a-z0-9_\-]+$", description="Cognitive category (e.g., interaction, preference, system)")
|
|
421
|
+
confidence: float = Field(0.95, ge=0.0, le=1.0, description="Confidence alignment coefficient")
|
|
422
|
+
tenant_id: Optional[str] = Field(None, alias="tenantId", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$", description="Target tenant ID for isolation")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class IngestResponse(BaseModel):
|
|
426
|
+
id: str
|
|
427
|
+
status: str
|
|
428
|
+
token_cost: int
|
|
429
|
+
tenant_id: str
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class QueryRequest(BaseModel):
|
|
433
|
+
prompt: Optional[str] = Field(None, min_length=1, max_length=8000, description="Active user prompt")
|
|
434
|
+
query: Optional[str] = Field(None, min_length=1, max_length=8000, description="Alternative alias for prompt")
|
|
435
|
+
max_token_budget: int = Field(1500, ge=50, le=8000, description="Max token window allowed for context packing", alias="maxTokens")
|
|
436
|
+
category: Optional[str] = Field(None, min_length=1, max_length=64, pattern=r"^[a-z0-9_\-]+$", description="Optional category filter")
|
|
437
|
+
tenant_id: Optional[str] = Field(None, alias="tenantId", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$", description="Target tenant ID")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class QueryResponse(BaseModel):
|
|
441
|
+
fused_context: str
|
|
442
|
+
injected_nodes_count: int
|
|
443
|
+
injected_nodes: List[Dict[str, Any]]
|
|
444
|
+
active_storage_backend: str
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class FeedbackRequest(BaseModel):
|
|
448
|
+
memory_id: str = Field(..., min_length=1, max_length=256, description="Memory node ID")
|
|
449
|
+
feedback_type: str = Field(..., pattern=r"^(positive|negative)$", description="Must be either 'positive' (boost) or 'negative' (hallucination alert)")
|
|
450
|
+
tenant_id: Optional[str] = Field(None, alias="tenantId", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class CreateKeyRequest(BaseModel):
|
|
454
|
+
name: str = Field(..., min_length=1, max_length=128)
|
|
455
|
+
organization_id: str = Field("org_default", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$")
|
|
456
|
+
project_id: str = Field("proj_default", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$")
|
|
457
|
+
tenant_id: str = Field("default", min_length=1, max_length=128, pattern=r"^([A-Za-z0-9_\-\.]+|\*)$")
|
|
458
|
+
scopes: List[str] = Field(["memories:read", "memories:write"], min_length=1, max_length=16)
|
|
459
|
+
# Accepts the canonical roles plus the legacy "admin" string. The legacy
|
|
460
|
+
# string stays schema-valid so can_create_api_key() can reject it with a
|
|
461
|
+
# clear 403 ("not assignable") instead of a generic 422, and so the role
|
|
462
|
+
# hierarchy is actually reachable through the API (F-01).
|
|
463
|
+
role: str = Field(
|
|
464
|
+
"developer",
|
|
465
|
+
pattern=r"^(admin|platform_admin|organization_admin|workspace_admin|developer|service|viewer)$",
|
|
466
|
+
)
|
|
467
|
+
ttl_days: Optional[int] = Field(365, ge=1, le=3650)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class RevokeKeyRequest(BaseModel):
|
|
471
|
+
key_id: str = Field(..., min_length=1, max_length=128)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
AUTH_KEYS_CREATE_SCOPE = "auth:keys:create"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# --- Telemetry (no auth) ---
|
|
478
|
+
|
|
479
|
+
@app.get("/liveness", tags=["Telemetry"])
|
|
480
|
+
def liveness():
|
|
481
|
+
"""Returns simple process liveness state.
|
|
482
|
+
|
|
483
|
+
DB-independent by design: the engine can be running and the process alive
|
|
484
|
+
even when the database or auth backend has failed. K8s `livenessProbe`
|
|
485
|
+
depends on this route; failures restart the container but do not take it
|
|
486
|
+
out of rotation until /readiness fails.
|
|
487
|
+
"""
|
|
488
|
+
return {"status": "OK", "timestamp": time.time()}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@app.get("/readiness", tags=["Telemetry"])
|
|
492
|
+
def readiness():
|
|
493
|
+
"""Checks actual database and auth backend usability.
|
|
494
|
+
|
|
495
|
+
Performs a bounded live health check with a short timeout (F-07). This is
|
|
496
|
+
the Kubernetes readiness probe: it must fail closed (503) when the storage
|
|
497
|
+
backend becomes unreachable so traffic is shed before requests start
|
|
498
|
+
failing.
|
|
499
|
+
"""
|
|
500
|
+
if ACTIVE_BACKEND == "pgvector":
|
|
501
|
+
# Bounded live check (F-07): trigger a real connection attempt with timeout.
|
|
502
|
+
# NOTE: psycopg2 connections have no execute() (that is a psycopg3 API), so
|
|
503
|
+
# the check must go through a cursor — otherwise readiness always 503s and
|
|
504
|
+
# the k8s probe keeps the engine out of rotation forever.
|
|
505
|
+
conn = None
|
|
506
|
+
try:
|
|
507
|
+
conn = store._get_connection()
|
|
508
|
+
with conn.cursor() as cur:
|
|
509
|
+
cur.execute("SELECT 1")
|
|
510
|
+
cur.fetchone()
|
|
511
|
+
conn.rollback() # end the probe's transaction before returning it
|
|
512
|
+
except Exception as e:
|
|
513
|
+
logger.warning("Readiness check failed: " + str(e))
|
|
514
|
+
raise HTTPException(
|
|
515
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
516
|
+
detail={
|
|
517
|
+
"status": "UNHEALTHY",
|
|
518
|
+
"storage_backend": ACTIVE_BACKEND,
|
|
519
|
+
"reason": "PostgreSQL connection unavailable."
|
|
520
|
+
},
|
|
521
|
+
)
|
|
522
|
+
finally:
|
|
523
|
+
if conn is not None:
|
|
524
|
+
store._return_connection(conn)
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
"status": "READY",
|
|
528
|
+
"timestamp": time.time(),
|
|
529
|
+
"storage_backend": ACTIVE_BACKEND,
|
|
530
|
+
"embedding_provider": embedder.provider_name,
|
|
531
|
+
"embedding_is_semantic": embedder.is_semantic
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@app.get("/health", tags=["Telemetry"])
|
|
536
|
+
def get_health():
|
|
537
|
+
"""Returns active service telemetry, storage backend, and security status."""
|
|
538
|
+
db_ok = True
|
|
539
|
+
if ACTIVE_BACKEND == "pgvector":
|
|
540
|
+
db_ok = store.is_connected
|
|
541
|
+
|
|
542
|
+
if not db_ok:
|
|
543
|
+
raise HTTPException(
|
|
544
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
545
|
+
detail={
|
|
546
|
+
"status": "UNHEALTHY",
|
|
547
|
+
"storage_backend": ACTIVE_BACKEND,
|
|
548
|
+
"reason": "Database connection offline"
|
|
549
|
+
}
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
"status": "HEALTHY",
|
|
554
|
+
"timestamp": time.time(),
|
|
555
|
+
"storage_backend": ACTIVE_BACKEND,
|
|
556
|
+
"hnsw_enabled": ACTIVE_BACKEND == "pgvector",
|
|
557
|
+
"row_level_security": "ENFORCED" if ACTIVE_BACKEND == "pgvector" else "TENANT_INDEXED",
|
|
558
|
+
"total_indexed_memories": store.count_memories(),
|
|
559
|
+
"embedding_provider": embedder.provider_name,
|
|
560
|
+
"engine_budget_solver": "Knapsack DP 0/1",
|
|
561
|
+
"auth_engine": "Hashed RBAC + Audit Log"
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@app.get("/api/memories", tags=["Memory Operations"])
|
|
566
|
+
@app.get("/api/memories/list", tags=["Memory Operations"])
|
|
567
|
+
def list_memories(
|
|
568
|
+
request: Request,
|
|
569
|
+
tenant_id: Optional[str] = None,
|
|
570
|
+
category: Optional[str] = None,
|
|
571
|
+
limit: int = 50,
|
|
572
|
+
offset: int = 0,
|
|
573
|
+
principal: APIKeyRecord = Depends(require_scope("memories:read"))
|
|
574
|
+
):
|
|
575
|
+
"""Lists memories for the authenticated tenant with pagination."""
|
|
576
|
+
effective_tenant = resolve_tenant(principal, tenant_id)
|
|
577
|
+
limit = max(1, min(limit, 200))
|
|
578
|
+
offset = max(0, offset)
|
|
579
|
+
if category:
|
|
580
|
+
mems = store.get_memories_by_category(category, tenant_id=effective_tenant)
|
|
581
|
+
# Full category-filtered set: length is the accurate total.
|
|
582
|
+
total = len(mems)
|
|
583
|
+
else:
|
|
584
|
+
# Pagination total must be the REAL corpus size (via COUNT), not the
|
|
585
|
+
# length of the fetched page window.
|
|
586
|
+
if hasattr(store, "count_active_memories"):
|
|
587
|
+
total = store.count_active_memories(tenant_id=effective_tenant) # type: ignore
|
|
588
|
+
elif hasattr(store, "count_memories"):
|
|
589
|
+
total = store.count_memories(tenant_id=effective_tenant) # type: ignore
|
|
590
|
+
else:
|
|
591
|
+
total = None # type: ignore
|
|
592
|
+
# Prefer bounded candidate query when available to avoid full-table loads
|
|
593
|
+
if hasattr(store, "query_candidates"):
|
|
594
|
+
try:
|
|
595
|
+
mems = store.query_candidates(tenant_id=effective_tenant, limit=limit + offset) # type: ignore
|
|
596
|
+
except Exception:
|
|
597
|
+
mems = store.get_all_memories(tenant_id=effective_tenant)
|
|
598
|
+
else:
|
|
599
|
+
mems = store.get_all_memories(tenant_id=effective_tenant)
|
|
600
|
+
if total is None:
|
|
601
|
+
total = len(mems)
|
|
602
|
+
|
|
603
|
+
page = mems[offset : offset + limit]
|
|
604
|
+
|
|
605
|
+
return envelope(
|
|
606
|
+
{
|
|
607
|
+
"memories": page,
|
|
608
|
+
"tenant_id": effective_tenant or "all",
|
|
609
|
+
"total": total,
|
|
610
|
+
"limit": limit,
|
|
611
|
+
"offset": offset,
|
|
612
|
+
"storage_backend": ACTIVE_BACKEND,
|
|
613
|
+
},
|
|
614
|
+
request,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
@app.post("/ingest", status_code=status.HTTP_201_CREATED, tags=["Ingestion"])
|
|
619
|
+
@app.post("/api/memories/create", status_code=status.HTTP_201_CREATED, tags=["Ingestion"])
|
|
620
|
+
@app.post("/api/memories/add", status_code=status.HTTP_201_CREATED, tags=["Ingestion"])
|
|
621
|
+
def ingest_memory(
|
|
622
|
+
request: Request,
|
|
623
|
+
payload: IngestRequest,
|
|
624
|
+
principal: APIKeyRecord = Depends(require_scope("memories:write"))
|
|
625
|
+
):
|
|
626
|
+
"""
|
|
627
|
+
Ingests memory segments with tenant isolation, cryptographic payload encryption,
|
|
628
|
+
semantic deduplication, and Knapsack token footprint calculation.
|
|
629
|
+
"""
|
|
630
|
+
if not payload.content.strip():
|
|
631
|
+
raise HTTPException(status_code=400, detail="Content string cannot be empty.")
|
|
632
|
+
|
|
633
|
+
effective_tenant = resolve_tenant(principal, payload.tenant_id)
|
|
634
|
+
|
|
635
|
+
global _ingest_success, _ingest_errors
|
|
636
|
+
try:
|
|
637
|
+
memory_id, action, cost = manager.ingest_with_deduplication(
|
|
638
|
+
content=payload.content,
|
|
639
|
+
category=payload.category,
|
|
640
|
+
confidence=payload.confidence,
|
|
641
|
+
tenant_id=effective_tenant
|
|
642
|
+
)
|
|
643
|
+
except Exception:
|
|
644
|
+
with _metrics_lock:
|
|
645
|
+
_ingest_errors += 1
|
|
646
|
+
raise
|
|
647
|
+
with _metrics_lock:
|
|
648
|
+
_ingest_success += 1
|
|
649
|
+
|
|
650
|
+
return envelope(
|
|
651
|
+
{
|
|
652
|
+
"id": memory_id,
|
|
653
|
+
"status": f"SUCCESS_{action}",
|
|
654
|
+
"token_cost": cost,
|
|
655
|
+
"tenant_id": effective_tenant,
|
|
656
|
+
},
|
|
657
|
+
request,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@app.post("/query", tags=["Retrieval"])
|
|
662
|
+
@app.post("/api/memories/query", tags=["Retrieval"])
|
|
663
|
+
@app.post("/api/retrieval/knapsack-pack", tags=["Retrieval"])
|
|
664
|
+
def query_memory(
|
|
665
|
+
request: Request,
|
|
666
|
+
payload: QueryRequest,
|
|
667
|
+
principal: APIKeyRecord = Depends(require_scope("memories:read"))
|
|
668
|
+
):
|
|
669
|
+
"""
|
|
670
|
+
Performs reciprocal hybrid vector search with Ebbinghaus temporal decay
|
|
671
|
+
and packs candidate nodes optimally into context budget using 0/1 Knapsack DP.
|
|
672
|
+
"""
|
|
673
|
+
query_text = payload.prompt or payload.query or ""
|
|
674
|
+
if not query_text.strip():
|
|
675
|
+
return envelope(QueryResponse(fused_context="", injected_nodes_count=0, injected_nodes=[], active_storage_backend=ACTIVE_BACKEND).model_dump(), request)
|
|
676
|
+
|
|
677
|
+
effective_tenant = resolve_tenant(principal, payload.tenant_id)
|
|
678
|
+
query_vector = embedder.embed_query(query_text)
|
|
679
|
+
|
|
680
|
+
# 1. Candidate retrieval. On the pgvector backend, hybrid search (dense HNSW
|
|
681
|
+
# + lexical tsvector) is fused inside a single SQL query (the scale path).
|
|
682
|
+
# On SQLite we load the tenant corpus and fuse in Python (dev/local only).
|
|
683
|
+
candidates = []
|
|
684
|
+
if ACTIVE_BACKEND == "pgvector" and hasattr(store, "hybrid_search_sql"):
|
|
685
|
+
candidates = store.hybrid_search_sql(
|
|
686
|
+
query=query_text,
|
|
687
|
+
query_vec=query_vector,
|
|
688
|
+
top_k=25,
|
|
689
|
+
category=payload.category,
|
|
690
|
+
tenant_id=effective_tenant,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
if not candidates:
|
|
694
|
+
# Fallback / SQLite path: dense + lexical pools fused in Python.
|
|
695
|
+
dense_candidates = store.vector_search(
|
|
696
|
+
query_vec=query_vector,
|
|
697
|
+
top_k=100,
|
|
698
|
+
category=payload.category,
|
|
699
|
+
tenant_id=effective_tenant
|
|
700
|
+
)
|
|
701
|
+
lexical_candidates = store.lexical_search(
|
|
702
|
+
query=query_text,
|
|
703
|
+
top_k=100,
|
|
704
|
+
category=payload.category,
|
|
705
|
+
tenant_id=effective_tenant
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
candidate_map = {m["id"]: m for m in dense_candidates}
|
|
709
|
+
for m in lexical_candidates:
|
|
710
|
+
if m["id"] not in candidate_map:
|
|
711
|
+
candidate_map[m["id"]] = m
|
|
712
|
+
|
|
713
|
+
candidate_docs = list(candidate_map.values())
|
|
714
|
+
if not candidate_docs:
|
|
715
|
+
candidate_docs = store.get_all_memories(tenant_id=effective_tenant)
|
|
716
|
+
|
|
717
|
+
if not candidate_docs:
|
|
718
|
+
return envelope(QueryResponse(fused_context="", injected_nodes_count=0, injected_nodes=[], active_storage_backend=ACTIVE_BACKEND).model_dump(), request)
|
|
719
|
+
|
|
720
|
+
# 2. Reciprocal Rank Fusion (RRF) in Python for the non-pgvector backends.
|
|
721
|
+
candidates = searcher.fused_search(
|
|
722
|
+
query_text,
|
|
723
|
+
candidate_docs,
|
|
724
|
+
query_vector=query_vector,
|
|
725
|
+
top_k=25,
|
|
726
|
+
category_filter=payload.category,
|
|
727
|
+
tenant_filter=effective_tenant
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
if not candidates:
|
|
731
|
+
return envelope(QueryResponse(fused_context="", injected_nodes_count=0, injected_nodes=[], active_storage_backend=ACTIVE_BACKEND).model_dump(), request)
|
|
732
|
+
|
|
733
|
+
# 2. Dynamic temporal decay adjustment: S(m,q,t) = R(m,q) * D(m,t).
|
|
734
|
+
# Preserve retrieval relevance; decay only re-weights priority.
|
|
735
|
+
decayed_candidates = []
|
|
736
|
+
for m in candidates:
|
|
737
|
+
mem_node = {
|
|
738
|
+
"created_at": m["created_at"],
|
|
739
|
+
"last_accessed_at": m.get("last_accessed_at", m["created_at"]),
|
|
740
|
+
"access_count": m.get("access_count", 0),
|
|
741
|
+
"confidence": m.get("confidence", 0.5),
|
|
742
|
+
"category": m.get("category", "interaction")
|
|
743
|
+
}
|
|
744
|
+
retrieval_score = float(m.get("relevance_score", 0.0))
|
|
745
|
+
combined = decay.combine_scores(retrieval_score, mem_node)
|
|
746
|
+
m_copy = m.copy()
|
|
747
|
+
m_copy["retrieval_score"] = retrieval_score
|
|
748
|
+
m_copy["decay_multiplier"] = combined / retrieval_score if retrieval_score else decay.temporal_multiplier(mem_node)
|
|
749
|
+
m_copy["relevance_score"] = combined
|
|
750
|
+
decayed_candidates.append(m_copy)
|
|
751
|
+
|
|
752
|
+
# 3. Dynamic programming 0/1 Knapsack optimal budget packing
|
|
753
|
+
packed_nodes = packer.pack(decayed_candidates, payload.max_token_budget)
|
|
754
|
+
|
|
755
|
+
global _retrieval_success, _last_candidate_count, _last_packed_tokens
|
|
756
|
+
with _metrics_lock:
|
|
757
|
+
_retrieval_success += 1
|
|
758
|
+
_last_candidate_count = len(candidates)
|
|
759
|
+
_last_packed_tokens = sum(int(n.get("token_cost", 0) or 0) for n in packed_nodes)
|
|
760
|
+
|
|
761
|
+
# Increment access counts for Ebbinghaus stabilization
|
|
762
|
+
for node in packed_nodes:
|
|
763
|
+
store.update_access_count(node["id"], 1, tenant_id=effective_tenant)
|
|
764
|
+
|
|
765
|
+
# Formulate fused context block
|
|
766
|
+
context_lines = []
|
|
767
|
+
for n in packed_nodes:
|
|
768
|
+
context_lines.append(f"[{n.get('category', 'MEMORY').upper()}] (Confidence: {n.get('confidence', 0.9):.2f}): {n.get('content', '')}")
|
|
769
|
+
|
|
770
|
+
fused_context_block = "\n---\n".join(context_lines)
|
|
771
|
+
|
|
772
|
+
return envelope(
|
|
773
|
+
{
|
|
774
|
+
"fused_context": fused_context_block,
|
|
775
|
+
"injected_nodes_count": len(packed_nodes),
|
|
776
|
+
"injected_nodes": packed_nodes,
|
|
777
|
+
"active_storage_backend": ACTIVE_BACKEND,
|
|
778
|
+
},
|
|
779
|
+
request,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
@app.post("/feedback", tags=["Reinforcement"])
|
|
784
|
+
def submit_feedback(
|
|
785
|
+
request: Request,
|
|
786
|
+
payload: FeedbackRequest,
|
|
787
|
+
principal: APIKeyRecord = Depends(require_scope("memories:write"))
|
|
788
|
+
):
|
|
789
|
+
"""Applies RLAIF reinforcement feedback to boost or demote memory relevance score."""
|
|
790
|
+
effective_tenant = resolve_tenant(principal, payload.tenant_id)
|
|
791
|
+
node = store.get_memory_by_id(payload.memory_id, tenant_id=effective_tenant)
|
|
792
|
+
if not node:
|
|
793
|
+
raise HTTPException(status_code=404, detail="Memory node ID not found in store.")
|
|
794
|
+
|
|
795
|
+
old_conf = node.get("confidence", 0.5)
|
|
796
|
+
new_conf = decay.compute_feedback_boost(old_conf, payload.feedback_type)
|
|
797
|
+
store.update_confidence(payload.memory_id, new_conf, tenant_id=effective_tenant)
|
|
798
|
+
|
|
799
|
+
return envelope(
|
|
800
|
+
{
|
|
801
|
+
"id": payload.memory_id,
|
|
802
|
+
"feedback_status": "PROCESSED",
|
|
803
|
+
"old_confidence": old_conf,
|
|
804
|
+
"new_confidence": new_conf,
|
|
805
|
+
"tenant_id": effective_tenant,
|
|
806
|
+
},
|
|
807
|
+
request,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@app.delete("/api/memories/{memory_id}", tags=["Memory Operations"])
|
|
812
|
+
def delete_memory(
|
|
813
|
+
request: Request,
|
|
814
|
+
memory_id: str,
|
|
815
|
+
tenant_id: Optional[str] = None,
|
|
816
|
+
principal: APIKeyRecord = Depends(require_scope("memories:delete"))
|
|
817
|
+
):
|
|
818
|
+
"""Deletes memory node by ID with tenant isolation."""
|
|
819
|
+
effective_tenant = resolve_tenant(principal, tenant_id)
|
|
820
|
+
store.delete_memory(memory_id, tenant_id=effective_tenant)
|
|
821
|
+
return envelope({"status": "DELETED", "id": memory_id}, request)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
@app.post("/api/memories/clear", tags=["Memory Operations"])
|
|
825
|
+
def clear_memories(
|
|
826
|
+
request: Request,
|
|
827
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
828
|
+
principal: APIKeyRecord = Depends(require_scope("memories:delete"))
|
|
829
|
+
):
|
|
830
|
+
"""Flushes memory corpus for the authenticated tenant."""
|
|
831
|
+
requested_tenant = payload.get("tenant_id") if payload else None
|
|
832
|
+
effective_tenant = resolve_tenant(principal, requested_tenant)
|
|
833
|
+
deleted_count = store.clear_memories(tenant_id=effective_tenant)
|
|
834
|
+
security_manager.log_audit_action(
|
|
835
|
+
actor_key_id=principal.key_id,
|
|
836
|
+
tenant_id=effective_tenant or "all",
|
|
837
|
+
action="MEMORIES_CLEAR",
|
|
838
|
+
resource="memories",
|
|
839
|
+
status="SUCCESS",
|
|
840
|
+
metadata={"deleted_count": deleted_count}
|
|
841
|
+
)
|
|
842
|
+
return envelope(
|
|
843
|
+
{
|
|
844
|
+
"status": "CLEARED",
|
|
845
|
+
"tenant_id": effective_tenant or "all",
|
|
846
|
+
"deleted_count": deleted_count,
|
|
847
|
+
},
|
|
848
|
+
request,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@app.post("/api/security/gdpr-delete", tags=["Security"])
|
|
853
|
+
def gdpr_delete(
|
|
854
|
+
request: Request,
|
|
855
|
+
payload: Dict[str, Any],
|
|
856
|
+
principal: APIKeyRecord = Depends(require_scope("memories:delete"))
|
|
857
|
+
):
|
|
858
|
+
"""
|
|
859
|
+
Executes an authenticated GDPR Right to be Forgotten deletion path.
|
|
860
|
+
Performs PostgreSQL transaction DELETE on tenant memories and returns actual deleted row count.
|
|
861
|
+
"""
|
|
862
|
+
requested_tenant = payload.get("tenant_id") or payload.get("tenantId")
|
|
863
|
+
effective_tenant = resolve_tenant(principal, requested_tenant)
|
|
864
|
+
deleted_count = store.clear_memories(tenant_id=effective_tenant)
|
|
865
|
+
security_manager.log_audit_action(
|
|
866
|
+
actor_key_id=principal.key_id,
|
|
867
|
+
tenant_id=effective_tenant or "all",
|
|
868
|
+
action="GDPR_RIGHT_TO_BE_FORGOTTEN_DELETE",
|
|
869
|
+
resource="memories",
|
|
870
|
+
status="SUCCESS",
|
|
871
|
+
metadata={"deleted_count": deleted_count}
|
|
872
|
+
)
|
|
873
|
+
return envelope(
|
|
874
|
+
{
|
|
875
|
+
"status": "SUCCESS",
|
|
876
|
+
"tenant_id": effective_tenant or "all",
|
|
877
|
+
"deleted_count": deleted_count,
|
|
878
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
879
|
+
"audit_logged": True,
|
|
880
|
+
},
|
|
881
|
+
request,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# --- ENTERPRISE SECURITY & API KEY ENDPOINTS ---
|
|
886
|
+
|
|
887
|
+
@app.post("/api/auth/keys/create", tags=["Enterprise Security"])
|
|
888
|
+
def create_api_key(
|
|
889
|
+
request: Request,
|
|
890
|
+
payload: CreateKeyRequest,
|
|
891
|
+
principal: APIKeyRecord = Depends(require_scope(AUTH_KEYS_CREATE_SCOPE)),
|
|
892
|
+
):
|
|
893
|
+
"""Generates a new cryptographic API Key with RBAC scopes.
|
|
894
|
+
|
|
895
|
+
P1.1/P1.2: key minting is authorization-aware. The authenticated principal may
|
|
896
|
+
only mint keys inside the workspaces they control, at a role that their own
|
|
897
|
+
role permits, and MUST NOT be allowed to elevate to platform-wide admin. The
|
|
898
|
+
'admin' scope is never auto-promoted: requested role/scopes are authoritative
|
|
899
|
+
and validated before minting.
|
|
900
|
+
"""
|
|
901
|
+
# Resolve the caller's effective workspace binding from their key.
|
|
902
|
+
caller_tenant = principal.tenant_id or "default"
|
|
903
|
+
caller_org = principal.organization_id or "org_default"
|
|
904
|
+
|
|
905
|
+
# Target workspace that the new key is bound to. Defaults to the caller's own
|
|
906
|
+
# binding so a caller cannot mint a key for an org/workspace they do not own.
|
|
907
|
+
target_org = (payload.organization_id or caller_org).strip() or caller_org
|
|
908
|
+
target_workspace = payload.tenant_id or caller_tenant
|
|
909
|
+
requested_role = payload.role or "developer"
|
|
910
|
+
|
|
911
|
+
allowed, reason = can_create_api_key(
|
|
912
|
+
principal,
|
|
913
|
+
target_org=target_org,
|
|
914
|
+
target_workspace=target_workspace,
|
|
915
|
+
requested_role=requested_role,
|
|
916
|
+
# F-01: the requested scopes MUST reach the delegation check, otherwise
|
|
917
|
+
# a workspace admin can mint scopes=["admin"]/"security:audit" that the
|
|
918
|
+
# guard was written to block.
|
|
919
|
+
requested_scopes=payload.scopes,
|
|
920
|
+
)
|
|
921
|
+
if not allowed:
|
|
922
|
+
raise HTTPException(status_code=403, detail=reason)
|
|
923
|
+
|
|
924
|
+
# Defense-in-depth: still re-validate the org/project/tenant bounding via the
|
|
925
|
+
# generic hierarchy check (system principals bypass it).
|
|
926
|
+
ok, reason = security_manager.check_hierarchy(
|
|
927
|
+
principal,
|
|
928
|
+
requested_org=target_org,
|
|
929
|
+
requested_project=payload.project_id,
|
|
930
|
+
requested_tenant=target_workspace,
|
|
931
|
+
)
|
|
932
|
+
if not ok:
|
|
933
|
+
raise HTTPException(status_code=403, detail=reason)
|
|
934
|
+
|
|
935
|
+
token, record = security_manager.generate_api_key(
|
|
936
|
+
name=payload.name,
|
|
937
|
+
organization_id=target_org,
|
|
938
|
+
project_id=payload.project_id,
|
|
939
|
+
tenant_id=target_workspace,
|
|
940
|
+
scopes=payload.scopes,
|
|
941
|
+
role=requested_role,
|
|
942
|
+
ttl_days=payload.ttl_days,
|
|
943
|
+
owner_id=principal.key_id,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
return envelope(
|
|
947
|
+
{
|
|
948
|
+
"status": "CREATED",
|
|
949
|
+
"api_key": token,
|
|
950
|
+
"key_id": record.key_id,
|
|
951
|
+
"name": record.name,
|
|
952
|
+
"scopes": record.scopes,
|
|
953
|
+
"tenant_id": record.tenant_id,
|
|
954
|
+
"expires_at": record.expires_at,
|
|
955
|
+
},
|
|
956
|
+
request,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
@app.get("/api/auth/keys", tags=["Enterprise Security"])
|
|
961
|
+
def list_api_keys(
|
|
962
|
+
request: Request,
|
|
963
|
+
principal: APIKeyRecord = Depends(require_scope("admin"))
|
|
964
|
+
):
|
|
965
|
+
"""Lists registered API keys without exposing secrets."""
|
|
966
|
+
scoped_org = (
|
|
967
|
+
principal.organization_id
|
|
968
|
+
if principal.organization_id not in ("org_root", "org_system")
|
|
969
|
+
else None
|
|
970
|
+
)
|
|
971
|
+
return envelope(
|
|
972
|
+
{"keys": security_manager.list_api_keys(organization_id=scoped_org)},
|
|
973
|
+
request,
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
@app.post("/api/auth/keys/revoke", tags=["Enterprise Security"])
|
|
978
|
+
def revoke_api_key(
|
|
979
|
+
request: Request,
|
|
980
|
+
payload: RevokeKeyRequest,
|
|
981
|
+
principal: APIKeyRecord = Depends(require_scope("admin"))
|
|
982
|
+
):
|
|
983
|
+
"""Revokes an API key immediately.
|
|
984
|
+
|
|
985
|
+
P1.1: an admin can only revoke keys it could have listed — i.e. keys inside
|
|
986
|
+
its own org boundary (system principals see and manage everything).
|
|
987
|
+
"""
|
|
988
|
+
scoped_org = (
|
|
989
|
+
principal.organization_id
|
|
990
|
+
if principal.organization_id not in ("org_root", "org_system")
|
|
991
|
+
else None
|
|
992
|
+
)
|
|
993
|
+
target = next(
|
|
994
|
+
(k for k in security_manager.list_api_keys(organization_id=scoped_org)
|
|
995
|
+
if k.get("key_id") == payload.key_id),
|
|
996
|
+
None,
|
|
997
|
+
)
|
|
998
|
+
if target is None:
|
|
999
|
+
# Never leak whether a key exists outside the caller's boundary.
|
|
1000
|
+
return envelope({"status": "NOT_FOUND", "key_id": payload.key_id}, request)
|
|
1001
|
+
ok, reason = security_manager.check_hierarchy(
|
|
1002
|
+
principal,
|
|
1003
|
+
requested_org=target.get("organization_id") or None,
|
|
1004
|
+
requested_project=target.get("project_id") or None,
|
|
1005
|
+
requested_tenant=target.get("tenant_id") or None,
|
|
1006
|
+
)
|
|
1007
|
+
if not ok:
|
|
1008
|
+
raise HTTPException(status_code=403, detail=reason)
|
|
1009
|
+
success = security_manager.revoke_api_key(payload.key_id)
|
|
1010
|
+
return envelope({"status": "REVOKED" if success else "NOT_FOUND", "key_id": payload.key_id}, request)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
@app.get("/api/auth/audit", tags=["Enterprise Security"])
|
|
1014
|
+
def get_audit_trail(
|
|
1015
|
+
request: Request,
|
|
1016
|
+
limit: int = 50,
|
|
1017
|
+
principal: APIKeyRecord = Depends(require_scope("admin"))
|
|
1018
|
+
):
|
|
1019
|
+
"""Retrieves authorization audit logs."""
|
|
1020
|
+
logs = security_manager.get_audit_logs(limit=min(limit, 200), tenant_id=principal.tenant_id if principal.tenant_id != "*" else None)
|
|
1021
|
+
return envelope({"audit_logs": logs}, request)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
@app.get("/api/auth/audit/export", tags=["Enterprise Security"])
|
|
1025
|
+
def export_audit_trail(
|
|
1026
|
+
request: Request,
|
|
1027
|
+
since: Optional[float] = None,
|
|
1028
|
+
limit: int = 5000,
|
|
1029
|
+
principal: APIKeyRecord = Depends(require_scope("admin"))
|
|
1030
|
+
):
|
|
1031
|
+
"""Compliance export of audit records (tenant-scoped, bounded, time-ordered).
|
|
1032
|
+
|
|
1033
|
+
Intended for archival into SIEM/object storage. Non-admins export only
|
|
1034
|
+
their own tenant; admins export everything via tenant wildcard.
|
|
1035
|
+
"""
|
|
1036
|
+
scoped_tenant = None if principal.tenant_id in ("*", "") else principal.tenant_id
|
|
1037
|
+
logs = security_manager.export_audit_logs(
|
|
1038
|
+
tenant_id=scoped_tenant,
|
|
1039
|
+
since=since,
|
|
1040
|
+
limit=min(limit, 20000),
|
|
1041
|
+
)
|
|
1042
|
+
return envelope({"audit_logs": logs, "exported": len(logs)}, request)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
@app.post("/api/auth/audit/prune", tags=["Enterprise Security"])
|
|
1046
|
+
def prune_audit_trail(
|
|
1047
|
+
request: Request,
|
|
1048
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
1049
|
+
principal: APIKeyRecord = Depends(require_scope("admin"))
|
|
1050
|
+
):
|
|
1051
|
+
"""Delete audit records older than the retention window (default 90 days).
|
|
1052
|
+
|
|
1053
|
+
Body: {"older_than_days": <float>}. Returns the deleted row count.
|
|
1054
|
+
"""
|
|
1055
|
+
days = 90.0
|
|
1056
|
+
if payload and payload.get("older_than_days") is not None:
|
|
1057
|
+
try:
|
|
1058
|
+
days = float(payload["older_than_days"])
|
|
1059
|
+
except (TypeError, ValueError):
|
|
1060
|
+
raise HTTPException(status_code=400, detail="older_than_days must be a number.")
|
|
1061
|
+
# P1.1: tenant-bound admins prune ONLY their own audit trail; only system
|
|
1062
|
+
# principals (env master / org_system) run a global retention pass.
|
|
1063
|
+
scoped_tenant = (
|
|
1064
|
+
None
|
|
1065
|
+
if security_manager.is_system_principal(principal) or principal.tenant_id in ("*", "")
|
|
1066
|
+
else principal.tenant_id
|
|
1067
|
+
)
|
|
1068
|
+
deleted = security_manager.prune_audit_logs(
|
|
1069
|
+
older_than_seconds=max(0.0, days) * 86400, tenant_id=scoped_tenant
|
|
1070
|
+
)
|
|
1071
|
+
security_manager.log_audit_action(
|
|
1072
|
+
actor_key_id=principal.key_id,
|
|
1073
|
+
tenant_id=principal.tenant_id,
|
|
1074
|
+
action="AUDIT_RETENTION_PRUNE",
|
|
1075
|
+
resource="auth_audit_logs",
|
|
1076
|
+
status="SUCCESS",
|
|
1077
|
+
metadata={"deleted_count": deleted, "older_than_days": days},
|
|
1078
|
+
)
|
|
1079
|
+
return envelope({"deleted": deleted, "older_than_days": days}, request)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
class RotateKeyRequest(BaseModel):
|
|
1083
|
+
key_id: str
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
@app.post("/api/auth/keys/rotate", tags=["Enterprise Security"])
|
|
1087
|
+
def rotate_api_key(
|
|
1088
|
+
request: Request,
|
|
1089
|
+
payload: RotateKeyRequest,
|
|
1090
|
+
principal: APIKeyRecord = Depends(require_scope("admin"))
|
|
1091
|
+
):
|
|
1092
|
+
"""Rotates an API key: revokes old, issues new with same metadata. Zero-downtime rotation.
|
|
1093
|
+
|
|
1094
|
+
P1.1: the target must live inside the caller's org boundary (system
|
|
1095
|
+
principals manage everything) and inherit only hierarchy-compatible
|
|
1096
|
+
metadata.
|
|
1097
|
+
"""
|
|
1098
|
+
scoped_org = (
|
|
1099
|
+
principal.organization_id
|
|
1100
|
+
if principal.organization_id not in ("org_root", "org_system")
|
|
1101
|
+
else None
|
|
1102
|
+
)
|
|
1103
|
+
keys = security_manager.list_api_keys(organization_id=scoped_org)
|
|
1104
|
+
old = next((k for k in keys if k.get("key_id") == payload.key_id), None)
|
|
1105
|
+
if not old:
|
|
1106
|
+
# 404 both for missing keys and keys outside the caller's boundary.
|
|
1107
|
+
raise HTTPException(status_code=404, detail="Key not found.")
|
|
1108
|
+
ok, reason = security_manager.check_hierarchy(
|
|
1109
|
+
principal,
|
|
1110
|
+
requested_org=old.get("organization_id") or None,
|
|
1111
|
+
requested_project=old.get("project_id") or None,
|
|
1112
|
+
requested_tenant=old.get("tenant_id") or None,
|
|
1113
|
+
)
|
|
1114
|
+
if not ok:
|
|
1115
|
+
raise HTTPException(status_code=403, detail=reason)
|
|
1116
|
+
token, record = security_manager.generate_api_key(
|
|
1117
|
+
name=old.get("name", "rotated"),
|
|
1118
|
+
organization_id=old.get("organization_id", principal.organization_id),
|
|
1119
|
+
project_id=old.get("project_id", "proj_default"),
|
|
1120
|
+
tenant_id=old.get("tenant_id", principal.tenant_id),
|
|
1121
|
+
scopes=old.get("scopes", ["memories:read", "memories:write"]),
|
|
1122
|
+
role=old.get("role", "developer"),
|
|
1123
|
+
ttl_days=365,
|
|
1124
|
+
)
|
|
1125
|
+
security_manager.revoke_api_key(payload.key_id)
|
|
1126
|
+
return envelope(
|
|
1127
|
+
{
|
|
1128
|
+
"status": "ROTATED",
|
|
1129
|
+
"old_key_id": payload.key_id,
|
|
1130
|
+
"api_key": token,
|
|
1131
|
+
"key_id": record.key_id,
|
|
1132
|
+
"expires_at": record.expires_at,
|
|
1133
|
+
},
|
|
1134
|
+
request,
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
class BulkIngestItem(BaseModel):
|
|
1139
|
+
content: str = Field(..., min_length=1, max_length=32000)
|
|
1140
|
+
category: str = Field("interaction", min_length=1, max_length=64, pattern=r"^[a-z0-9_\-]+$")
|
|
1141
|
+
confidence: float = Field(0.95, ge=0.0, le=1.0)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
class BulkIngestRequest(BaseModel):
|
|
1145
|
+
items: List[BulkIngestItem] = Field(..., min_length=1, max_length=100)
|
|
1146
|
+
tenant_id: Optional[str] = Field(None, alias="tenantId", min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_\-\.]+$")
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
@app.post("/api/memories/bulk", tags=["Ingestion"])
|
|
1150
|
+
def bulk_ingest(
|
|
1151
|
+
request: Request,
|
|
1152
|
+
payload: BulkIngestRequest,
|
|
1153
|
+
principal: APIKeyRecord = Depends(require_scope("memories:write"))
|
|
1154
|
+
):
|
|
1155
|
+
"""Bulk ingest up to 100 memories in one call (team backfills)."""
|
|
1156
|
+
total_chars = sum(len(i.content) for i in payload.items)
|
|
1157
|
+
if total_chars > 500_000:
|
|
1158
|
+
raise HTTPException(status_code=413, detail="Bulk payload too large (500KB text limit).")
|
|
1159
|
+
effective_tenant = resolve_tenant(principal, payload.tenant_id)
|
|
1160
|
+
results = []
|
|
1161
|
+
for item in payload.items:
|
|
1162
|
+
if not item.content.strip():
|
|
1163
|
+
continue
|
|
1164
|
+
mid, action, cost = manager.ingest_with_deduplication(
|
|
1165
|
+
content=item.content,
|
|
1166
|
+
category=item.category,
|
|
1167
|
+
confidence=max(0.0, min(1.0, item.confidence)),
|
|
1168
|
+
tenant_id=effective_tenant,
|
|
1169
|
+
)
|
|
1170
|
+
results.append({"id": mid, "status": action, "token_cost": cost})
|
|
1171
|
+
return envelope({"tenant_id": effective_tenant, "ingested": len(results), "results": results}, request)
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
@app.get("/metrics", tags=["Telemetry"])
|
|
1175
|
+
def prometheus_metrics():
|
|
1176
|
+
"""Prometheus-compatible metrics (no extra deps). Scrape with Prometheus/Grafana.
|
|
1177
|
+
|
|
1178
|
+
All values are live: request counters + latency samples by endpoint,
|
|
1179
|
+
auth failures, rate-limit rejections, ingestion/retrieval volumes and
|
|
1180
|
+
errors, pool utilization, token-budget usage. No hardcoded figures.
|
|
1181
|
+
"""
|
|
1182
|
+
total = 0
|
|
1183
|
+
try:
|
|
1184
|
+
total = store.count_memories()
|
|
1185
|
+
except Exception:
|
|
1186
|
+
pass
|
|
1187
|
+
pool_stats: Dict[str, Any] = {}
|
|
1188
|
+
for candidate in (store, security_manager):
|
|
1189
|
+
stats_fn = getattr(candidate, "pool_stats", None)
|
|
1190
|
+
if not callable(stats_fn):
|
|
1191
|
+
continue
|
|
1192
|
+
try:
|
|
1193
|
+
candidate_stats = stats_fn() or {}
|
|
1194
|
+
except Exception:
|
|
1195
|
+
logger.debug("pool_stats() failed for %r", candidate)
|
|
1196
|
+
candidate_stats = {}
|
|
1197
|
+
if candidate_stats:
|
|
1198
|
+
pool_stats = candidate_stats
|
|
1199
|
+
break
|
|
1200
|
+
lines = [
|
|
1201
|
+
"# HELP synapse_memories_total Total indexed memories",
|
|
1202
|
+
"# TYPE synapse_memories_total gauge",
|
|
1203
|
+
f"synapse_memories_total{{backend=\"{ACTIVE_BACKEND}\"}} {total}",
|
|
1204
|
+
"# HELP synapse_up Service liveness",
|
|
1205
|
+
"# TYPE synapse_up gauge",
|
|
1206
|
+
"synapse_up 1",
|
|
1207
|
+
"# HELP synapse_embedding_dimension Active embedding dimension",
|
|
1208
|
+
"# TYPE synapse_embedding_dimension gauge",
|
|
1209
|
+
f"synapse_embedding_dimension {embedding_dimension}",
|
|
1210
|
+
"# HELP synapse_requests_total Requests by endpoint and status",
|
|
1211
|
+
"# TYPE synapse_requests_total counter",
|
|
1212
|
+
]
|
|
1213
|
+
with _metrics_lock:
|
|
1214
|
+
for (route_template, status_code), count in sorted(_REQUEST_COUNTS.items()):
|
|
1215
|
+
lines.append(f"synapse_requests_total{{path=\"{route_template}\",status=\"{status_code}\"}} {count}")
|
|
1216
|
+
lines += [
|
|
1217
|
+
"# HELP synapse_auth_failures_total Authentication/authorization failures",
|
|
1218
|
+
"# TYPE synapse_auth_failures_total counter",
|
|
1219
|
+
f"synapse_auth_failures_total {_auth_failures}",
|
|
1220
|
+
"# HELP synapse_rate_limit_rejections_total Rate-limit 429 rejections",
|
|
1221
|
+
"# TYPE synapse_rate_limit_rejections_total counter",
|
|
1222
|
+
f"synapse_rate_limit_rejections_total {_rate_limit_rejections}",
|
|
1223
|
+
"# HELP synapse_ingest_total Ingested memories and failures",
|
|
1224
|
+
"# TYPE synapse_ingest_total counter",
|
|
1225
|
+
f"synapse_ingest_total{{outcome=\"success\"}} {_ingest_success}",
|
|
1226
|
+
f"synapse_ingest_total{{outcome=\"error\"}} {_ingest_errors}",
|
|
1227
|
+
"# HELP synapse_retrieval_total Retrieval requests and errors",
|
|
1228
|
+
"# TYPE synapse_retrieval_total counter",
|
|
1229
|
+
f"synapse_retrieval_total{{outcome=\"success\"}} {_retrieval_success}",
|
|
1230
|
+
f"synapse_retrieval_total{{outcome=\"error\"}} {_retrieval_errors}",
|
|
1231
|
+
"# HELP synapse_retrieval_candidates Candidate count of last retrieval",
|
|
1232
|
+
"# TYPE synapse_retrieval_candidates gauge",
|
|
1233
|
+
f"synapse_retrieval_candidates {_last_candidate_count}",
|
|
1234
|
+
"# HELP synapse_context_tokens_packed Tokens packed into last context",
|
|
1235
|
+
"# TYPE synapse_context_tokens_packed gauge",
|
|
1236
|
+
f"synapse_context_tokens_packed {_last_packed_tokens}",
|
|
1237
|
+
]
|
|
1238
|
+
lines += [
|
|
1239
|
+
"# HELP synapse_db_pool_in_use Pooled DB connections currently checked out",
|
|
1240
|
+
"# TYPE synapse_db_pool_in_use gauge",
|
|
1241
|
+
f"synapse_db_pool_in_use {pool_stats.get('in_use', 0)}",
|
|
1242
|
+
"# HELP synapse_db_pool_size Configured DB pool size",
|
|
1243
|
+
"# TYPE synapse_db_pool_size gauge",
|
|
1244
|
+
f"synapse_db_pool_size {pool_stats.get('pool_size', 0)}",
|
|
1245
|
+
"# HELP synapse_rate_limit_backend Active rate-limiter backend (constant 1)",
|
|
1246
|
+
"# TYPE synapse_rate_limit_backend gauge",
|
|
1247
|
+
f'synapse_rate_limit_backend{{backend="{rate_limiter.backend}"}} 1',
|
|
1248
|
+
]
|
|
1249
|
+
lat_lines = ["# HELP synapse_request_latency_seconds Request latency by endpoint", "# TYPE synapse_request_latency_seconds summary"]
|
|
1250
|
+
with _metrics_lock:
|
|
1251
|
+
for path, samples in sorted(_LATENCY_SAMPLES.items()):
|
|
1252
|
+
if not samples:
|
|
1253
|
+
continue
|
|
1254
|
+
ordered = sorted(samples)
|
|
1255
|
+
n = len(ordered)
|
|
1256
|
+
avg = sum(ordered) / n
|
|
1257
|
+
p50 = ordered[min(n - 1, n // 2)]
|
|
1258
|
+
p95 = ordered[min(n - 1, int(n * 0.95))]
|
|
1259
|
+
lat_lines.append(f"synapse_request_latency_seconds{{path=\"{path}\",quantile=\"0.5\"}} {p50:.6f}")
|
|
1260
|
+
lat_lines.append(f"synapse_request_latency_seconds{{path=\"{path}\",quantile=\"0.95\"}} {p95:.6f}")
|
|
1261
|
+
lat_lines.append(f"synapse_request_latency_seconds_sum{{path=\"{path}\"}} {sum(ordered):.6f}")
|
|
1262
|
+
lat_lines.append(f"synapse_request_latency_seconds_count{{path=\"{path}\"}} {n}")
|
|
1263
|
+
lat_lines.append(f"# avg {avg:.6f}s over {n} samples")
|
|
1264
|
+
from fastapi.responses import PlainTextResponse
|
|
1265
|
+
|
|
1266
|
+
return PlainTextResponse("\n".join(lines + lat_lines) + "\n", media_type="text/plain")
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
@app.post("/api/security/scrub-pii", tags=["Security"])
|
|
1270
|
+
@app.post("/api/security/scrub", tags=["Security"])
|
|
1271
|
+
def scrub_pii(
|
|
1272
|
+
request: Request,
|
|
1273
|
+
payload: Dict[str, Any],
|
|
1274
|
+
principal: APIKeyRecord = Depends(require_scope("memories:read"))
|
|
1275
|
+
):
|
|
1276
|
+
"""
|
|
1277
|
+
Authenticated PII scrubbing endpoint — the single implementation.
|
|
1278
|
+
|
|
1279
|
+
The Node gateway proxies both of its aliases here instead of running its own
|
|
1280
|
+
weaker regex set (F-11). Ordering matters: high-entropy identifiers and
|
|
1281
|
+
structured digit runs (card / Aadhaar / PAN / SSN) are redacted BEFORE the
|
|
1282
|
+
generic phone matcher, which would otherwise swallow them and leave them
|
|
1283
|
+
only *counted* as detected rather than actually removed.
|
|
1284
|
+
|
|
1285
|
+
Nothing cryptographic happens in this handler, so the response carries no
|
|
1286
|
+
encryption/CMEK claim.
|
|
1287
|
+
"""
|
|
1288
|
+
import re
|
|
1289
|
+
|
|
1290
|
+
text = payload.get("text", "")
|
|
1291
|
+
if not isinstance(text, str) or not text.strip():
|
|
1292
|
+
raise HTTPException(status_code=400, detail="Text field required.")
|
|
1293
|
+
if len(text) > 20000:
|
|
1294
|
+
raise HTTPException(status_code=413, detail="Text exceeds the 20000 character limit.")
|
|
1295
|
+
|
|
1296
|
+
redactions = [
|
|
1297
|
+
(
|
|
1298
|
+
r"(sk-proj-[A-Za-z0-9]{20,}|sk-ant-[A-Za-z0-9\-_]{20,}"
|
|
1299
|
+
r"|xox[bpas]-[A-Za-z0-9\-_]{10,}|gh[pousr]_[A-Za-z0-9]{20,}"
|
|
1300
|
+
r"|AKIA[0-9A-Z]{16})",
|
|
1301
|
+
"[REDACTED_API_KEY]",
|
|
1302
|
+
),
|
|
1303
|
+
(
|
|
1304
|
+
r"\b(?:4[0-9]{3}|5[1-5][0-9]{2}|3[47][0-9]{2}|6(?:011|5[0-9]{2}))"
|
|
1305
|
+
r"[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{1,4}\b",
|
|
1306
|
+
"[REDACTED_CARD]",
|
|
1307
|
+
),
|
|
1308
|
+
(r"\b[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}\b", "[REDACTED_AADHAAR]"),
|
|
1309
|
+
(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b", "[REDACTED_PAN]"),
|
|
1310
|
+
(r"\b[0-9]{3}-[0-9]{2}-[0-9]{4}\b", "[REDACTED_SSN]"),
|
|
1311
|
+
(r"[\w\.-]+@[\w\.-]+\.\w+", "[REDACTED_EMAIL]"),
|
|
1312
|
+
(r"password\s*[:=]\s*\S+", "password=[REDACTED]"),
|
|
1313
|
+
(r"\+?[0-9][0-9\-.\s()]{7,}[0-9]", "[REDACTED_PHONE]"),
|
|
1314
|
+
]
|
|
1315
|
+
|
|
1316
|
+
scrubbed = text
|
|
1317
|
+
redaction_count = 0
|
|
1318
|
+
for pattern, replacement in redactions:
|
|
1319
|
+
scrubbed, count = re.subn(pattern, replacement, scrubbed)
|
|
1320
|
+
redaction_count += count
|
|
1321
|
+
|
|
1322
|
+
return envelope(
|
|
1323
|
+
{
|
|
1324
|
+
"detectedPII": redaction_count > 0,
|
|
1325
|
+
"scrubbedText": scrubbed,
|
|
1326
|
+
"redactionCount": redaction_count,
|
|
1327
|
+
"scrubber": "synapse-engine/regex-v2",
|
|
1328
|
+
},
|
|
1329
|
+
request,
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
if __name__ == "__main__":
|
|
1334
|
+
uvicorn.run("fastapi_server:app", host="127.0.0.1", port=int(os.environ.get("PORT", 8008)), reload=True) # nosec B104
|