severian 0.3.13__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.
- severian/__init__.py +0 -0
- severian/api/__init__.py +12 -0
- severian/api/app.py +419 -0
- severian/api/schemas.py +124 -0
- severian/application/__init__.py +0 -0
- severian/application/anticipation.py +397 -0
- severian/application/compactcites.py +42 -0
- severian/application/conflicts.py +204 -0
- severian/application/context_v2.py +145 -0
- severian/application/datestamps.py +96 -0
- severian/application/derivation.py +326 -0
- severian/application/dreaming.py +760 -0
- severian/application/emptyitems.py +72 -0
- severian/application/episodes.py +155 -0
- severian/application/eventtime.py +333 -0
- severian/application/extraction.py +253 -0
- severian/application/factanchors.py +78 -0
- severian/application/factslots.py +356 -0
- severian/application/fillbudget.py +192 -0
- severian/application/graph.py +218 -0
- severian/application/ingestion.py +114 -0
- severian/application/ports.py +430 -0
- severian/application/projections.py +211 -0
- severian/application/replytrim.py +82 -0
- severian/application/rerank.py +116 -0
- severian/application/retrieval.py +604 -0
- severian/application/service.py +905 -0
- severian/application/statements.py +167 -0
- severian/application/sufficiency.py +171 -0
- severian/application/truthresolution.py +505 -0
- severian/application/userfacts.py +409 -0
- severian/application/worker.py +465 -0
- severian/auth/__init__.py +24 -0
- severian/auth/principal_resolver.py +114 -0
- severian/auth/store.py +226 -0
- severian/backends.py +104 -0
- severian/benchmarks/__init__.py +48 -0
- severian/benchmarks/adapters/__init__.py +24 -0
- severian/benchmarks/adapters/baseline.py +85 -0
- severian/benchmarks/adapters/chroma.py +271 -0
- severian/benchmarks/adapters/scope.py +18 -0
- severian/benchmarks/adapters/severian.py +434 -0
- severian/benchmarks/checkpoint_merge.py +151 -0
- severian/benchmarks/cli.py +45 -0
- severian/benchmarks/embedding_runner.py +680 -0
- severian/benchmarks/manifest.py +292 -0
- severian/benchmarks/quantiles.py +57 -0
- severian/benchmarks/runner.py +398 -0
- severian/cli/__init__.py +13 -0
- severian/cli/__main__.py +5 -0
- severian/cli/app.py +774 -0
- severian/cli/path_policy.py +103 -0
- severian/composition.py +204 -0
- severian/domain/__init__.py +0 -0
- severian/domain/errors.py +42 -0
- severian/domain/hits.py +25 -0
- severian/domain/models.py +362 -0
- severian/domain/text.py +38 -0
- severian/infrastructure/__init__.py +0 -0
- severian/infrastructure/audit.py +411 -0
- severian/infrastructure/embedding_daemon.py +305 -0
- severian/infrastructure/embedding_metadata.py +137 -0
- severian/infrastructure/embedding_registry.py +177 -0
- severian/infrastructure/embedding_resolver.py +170 -0
- severian/infrastructure/fts.py +289 -0
- severian/infrastructure/granite_embedding.py +100 -0
- severian/infrastructure/native_accel.py +148 -0
- severian/infrastructure/pgvector_index.py +343 -0
- severian/infrastructure/postgresql.py +2176 -0
- severian/infrastructure/qwen_embedding.py +147 -0
- severian/infrastructure/rebuild.py +469 -0
- severian/infrastructure/remote_qwen_embedding.py +254 -0
- severian/infrastructure/snapshot.py +237 -0
- severian/infrastructure/sqlite.py +2036 -0
- severian/infrastructure/stemming.py +207 -0
- severian/infrastructure/task_notes.py +107 -0
- severian/infrastructure/vector.py +520 -0
- severian/integrations/__init__.py +9 -0
- severian/integrations/hermes.py +1115 -0
- severian/mcp/__init__.py +18 -0
- severian/mcp/__main__.py +73 -0
- severian/mcp/server.py +931 -0
- severian/py.typed +0 -0
- severian/sdk/__init__.py +17 -0
- severian/sdk/client.py +250 -0
- severian-0.3.13.dist-info/METADATA +278 -0
- severian-0.3.13.dist-info/RECORD +90 -0
- severian-0.3.13.dist-info/WHEEL +4 -0
- severian-0.3.13.dist-info/entry_points.txt +3 -0
- severian-0.3.13.dist-info/licenses/LICENSE +21 -0
severian/__init__.py
ADDED
|
File without changes
|
severian/api/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Public API package for Severian.
|
|
2
|
+
|
|
3
|
+
The application is constructed via :func:`severian.api.app.create_app`
|
|
4
|
+
so tests and the runtime uvicorn entrypoint share the same composition
|
|
5
|
+
root. Adapters (CLI/SDK) MUST NOT bypass the ``MemoryService`` wired
|
|
6
|
+
here.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from severian.api.app import create_app
|
|
11
|
+
|
|
12
|
+
__all__ = ["create_app"]
|
severian/api/app.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""FastAPI application factory.
|
|
2
|
+
|
|
3
|
+
The API is intentionally thin: it validates request shapes, maps the
|
|
4
|
+
validated payload to the canonical application service, and returns
|
|
5
|
+
Pydantic response models. There is no direct repository, projection
|
|
6
|
+
or index access in this layer.
|
|
7
|
+
|
|
8
|
+
``create_app`` is the single composition root used by both production
|
|
9
|
+
(``uvicorn severian.api.app:app``) and tests (``TestClient(create_app(...))``).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastapi import Depends, FastAPI, HTTPException, Path, Request, status
|
|
17
|
+
from fastapi.responses import JSONResponse
|
|
18
|
+
|
|
19
|
+
from severian.api.schemas import (
|
|
20
|
+
HealthResponse,
|
|
21
|
+
IngestRequest,
|
|
22
|
+
IngestResponse,
|
|
23
|
+
MemoryResponse,
|
|
24
|
+
ObservationResponse,
|
|
25
|
+
ScopeModel,
|
|
26
|
+
SearchHit,
|
|
27
|
+
SearchRequest,
|
|
28
|
+
SearchResponse,
|
|
29
|
+
)
|
|
30
|
+
from severian.application.derivation import DerivationGateway
|
|
31
|
+
from severian.application.service import MemoryService
|
|
32
|
+
from severian.auth.principal_resolver import PrincipalResolver
|
|
33
|
+
from severian.domain.errors import ForbiddenError, NotFoundError
|
|
34
|
+
from severian.domain.models import Scope
|
|
35
|
+
|
|
36
|
+
# Constants kept here so URL paths and tests share the same source of
|
|
37
|
+
# truth. Versioned under ``/api/v1`` per Phase 3 plan.
|
|
38
|
+
API_PREFIX = "/api/v1"
|
|
39
|
+
OBSERVATIONS_PATH = f"{API_PREFIX}/observations"
|
|
40
|
+
MEMORIES_PATH = f"{API_PREFIX}/memories"
|
|
41
|
+
SEARCH_PATH = f"{API_PREFIX}/search"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
GatewayFactory = Callable[[], DerivationGateway]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scope_from_model(model: ScopeModel) -> Scope:
|
|
48
|
+
return Scope(
|
|
49
|
+
tenant_id=model.tenant_id,
|
|
50
|
+
profile_id=model.profile_id,
|
|
51
|
+
collection_id=model.collection_id,
|
|
52
|
+
agent_id=model.agent_id,
|
|
53
|
+
session_id=model.session_id,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _bind_principal(requested: ScopeModel, principal: Scope) -> Scope:
|
|
58
|
+
"""Derive the effective scope from the authenticated principal.
|
|
59
|
+
|
|
60
|
+
P0-6: the security-sensitive dimensions (tenant/profile) are NOT
|
|
61
|
+
client-selectable when a principal is bound. A request that claims
|
|
62
|
+
a different tenant or profile than the principal is an
|
|
63
|
+
authorization violation (403) — it is NOT a validation error, so
|
|
64
|
+
callers cannot probe for cross-tenant existence via 422 vs 403.
|
|
65
|
+
"""
|
|
66
|
+
if requested.tenant_id != principal.tenant_id or requested.profile_id != principal.profile_id:
|
|
67
|
+
raise ForbiddenError("requested scope is outside the authenticated principal")
|
|
68
|
+
return Scope(
|
|
69
|
+
tenant_id=principal.tenant_id,
|
|
70
|
+
profile_id=principal.profile_id,
|
|
71
|
+
collection_id=requested.collection_id,
|
|
72
|
+
agent_id=requested.agent_id,
|
|
73
|
+
session_id=requested.session_id,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _view_to_observation_response(observation: Any) -> ObservationResponse:
|
|
78
|
+
return ObservationResponse(
|
|
79
|
+
observation_id=observation.id,
|
|
80
|
+
content=observation.content,
|
|
81
|
+
content_hash=observation.content_hash,
|
|
82
|
+
scope=ScopeModel(
|
|
83
|
+
tenant_id=observation.scope.tenant_id,
|
|
84
|
+
profile_id=observation.scope.profile_id,
|
|
85
|
+
collection_id=observation.scope.collection_id,
|
|
86
|
+
agent_id=observation.scope.agent_id,
|
|
87
|
+
session_id=observation.scope.session_id,
|
|
88
|
+
),
|
|
89
|
+
source=dict(observation.source),
|
|
90
|
+
created_at=observation.created_at.isoformat(),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _hit_to_response(hit: Any) -> SearchHit:
|
|
95
|
+
return SearchHit(
|
|
96
|
+
memory_id=hit.memory_id,
|
|
97
|
+
content=hit.content,
|
|
98
|
+
source_observation_ids=list(hit.source_observation_ids),
|
|
99
|
+
lexical_score=hit.lexical_score,
|
|
100
|
+
vector_score=hit.vector_score,
|
|
101
|
+
fused_score=hit.fused_score,
|
|
102
|
+
scope=ScopeModel(
|
|
103
|
+
tenant_id=hit.scope.tenant_id,
|
|
104
|
+
profile_id=hit.scope.profile_id,
|
|
105
|
+
collection_id=hit.scope.collection_id,
|
|
106
|
+
agent_id=hit.scope.agent_id,
|
|
107
|
+
session_id=hit.scope.session_id,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_app(
|
|
113
|
+
*,
|
|
114
|
+
service: MemoryService,
|
|
115
|
+
gateway: GatewayFactory | DerivationGateway,
|
|
116
|
+
principal: Scope | None = None,
|
|
117
|
+
principal_resolver: PrincipalResolver | None = None,
|
|
118
|
+
) -> FastAPI:
|
|
119
|
+
"""Build a FastAPI app bound to a ``MemoryService`` instance.
|
|
120
|
+
|
|
121
|
+
``gateway`` may be either a callable returning a fresh gateway per
|
|
122
|
+
request (preferred — derivation may hold per-request state) or a
|
|
123
|
+
fixed gateway instance used for every request (suitable for
|
|
124
|
+
deterministic local fixtures).
|
|
125
|
+
|
|
126
|
+
``principal`` optionally binds a fixed authenticated scope. When
|
|
127
|
+
set, the security-sensitive dimensions (``tenant_id`` and
|
|
128
|
+
``profile_id``) are derived server-side from the principal and any
|
|
129
|
+
request body/token that disagrees with them is rejected with 403 —
|
|
130
|
+
scope becomes authorization, not a client-supplied selector. When
|
|
131
|
+
unset (local/dev mode), the client must supply a fully-populated
|
|
132
|
+
scope as before.
|
|
133
|
+
|
|
134
|
+
``principal_resolver`` optionally enables per-request API-key
|
|
135
|
+
authentication (SEC-01). When set, each request must carry a
|
|
136
|
+
valid ``Authorization: Bearer <key>`` header; the resolved
|
|
137
|
+
principal overrides the static ``principal`` (if any). Fail-closed:
|
|
138
|
+
unauthenticated requests are rejected with 401. Health endpoints
|
|
139
|
+
(``/healthz``, ``/readyz``) are exempt.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def _get_gateway() -> DerivationGateway:
|
|
143
|
+
if callable(gateway) and not hasattr(gateway, "derive"):
|
|
144
|
+
return gateway()
|
|
145
|
+
return gateway # type: ignore[return-value]
|
|
146
|
+
|
|
147
|
+
app = FastAPI(
|
|
148
|
+
title="Severian Memory API",
|
|
149
|
+
version="0.1.0",
|
|
150
|
+
description="Versioned HTTP API for the canonical memory platform.",
|
|
151
|
+
)
|
|
152
|
+
app.state.service = service
|
|
153
|
+
|
|
154
|
+
def get_service() -> MemoryService:
|
|
155
|
+
return service
|
|
156
|
+
|
|
157
|
+
def get_gateway() -> DerivationGateway:
|
|
158
|
+
return _get_gateway()
|
|
159
|
+
|
|
160
|
+
# SEC-01: when no principal_resolver is configured (dev/loopback mode),
|
|
161
|
+
# use a no-op dependency that returns None so FastAPI's parameter
|
|
162
|
+
# analysis treats ``payload`` as the body. When a resolver is
|
|
163
|
+
# configured, it is a callable that returns the authenticated Scope
|
|
164
|
+
# (or raises 401).
|
|
165
|
+
def _no_auth() -> Scope | None:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
auth_dependency = Depends(principal_resolver) if principal_resolver is not None else Depends(_no_auth)
|
|
169
|
+
|
|
170
|
+
def resolve_scope(scope_model: ScopeModel, request_principal: Scope | None = None) -> Scope:
|
|
171
|
+
"""Resolve the effective scope for a request.
|
|
172
|
+
|
|
173
|
+
With a bound principal, tenant/profile are derived server-side
|
|
174
|
+
and any disagreement is a 403. Without one, the client-supplied
|
|
175
|
+
scope is used as-is (local/dev mode).
|
|
176
|
+
|
|
177
|
+
``request_principal`` (from the auth resolver) takes precedence
|
|
178
|
+
over the static ``principal`` — it is the per-request authenticated
|
|
179
|
+
identity.
|
|
180
|
+
"""
|
|
181
|
+
effective = request_principal if request_principal is not None else principal
|
|
182
|
+
if effective is not None:
|
|
183
|
+
return _bind_principal(scope_model, effective)
|
|
184
|
+
return _scope_from_model(scope_model)
|
|
185
|
+
|
|
186
|
+
@app.get("/healthz", response_model=HealthResponse)
|
|
187
|
+
def healthz() -> HealthResponse:
|
|
188
|
+
# Liveness: always 200 while the process serves requests.
|
|
189
|
+
return HealthResponse(ready=True, components={})
|
|
190
|
+
|
|
191
|
+
@app.get("/readyz", response_model=HealthResponse)
|
|
192
|
+
def readyz(svc: MemoryService = Depends(get_service)) -> HealthResponse:
|
|
193
|
+
# Readiness: dependency gate. P1-2: a service whose
|
|
194
|
+
# dependencies are down must NOT report ready — callers (load
|
|
195
|
+
# balancers, orchestrators) get 503 so traffic is routed away.
|
|
196
|
+
report = svc.health()
|
|
197
|
+
response = HealthResponse(ready=report.ready, components=report.components)
|
|
198
|
+
if not report.ready:
|
|
199
|
+
raise HTTPException(
|
|
200
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
201
|
+
detail={
|
|
202
|
+
"ready": False,
|
|
203
|
+
"components": report.components,
|
|
204
|
+
"type": "not_ready",
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
return response
|
|
208
|
+
|
|
209
|
+
@app.post(
|
|
210
|
+
OBSERVATIONS_PATH,
|
|
211
|
+
response_model=IngestResponse,
|
|
212
|
+
status_code=status.HTTP_201_CREATED,
|
|
213
|
+
)
|
|
214
|
+
def ingest(
|
|
215
|
+
payload: IngestRequest,
|
|
216
|
+
svc: MemoryService = Depends(get_service),
|
|
217
|
+
request_principal: Scope | None = auth_dependency,
|
|
218
|
+
) -> IngestResponse:
|
|
219
|
+
try:
|
|
220
|
+
view = svc.ingest_observation(
|
|
221
|
+
resolve_scope(payload.scope, request_principal),
|
|
222
|
+
content=payload.content,
|
|
223
|
+
source=payload.source,
|
|
224
|
+
)
|
|
225
|
+
except ForbiddenError as exc:
|
|
226
|
+
raise HTTPException(
|
|
227
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
228
|
+
detail=str(exc),
|
|
229
|
+
) from exc
|
|
230
|
+
except ValueError as exc:
|
|
231
|
+
# Pydantic surface for client errors — never leak internals.
|
|
232
|
+
raise HTTPException(
|
|
233
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
234
|
+
detail=str(exc),
|
|
235
|
+
) from exc
|
|
236
|
+
return IngestResponse(
|
|
237
|
+
observation_id=view.observation_id,
|
|
238
|
+
content=view.content,
|
|
239
|
+
content_hash=view.content_hash,
|
|
240
|
+
scope=ScopeModel(
|
|
241
|
+
tenant_id=view.scope.tenant_id,
|
|
242
|
+
profile_id=view.scope.profile_id,
|
|
243
|
+
collection_id=view.scope.collection_id,
|
|
244
|
+
agent_id=view.scope.agent_id,
|
|
245
|
+
session_id=view.scope.session_id,
|
|
246
|
+
),
|
|
247
|
+
source=dict(view.source),
|
|
248
|
+
job_id=view.job_id,
|
|
249
|
+
job_kind=view.job_kind,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@app.get(
|
|
253
|
+
f"{OBSERVATIONS_PATH}/{{observation_id}}",
|
|
254
|
+
response_model=ObservationResponse,
|
|
255
|
+
)
|
|
256
|
+
def get_observation(
|
|
257
|
+
observation_id: str = Path(..., min_length=1),
|
|
258
|
+
tenant_id: str = "",
|
|
259
|
+
profile_id: str = "",
|
|
260
|
+
collection_id: str = "",
|
|
261
|
+
agent_id: str = "",
|
|
262
|
+
session_id: str = "",
|
|
263
|
+
svc: MemoryService = Depends(get_service),
|
|
264
|
+
request_principal: Scope | None = auth_dependency,
|
|
265
|
+
) -> ObservationResponse:
|
|
266
|
+
# P1-4: ScopeModel construction lives INSIDE the try so pydantic
|
|
267
|
+
# validation failures surface as 422, not 500. Missing/invalid
|
|
268
|
+
# scope dimensions are client errors, never server errors.
|
|
269
|
+
try:
|
|
270
|
+
scope = ScopeModel(
|
|
271
|
+
tenant_id=tenant_id,
|
|
272
|
+
profile_id=profile_id,
|
|
273
|
+
collection_id=collection_id,
|
|
274
|
+
agent_id=agent_id,
|
|
275
|
+
session_id=session_id,
|
|
276
|
+
)
|
|
277
|
+
scope_obj = resolve_scope(scope, request_principal)
|
|
278
|
+
except ForbiddenError as exc:
|
|
279
|
+
raise HTTPException(
|
|
280
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
281
|
+
detail=str(exc),
|
|
282
|
+
) from exc
|
|
283
|
+
except ValueError as exc:
|
|
284
|
+
raise HTTPException(
|
|
285
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
286
|
+
detail=str(exc),
|
|
287
|
+
) from exc
|
|
288
|
+
try:
|
|
289
|
+
observation = svc.get_observation(scope_obj, observation_id)
|
|
290
|
+
except NotFoundError as exc:
|
|
291
|
+
# 404 is correct: the request was well-formed but the scope
|
|
292
|
+
# cannot see the requested observation. The body MUST NOT
|
|
293
|
+
# disclose whether the ID exists in another scope.
|
|
294
|
+
raise HTTPException(
|
|
295
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
296
|
+
detail="observation not found in scope",
|
|
297
|
+
) from exc
|
|
298
|
+
return _view_to_observation_response(observation)
|
|
299
|
+
|
|
300
|
+
@app.get(
|
|
301
|
+
f"{MEMORIES_PATH}/{{memory_id}}",
|
|
302
|
+
response_model=MemoryResponse,
|
|
303
|
+
)
|
|
304
|
+
def get_memory(
|
|
305
|
+
memory_id: str = Path(..., min_length=1),
|
|
306
|
+
tenant_id: str = "",
|
|
307
|
+
profile_id: str = "",
|
|
308
|
+
collection_id: str = "",
|
|
309
|
+
agent_id: str = "",
|
|
310
|
+
session_id: str = "",
|
|
311
|
+
svc: MemoryService = Depends(get_service),
|
|
312
|
+
request_principal: Scope | None = auth_dependency,
|
|
313
|
+
) -> MemoryResponse:
|
|
314
|
+
# P1-4: memory-detail endpoint. Search returns memory ids (both
|
|
315
|
+
# observation-derived and derived memories); the dashboard needs
|
|
316
|
+
# to open any hit. Scope construction lives INSIDE the try so
|
|
317
|
+
# validation failures are 422, not 500.
|
|
318
|
+
try:
|
|
319
|
+
scope = ScopeModel(
|
|
320
|
+
tenant_id=tenant_id,
|
|
321
|
+
profile_id=profile_id,
|
|
322
|
+
collection_id=collection_id,
|
|
323
|
+
agent_id=agent_id,
|
|
324
|
+
session_id=session_id,
|
|
325
|
+
)
|
|
326
|
+
scope_obj = resolve_scope(scope, request_principal)
|
|
327
|
+
except ForbiddenError as exc:
|
|
328
|
+
raise HTTPException(
|
|
329
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
330
|
+
detail=str(exc),
|
|
331
|
+
) from exc
|
|
332
|
+
except ValueError as exc:
|
|
333
|
+
raise HTTPException(
|
|
334
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
335
|
+
detail=str(exc),
|
|
336
|
+
) from exc
|
|
337
|
+
try:
|
|
338
|
+
memory = svc.get_memory(scope_obj, memory_id)
|
|
339
|
+
except NotFoundError as exc:
|
|
340
|
+
# Same discipline as observations: a well-formed request for
|
|
341
|
+
# an id the scope cannot see is 404; the body never
|
|
342
|
+
# discloses cross-scope existence.
|
|
343
|
+
raise HTTPException(
|
|
344
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
345
|
+
detail="memory not found in scope",
|
|
346
|
+
) from exc
|
|
347
|
+
return MemoryResponse(
|
|
348
|
+
memory_id=memory.id,
|
|
349
|
+
content=memory.content,
|
|
350
|
+
kind=memory.kind,
|
|
351
|
+
source_observation_ids=list(memory.source_observation_ids),
|
|
352
|
+
scope=ScopeModel.from_domain(memory.scope),
|
|
353
|
+
created_at=str(memory.created_at),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
@app.post(SEARCH_PATH, response_model=SearchResponse)
|
|
357
|
+
def search(
|
|
358
|
+
payload: SearchRequest,
|
|
359
|
+
gw: DerivationGateway = Depends(get_gateway),
|
|
360
|
+
svc: MemoryService = Depends(get_service),
|
|
361
|
+
request_principal: Scope | None = auth_dependency,
|
|
362
|
+
) -> SearchResponse:
|
|
363
|
+
try:
|
|
364
|
+
scope = resolve_scope(payload.scope, request_principal)
|
|
365
|
+
except ForbiddenError as exc:
|
|
366
|
+
raise HTTPException(
|
|
367
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
368
|
+
detail=str(exc),
|
|
369
|
+
) from exc
|
|
370
|
+
except ValueError as exc:
|
|
371
|
+
raise HTTPException(
|
|
372
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
373
|
+
detail=str(exc),
|
|
374
|
+
) from exc
|
|
375
|
+
try:
|
|
376
|
+
hits = svc.search(
|
|
377
|
+
scope,
|
|
378
|
+
query=payload.query,
|
|
379
|
+
limit=payload.limit,
|
|
380
|
+
derive=payload.derive,
|
|
381
|
+
gateway=gw,
|
|
382
|
+
)
|
|
383
|
+
except ValueError as exc:
|
|
384
|
+
raise HTTPException(
|
|
385
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
386
|
+
detail=str(exc),
|
|
387
|
+
) from exc
|
|
388
|
+
return SearchResponse(hits=[_hit_to_response(hit) for hit in hits])
|
|
389
|
+
|
|
390
|
+
@app.exception_handler(Exception)
|
|
391
|
+
async def safe_exception_handler(
|
|
392
|
+
request: Request, exc: Exception
|
|
393
|
+
) -> JSONResponse:
|
|
394
|
+
# HTTPException (404/403/422/503) must pass through with its
|
|
395
|
+
# intended status; only unexpected exceptions become 500.
|
|
396
|
+
# Defence-in-depth: never leak stack traces or canonical
|
|
397
|
+
# payloads. QA gate SEC-03 requires no sensitive content in
|
|
398
|
+
# error responses.
|
|
399
|
+
if isinstance(exc, HTTPException):
|
|
400
|
+
detail = exc.detail if isinstance(exc.detail, (str, dict, list)) else "error"
|
|
401
|
+
return JSONResponse(
|
|
402
|
+
status_code=exc.status_code,
|
|
403
|
+
content={"detail": detail, "type": "http_error"},
|
|
404
|
+
)
|
|
405
|
+
return JSONResponse(
|
|
406
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
407
|
+
content={"detail": "internal error", "type": "server_error"},
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
return app
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
__all__ = [
|
|
414
|
+
"API_PREFIX",
|
|
415
|
+
"OBSERVATIONS_PATH",
|
|
416
|
+
"MEMORIES_PATH",
|
|
417
|
+
"SEARCH_PATH",
|
|
418
|
+
"create_app",
|
|
419
|
+
]
|
severian/api/schemas.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Pydantic request/response models for the versioned HTTP API.
|
|
2
|
+
|
|
3
|
+
All scope fields are mandatory on every request: there is no implicit
|
|
4
|
+
scope on the server. The model rejects empty scope dimensions, which
|
|
5
|
+
mirrors the domain invariant in :class:`severian.domain.models.Scope`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScopeModel(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="forbid")
|
|
16
|
+
|
|
17
|
+
tenant_id: str
|
|
18
|
+
profile_id: str
|
|
19
|
+
collection_id: str
|
|
20
|
+
agent_id: str
|
|
21
|
+
session_id: str
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_domain(cls, scope: Any) -> ScopeModel:
|
|
25
|
+
return cls(
|
|
26
|
+
tenant_id=scope.tenant_id,
|
|
27
|
+
profile_id=scope.profile_id,
|
|
28
|
+
collection_id=scope.collection_id,
|
|
29
|
+
agent_id=scope.agent_id,
|
|
30
|
+
session_id=scope.session_id,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@field_validator(
|
|
34
|
+
"tenant_id", "profile_id", "collection_id", "agent_id", "session_id"
|
|
35
|
+
)
|
|
36
|
+
@classmethod
|
|
37
|
+
def _nonempty(cls, value: str) -> str:
|
|
38
|
+
if not value.strip():
|
|
39
|
+
raise ValueError("scope dimensions must not be empty")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class IngestRequest(BaseModel):
|
|
44
|
+
model_config = ConfigDict(extra="forbid")
|
|
45
|
+
|
|
46
|
+
scope: ScopeModel
|
|
47
|
+
content: str = Field(min_length=1, max_length=1_000_000)
|
|
48
|
+
source: dict[str, Any] = Field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class IngestResponse(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="forbid")
|
|
53
|
+
|
|
54
|
+
observation_id: str
|
|
55
|
+
content: str
|
|
56
|
+
content_hash: str
|
|
57
|
+
scope: ScopeModel
|
|
58
|
+
source: dict[str, Any]
|
|
59
|
+
job_id: str
|
|
60
|
+
job_kind: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ObservationResponse(BaseModel):
|
|
64
|
+
model_config = ConfigDict(extra="forbid")
|
|
65
|
+
|
|
66
|
+
observation_id: str
|
|
67
|
+
content: str
|
|
68
|
+
content_hash: str
|
|
69
|
+
scope: ScopeModel
|
|
70
|
+
source: dict[str, Any]
|
|
71
|
+
created_at: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SearchRequest(BaseModel):
|
|
75
|
+
model_config = ConfigDict(extra="forbid")
|
|
76
|
+
|
|
77
|
+
scope: ScopeModel
|
|
78
|
+
query: str = Field(min_length=1, max_length=10_000)
|
|
79
|
+
limit: int = Field(default=20, ge=1, le=200)
|
|
80
|
+
derive: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SearchHit(BaseModel):
|
|
84
|
+
model_config = ConfigDict(extra="forbid")
|
|
85
|
+
|
|
86
|
+
memory_id: str
|
|
87
|
+
content: str
|
|
88
|
+
source_observation_ids: list[str]
|
|
89
|
+
lexical_score: float
|
|
90
|
+
vector_score: float
|
|
91
|
+
fused_score: float
|
|
92
|
+
scope: ScopeModel
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MemoryResponse(BaseModel):
|
|
96
|
+
model_config = ConfigDict(extra="forbid")
|
|
97
|
+
|
|
98
|
+
memory_id: str
|
|
99
|
+
content: str
|
|
100
|
+
kind: str
|
|
101
|
+
source_observation_ids: list[str]
|
|
102
|
+
scope: ScopeModel
|
|
103
|
+
created_at: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SearchResponse(BaseModel):
|
|
107
|
+
model_config = ConfigDict(extra="forbid")
|
|
108
|
+
|
|
109
|
+
hits: list[SearchHit]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class HealthComponent(BaseModel):
|
|
113
|
+
model_config = ConfigDict(extra="forbid")
|
|
114
|
+
|
|
115
|
+
name: str
|
|
116
|
+
status: str
|
|
117
|
+
detail: str = ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class HealthResponse(BaseModel):
|
|
121
|
+
model_config = ConfigDict(extra="forbid")
|
|
122
|
+
|
|
123
|
+
ready: bool
|
|
124
|
+
components: dict[str, str]
|
|
File without changes
|