mesa-memory 0.9.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.
- mesa_api/__init__.py +9 -0
- mesa_api/admission.py +23 -0
- mesa_api/identifier_validation.py +67 -0
- mesa_api/routers/control/router.py +276 -0
- mesa_api/schemas.py +16 -0
- mesa_api/v4_router.py +1624 -0
- mesa_client/__init__.py +27 -0
- mesa_client/client.py +1265 -0
- mesa_mcp/__init__.py +5 -0
- mesa_mcp/adapter.py +226 -0
- mesa_mcp/antigravity_bridge.py +466 -0
- mesa_mcp/antigravity_cli.py +334 -0
- mesa_mcp/bounded_cache.py +133 -0
- mesa_mcp/codex_cli.py +631 -0
- mesa_mcp/codex_hooks.py +161 -0
- mesa_mcp/configuration.py +100 -0
- mesa_mcp/errors.py +24 -0
- mesa_mcp/gateway/__init__.py +1 -0
- mesa_mcp/gateway/anomaly.py +30 -0
- mesa_mcp/gateway/app.py +270 -0
- mesa_mcp/gateway/approval.py +132 -0
- mesa_mcp/gateway/auth.py +32 -0
- mesa_mcp/gateway/codex_transport.py +148 -0
- mesa_mcp/gateway/middleware.py +182 -0
- mesa_mcp/gateway/operations.py +955 -0
- mesa_mcp/gateway/policy/__init__.py +1 -0
- mesa_mcp/gateway/policy/engine.py +80 -0
- mesa_mcp/gateway/policy/simulator.py +45 -0
- mesa_mcp/http_service.py +63 -0
- mesa_mcp/local_cli.py +374 -0
- mesa_mcp/security.py +68 -0
- mesa_mcp/server.py +317 -0
- mesa_mcp/service.py +25 -0
- mesa_mcp/v4_service.py +582 -0
- mesa_mcp/workspace.py +23 -0
- mesa_memory/adapter/base.py +49 -0
- mesa_memory/adapter/claude.py +220 -0
- mesa_memory/adapter/data/cl100k_base.tiktoken +100256 -0
- mesa_memory/adapter/endpoints.py +14 -0
- mesa_memory/adapter/factory.py +288 -0
- mesa_memory/adapter/live.py +274 -0
- mesa_memory/adapter/ollama.py +113 -0
- mesa_memory/adapter/tokenizer.py +98 -0
- mesa_memory/api/middleware.py +91 -0
- mesa_memory/api/server.py +926 -0
- mesa_memory/config.py +993 -0
- mesa_memory/consolidation/loop.py +1179 -0
- mesa_memory/consolidation/parser.py +271 -0
- mesa_memory/consolidation/policy.py +173 -0
- mesa_memory/consolidation/router.py +392 -0
- mesa_memory/consolidation/schemas.py +255 -0
- mesa_memory/consolidation/validator.py +286 -0
- mesa_memory/container_health.py +25 -0
- mesa_memory/context_builder.py +693 -0
- mesa_memory/document_chunking.py +99 -0
- mesa_memory/embedding/__init__.py +21 -0
- mesa_memory/embedding/service.py +752 -0
- mesa_memory/extraction/service.py +614 -0
- mesa_memory/graph/__init__.py +5 -0
- mesa_memory/graph/projector.py +43 -0
- mesa_memory/observability/http.py +122 -0
- mesa_memory/observability/logger.py +259 -0
- mesa_memory/observability/metrics.py +252 -0
- mesa_memory/rebuild_runner.py +505 -0
- mesa_memory/retrieval/adaptive.py +311 -0
- mesa_memory/retrieval/core.py +2 -0
- mesa_memory/retrieval/legal_resolver.py +15 -0
- mesa_memory/runtime_entrypoint.py +49 -0
- mesa_memory/security/admin_cli.py +204 -0
- mesa_memory/security/api_keys.py +267 -0
- mesa_memory/security/input_validation.py +50 -0
- mesa_memory/security/rbac.py +787 -0
- mesa_memory/security/rbac_constants.py +25 -0
- mesa_memory/security/untrusted_memory.py +45 -0
- mesa_memory/utils.py +36 -0
- mesa_memory-0.9.0.dist-info/METADATA +213 -0
- mesa_memory-0.9.0.dist-info/RECORD +157 -0
- mesa_memory-0.9.0.dist-info/WHEEL +5 -0
- mesa_memory-0.9.0.dist-info/entry_points.txt +8 -0
- mesa_memory-0.9.0.dist-info/licenses/LICENSE +21 -0
- mesa_memory-0.9.0.dist-info/top_level.txt +6 -0
- mesa_storage/__init__.py +42 -0
- mesa_storage/alembic/README +1 -0
- mesa_storage/alembic/env.py +133 -0
- mesa_storage/alembic/script.py.mako +28 -0
- mesa_storage/alembic/versions/076eef5d1b6c_add_daily_limits.py +34 -0
- mesa_storage/alembic/versions/087de6628c51_add_mcp_activity_approval.py +81 -0
- mesa_storage/alembic/versions/0a7b8c9d0e1f_scope_catalog_physical_ids.py +42 -0
- mesa_storage/alembic/versions/1e7a061f7f9e_add_mcp_clients_and_bindings.py +61 -0
- mesa_storage/alembic/versions/41402f316580_add_temporal_validity_to_nodes.py +30 -0
- mesa_storage/alembic/versions/4933fb5fd0ea_initial_schema.py +143 -0
- mesa_storage/alembic/versions/9a1b2c3d4e5f_add_v4_catalog_provenance.py +367 -0
- mesa_storage/alembic/versions/9c6c7ae69ed7_add_mcp_policy_rules.py +41 -0
- mesa_storage/alembic/versions/a1d2e3f4b5c6_add_wal_projection_states.py +53 -0
- mesa_storage/alembic/versions/a2b3c4d5e6f7_add_declared_content_hash.py +24 -0
- mesa_storage/alembic/versions/a94df5d14fce_add_connections_and_settings.py +55 -0
- mesa_storage/alembic/versions/b2e3f4a5c6d7_add_session_finalization_journal.py +32 -0
- mesa_storage/alembic/versions/b3c4d5e6f7a8_allow_unknown_revision_content_hash.py +29 -0
- mesa_storage/alembic/versions/bb2355d0cdd4_add_epistemic_columns.py +38 -0
- mesa_storage/alembic/versions/c3d4e5f6a7b8_replace_daily_limit_credential_subject.py +45 -0
- mesa_storage/alembic/versions/c4d5e6f7a8b9_add_assertion_fts_and_representation.py +119 -0
- mesa_storage/alembic/versions/c4f1a8e2d9b0_add_purge_journal.py +62 -0
- mesa_storage/alembic/versions/c5d6e7f8a9b0_add_mcp_operation_ledger.py +58 -0
- mesa_storage/alembic/versions/d4e5f6a7b8c9_add_v4_mutation_ledger.py +96 -0
- mesa_storage/alembic/versions/d5e6f7a8b9c0_add_typed_legal_identity.py +65 -0
- mesa_storage/alembic/versions/d6e7f8a9b0c1_add_mcp_client_credentials.py +35 -0
- mesa_storage/alembic/versions/e7f8a9b0c1d2_add_mcp_codex_profiles.py +33 -0
- mesa_storage/alembic/versions/e9b7c3a1d4f2_add_claim_leases.py +61 -0
- mesa_storage/alembic/versions/f6d4a7b8c9e0_add_dispatch_journal.py +48 -0
- mesa_storage/alembic/versions/f7e5b9c0d1a2_add_dispatch_queue_payload_bytes.py +33 -0
- mesa_storage/alembic/versions/f8a6c0d1e2b3_add_dispatch_completion_receipts.py +44 -0
- mesa_storage/alembic/versions/f8a9b0c1d2e3_generalize_mcp_context_profiles.py +41 -0
- mesa_storage/alembic/versions/f9a0b1c2d3e4_enforce_catalog_scope_integrity.py +44 -0
- mesa_storage/alembic/versions/fa1b2c3d4e5f_add_mcp_credential_expiry.py +16 -0
- mesa_storage/alembic/versions/fb2c3d4e5f6a_add_revision_manifest_hash.py +18 -0
- mesa_storage/alembic/versions/fc3d4e5f6a7b_add_durable_projection_operations.py +172 -0
- mesa_storage/alembic/versions/fd4e5f6a7b8c_add_embedding_provider_identity.py +20 -0
- mesa_storage/alembic/versions/fe5f6a7b8c9d_enforce_active_head_invariant.py +35 -0
- mesa_storage/alembic/versions/ff6a7b8c9d0e_freeze_revision_manifests.py +29 -0
- mesa_storage/alembic.ini +148 -0
- mesa_storage/control/__init__.py +0 -0
- mesa_storage/control/activity_repo.py +159 -0
- mesa_storage/control/approval_repo.py +141 -0
- mesa_storage/control/client_repo.py +175 -0
- mesa_storage/control/codex_profile_repo.py +105 -0
- mesa_storage/control/connection_repo.py +118 -0
- mesa_storage/control/credential_repo.py +196 -0
- mesa_storage/control/policy_repo.py +81 -0
- mesa_storage/control/settings_repo.py +56 -0
- mesa_storage/dao.py +10747 -0
- mesa_storage/embedding_identity.py +157 -0
- mesa_storage/kuzu_migration.py +450 -0
- mesa_storage/kuzu_provider.py +1768 -0
- mesa_storage/kuzu_schema_migration.py +340 -0
- mesa_storage/kuzu_setup.py +207 -0
- mesa_storage/legal_identity.py +473 -0
- mesa_storage/projection_generations.py +728 -0
- mesa_storage/rebuild_cutover.py +573 -0
- mesa_storage/rebuild_health.py +112 -0
- mesa_storage/rebuild_observability.py +92 -0
- mesa_storage/rebuild_preparation.py +533 -0
- mesa_storage/rebuild_replay.py +745 -0
- mesa_storage/recovery.py +503 -0
- mesa_storage/repositories/__init__.py +20 -0
- mesa_storage/repositories/catalog.py +230 -0
- mesa_storage/repositories/operations.py +690 -0
- mesa_storage/representation.py +99 -0
- mesa_storage/retrieval_scope.py +160 -0
- mesa_storage/schema_contract.py +453 -0
- mesa_storage/schemas.py +292 -0
- mesa_storage/sqlite_engine.py +498 -0
- mesa_storage/vector_engine.py +1697 -0
- mesa_storage/writer_lock.py +116 -0
- mesa_workers/__init__.py +6 -0
- mesa_workers/ingestion_worker.py +465 -0
- mesa_workers/projection_worker.py +322 -0
- mesa_workers/supervision.py +124 -0
mesa_api/v4_router.py
ADDED
|
@@ -0,0 +1,1624 @@
|
|
|
1
|
+
# mypy: disable-error-code="no-untyped-def,untyped-decorator,no-any-return"
|
|
2
|
+
"""Versioned V4 full-cognitive API contract.
|
|
3
|
+
|
|
4
|
+
V3 remains the lexical-core compatibility surface. V4 admission creates a
|
|
5
|
+
canonical mutation before background processing and every later operation is
|
|
6
|
+
authorized against a verified principal-to-agent-to-session binding.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import hashlib
|
|
13
|
+
import logging
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any, Callable, Literal
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
19
|
+
|
|
20
|
+
from mesa_api.admission import require_mutation_admission as _require_mutation_admission
|
|
21
|
+
from mesa_api.identifier_validation import PublicIdentifier, SourceIdentifier
|
|
22
|
+
from mesa_memory.adapter.factory import AdapterFactory
|
|
23
|
+
from mesa_memory.config import config, configured_embedding_identity
|
|
24
|
+
from mesa_memory.consolidation.policy import ValidationPolicy
|
|
25
|
+
from mesa_memory.context_builder import ContextBuilder
|
|
26
|
+
from mesa_memory.document_chunking import chunk_document
|
|
27
|
+
from mesa_memory.embedding.service import (
|
|
28
|
+
EmbeddingGenerationError,
|
|
29
|
+
EmbeddingIdentityMismatchError,
|
|
30
|
+
EmbeddingUnavailableError,
|
|
31
|
+
ExternalProviderForbiddenError,
|
|
32
|
+
)
|
|
33
|
+
from mesa_memory.retrieval.adaptive import (
|
|
34
|
+
BoundedAdaptiveQueryRetriever,
|
|
35
|
+
BoundedQueryPlanner,
|
|
36
|
+
)
|
|
37
|
+
from mesa_memory.security.input_validation import validate_write_payload
|
|
38
|
+
from mesa_memory.security.rbac import AccessControl
|
|
39
|
+
from mesa_storage.dao import (
|
|
40
|
+
MemoryDAO,
|
|
41
|
+
NonHeadRollbackConflictError,
|
|
42
|
+
NonReplayableMutationConflictError,
|
|
43
|
+
PurgeRetryPendingError,
|
|
44
|
+
QueueOverCapacityError,
|
|
45
|
+
QueueRecordTooLargeError,
|
|
46
|
+
QueueUnavailableError,
|
|
47
|
+
)
|
|
48
|
+
from mesa_storage.kuzu_provider import GraphSearchError
|
|
49
|
+
from mesa_storage.legal_identity import canonicalize_trusted_legal_identity
|
|
50
|
+
from mesa_storage.repositories.catalog import CatalogIdentityNotFoundError
|
|
51
|
+
from mesa_storage.repositories.operations import (
|
|
52
|
+
OperationActiveConflictError,
|
|
53
|
+
OperationIdempotencyConflictError,
|
|
54
|
+
OperationNotFoundError,
|
|
55
|
+
OperationStateError,
|
|
56
|
+
)
|
|
57
|
+
from mesa_storage.vector_engine import (
|
|
58
|
+
EmbeddingMigrationRequiredError,
|
|
59
|
+
VectorSearchError,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger("MESA_V4_API")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class V4MutationStatusResponse(BaseModel):
|
|
66
|
+
model_config = ConfigDict(frozen=True)
|
|
67
|
+
|
|
68
|
+
mutation_id: str
|
|
69
|
+
candidate_id: str
|
|
70
|
+
state: str
|
|
71
|
+
failure_class: str | None = None
|
|
72
|
+
rejection_reason: str | None = None
|
|
73
|
+
tier3_audit: dict | None = None
|
|
74
|
+
pipeline_run: dict | None = None
|
|
75
|
+
artifacts: list[dict] = Field(default_factory=list)
|
|
76
|
+
projections: list[dict] = Field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class V4CapabilityFlags(BaseModel):
|
|
80
|
+
model_config = ConfigDict(frozen=True)
|
|
81
|
+
|
|
82
|
+
canonical_ledger: bool = True
|
|
83
|
+
projection_outbox: bool = True
|
|
84
|
+
idempotent_ingestion: bool = True
|
|
85
|
+
vector_retrieval: bool = True
|
|
86
|
+
lexical_retrieval: bool = True
|
|
87
|
+
assertion_relational_lane: bool = True
|
|
88
|
+
validity_interval_filtering: bool = True
|
|
89
|
+
graph_projection: bool = True
|
|
90
|
+
graph_neighbor_retrieval: bool = False
|
|
91
|
+
associative_ppr: bool = False
|
|
92
|
+
bitemporal_query: bool = False
|
|
93
|
+
durable_rebuild: bool = False
|
|
94
|
+
human_review: bool = False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class V4ValidationCapability(BaseModel):
|
|
98
|
+
model_config = ConfigDict(frozen=True)
|
|
99
|
+
|
|
100
|
+
mode: int = 0
|
|
101
|
+
policy: str = "deterministic_only"
|
|
102
|
+
llm_validation_enabled: bool = False
|
|
103
|
+
validator_count: int = 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class V4CapabilityLimits(BaseModel):
|
|
107
|
+
model_config = ConfigDict(frozen=True)
|
|
108
|
+
|
|
109
|
+
rebuild_kind: str = "projection"
|
|
110
|
+
rebuild_scope: str = "storage_root"
|
|
111
|
+
requires_offline_runner: bool = True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class V4CapabilityResponse(BaseModel):
|
|
115
|
+
model_config = ConfigDict(frozen=True)
|
|
116
|
+
|
|
117
|
+
api_version: str = "v4"
|
|
118
|
+
features: list[str]
|
|
119
|
+
capabilities: V4CapabilityFlags
|
|
120
|
+
validation: V4ValidationCapability = Field(default_factory=V4ValidationCapability)
|
|
121
|
+
limits: V4CapabilityLimits = Field(default_factory=V4CapabilityLimits)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class V4OperationProgress(BaseModel):
|
|
125
|
+
model_config = ConfigDict(frozen=True)
|
|
126
|
+
|
|
127
|
+
completed: int = Field(ge=0)
|
|
128
|
+
total: int = Field(ge=0)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class V4OperationResponse(BaseModel):
|
|
132
|
+
"""Content-free control-plane view of one durable operation."""
|
|
133
|
+
|
|
134
|
+
model_config = ConfigDict(frozen=True)
|
|
135
|
+
|
|
136
|
+
operation_id: str
|
|
137
|
+
operation_kind: str = "projection_rebuild"
|
|
138
|
+
scope: str = "storage_root"
|
|
139
|
+
state: str
|
|
140
|
+
attempt: int = Field(ge=0)
|
|
141
|
+
progress: V4OperationProgress
|
|
142
|
+
error_class: str | None = None
|
|
143
|
+
retry_available: bool
|
|
144
|
+
cancel_available: bool
|
|
145
|
+
created_at: str
|
|
146
|
+
updated_at: str
|
|
147
|
+
completed_at: str | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class V4DatasetRequest(BaseModel):
|
|
151
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
152
|
+
|
|
153
|
+
tenant_id: PublicIdentifier
|
|
154
|
+
workspace_id: PublicIdentifier
|
|
155
|
+
dataset_id: PublicIdentifier
|
|
156
|
+
tenant_name: str | None = Field(default=None, max_length=256)
|
|
157
|
+
workspace_name: str | None = Field(default=None, max_length=256)
|
|
158
|
+
dataset_name: str | None = Field(default=None, max_length=256)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class V4WorkspaceRequest(BaseModel):
|
|
162
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
163
|
+
|
|
164
|
+
tenant_id: PublicIdentifier
|
|
165
|
+
workspace_id: PublicIdentifier
|
|
166
|
+
tenant_name: str | None = Field(default=None, max_length=256)
|
|
167
|
+
workspace_name: str | None = Field(default=None, max_length=256)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class V4DocumentRequest(BaseModel):
|
|
171
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
172
|
+
|
|
173
|
+
tenant_id: PublicIdentifier
|
|
174
|
+
workspace_id: PublicIdentifier
|
|
175
|
+
dataset_id: PublicIdentifier
|
|
176
|
+
document_id: SourceIdentifier
|
|
177
|
+
title: str = Field(min_length=1, max_length=512)
|
|
178
|
+
external_ref: str | None = Field(default=None, max_length=2048)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class V4RevisionRequest(BaseModel):
|
|
182
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
183
|
+
|
|
184
|
+
tenant_id: PublicIdentifier
|
|
185
|
+
workspace_id: PublicIdentifier
|
|
186
|
+
dataset_id: PublicIdentifier
|
|
187
|
+
document_id: SourceIdentifier
|
|
188
|
+
revision_id: SourceIdentifier
|
|
189
|
+
revision_number: int = Field(ge=1)
|
|
190
|
+
content_sha256: str = Field(pattern=r"^[0-9a-fA-F]{64}$")
|
|
191
|
+
supersedes_revision_id: SourceIdentifier | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class V4SourceChunkRequest(BaseModel):
|
|
195
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
196
|
+
|
|
197
|
+
tenant_id: PublicIdentifier
|
|
198
|
+
workspace_id: PublicIdentifier
|
|
199
|
+
dataset_id: PublicIdentifier
|
|
200
|
+
document_id: SourceIdentifier
|
|
201
|
+
revision_id: SourceIdentifier
|
|
202
|
+
chunk_id: SourceIdentifier
|
|
203
|
+
title: str = Field(min_length=1, max_length=512)
|
|
204
|
+
content: str = Field(min_length=1, max_length=32768)
|
|
205
|
+
source_ref: str = Field(min_length=1, max_length=2048)
|
|
206
|
+
revision_number: int = Field(default=1, ge=1)
|
|
207
|
+
chunk_ordinal: int = Field(default=0, ge=0)
|
|
208
|
+
finalize_revision: bool = True
|
|
209
|
+
external_ref: str | None = Field(default=None, max_length=2048)
|
|
210
|
+
supersedes_revision_id: SourceIdentifier | None = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class V4SessionStartRequest(BaseModel):
|
|
214
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
215
|
+
|
|
216
|
+
tenant_id: PublicIdentifier
|
|
217
|
+
workspace_id: PublicIdentifier
|
|
218
|
+
dataset_ids: list[PublicIdentifier] = Field(min_length=1, max_length=64)
|
|
219
|
+
agent_id: PublicIdentifier
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class V4LegalIdentityRequest(BaseModel):
|
|
223
|
+
"""Typed, validated legal identity supplied by an authorized ingestion writer."""
|
|
224
|
+
|
|
225
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
226
|
+
|
|
227
|
+
law_id: SourceIdentifier
|
|
228
|
+
statute_code: SourceIdentifier | None = None
|
|
229
|
+
article_id: str = Field(
|
|
230
|
+
pattern=r"^(?:madde[- ]*)?\d+[A-Za-zÇĞİÖŞÜçğıöşü]?$", max_length=32
|
|
231
|
+
)
|
|
232
|
+
version_id: SourceIdentifier | None = None
|
|
233
|
+
paragraph_id: SourceIdentifier | None = None
|
|
234
|
+
clause_id: SourceIdentifier | None = None
|
|
235
|
+
|
|
236
|
+
@model_validator(mode="after")
|
|
237
|
+
def validate_legal_identity(self) -> "V4LegalIdentityRequest":
|
|
238
|
+
canonicalize_trusted_legal_identity(
|
|
239
|
+
{
|
|
240
|
+
**self.model_dump(exclude_none=True),
|
|
241
|
+
"identity_source": "verified_ingestion",
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class V4MemoryInsertRequest(BaseModel):
|
|
248
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
249
|
+
|
|
250
|
+
session_id: PublicIdentifier
|
|
251
|
+
dataset_id: PublicIdentifier
|
|
252
|
+
document_id: SourceIdentifier
|
|
253
|
+
revision_id: SourceIdentifier
|
|
254
|
+
chunk_id: SourceIdentifier
|
|
255
|
+
title: str = Field(min_length=1, max_length=512)
|
|
256
|
+
source_ref: str = Field(min_length=1, max_length=2048)
|
|
257
|
+
content: str = Field(min_length=1, max_length=32768)
|
|
258
|
+
evidence_span: str = Field(default="", max_length=4096)
|
|
259
|
+
revision_number: int = Field(default=1, ge=1)
|
|
260
|
+
chunk_ordinal: int = Field(default=0, ge=0)
|
|
261
|
+
finalize_revision: bool = True
|
|
262
|
+
supersedes_revision_id: SourceIdentifier | None = None
|
|
263
|
+
metadata: dict = Field(default_factory=dict)
|
|
264
|
+
legal_identity: V4LegalIdentityRequest | None = None
|
|
265
|
+
idempotency_key: str | None = Field(default=None, max_length=128)
|
|
266
|
+
|
|
267
|
+
@model_validator(mode="after")
|
|
268
|
+
def validate_write_boundary(self) -> "V4MemoryInsertRequest":
|
|
269
|
+
validate_write_payload(self.content, self.metadata)
|
|
270
|
+
return self
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class V4DocumentIngestRequest(BaseModel):
|
|
274
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
275
|
+
|
|
276
|
+
session_id: PublicIdentifier
|
|
277
|
+
dataset_id: PublicIdentifier
|
|
278
|
+
document_id: SourceIdentifier
|
|
279
|
+
title: str = Field(min_length=1, max_length=512)
|
|
280
|
+
source_ref: str = Field(min_length=1, max_length=2048)
|
|
281
|
+
content: str = Field(min_length=1)
|
|
282
|
+
metadata: dict = Field(default_factory=dict)
|
|
283
|
+
|
|
284
|
+
@model_validator(mode="after")
|
|
285
|
+
def validate_write_boundary(self) -> "V4DocumentIngestRequest":
|
|
286
|
+
if len(self.content.encode("utf-8")) > 5_000_000:
|
|
287
|
+
raise HTTPException(
|
|
288
|
+
status_code=413, detail="document exceeds 5,000,000 UTF-8 bytes"
|
|
289
|
+
)
|
|
290
|
+
if not self.content.strip():
|
|
291
|
+
raise ValueError("content must not be empty")
|
|
292
|
+
validate_write_payload(self.content, self.metadata)
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class V4DocumentIngestResponse(BaseModel):
|
|
297
|
+
document_id: str
|
|
298
|
+
revision_id: str
|
|
299
|
+
revision_number: int
|
|
300
|
+
chunk_count: int
|
|
301
|
+
mutation_ids: list[str]
|
|
302
|
+
duplicate: bool
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class V4SearchRequest(BaseModel):
|
|
306
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
307
|
+
|
|
308
|
+
session_id: PublicIdentifier
|
|
309
|
+
dataset_ids: list[PublicIdentifier] | None = Field(
|
|
310
|
+
default=None, min_length=1, max_length=64
|
|
311
|
+
)
|
|
312
|
+
query: str = Field(min_length=1, max_length=4096)
|
|
313
|
+
limit: int = Field(default=10, ge=1, le=50)
|
|
314
|
+
jurisdiction: str | None = Field(default=None, max_length=256)
|
|
315
|
+
valid_at: datetime | None = None
|
|
316
|
+
valid_from: datetime | None = None
|
|
317
|
+
valid_to: datetime | None = None
|
|
318
|
+
graph_mode: Literal["enabled", "disabled"] = Field(
|
|
319
|
+
default="enabled",
|
|
320
|
+
description="Enable or disable only the graph retrieval lane for matched ablation.",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
@field_validator("valid_at", "valid_from", "valid_to", mode="before")
|
|
324
|
+
@classmethod
|
|
325
|
+
def parse_iso8601_temporal(cls, value: Any) -> Any:
|
|
326
|
+
if value is None:
|
|
327
|
+
return None
|
|
328
|
+
if isinstance(value, datetime):
|
|
329
|
+
return value
|
|
330
|
+
if isinstance(value, str):
|
|
331
|
+
raw = value.strip()
|
|
332
|
+
if not raw or len(raw) > 128 or any(ord(char) < 32 for char in raw):
|
|
333
|
+
raise ValueError(
|
|
334
|
+
"temporal value must be a valid ISO-8601 timestamp string"
|
|
335
|
+
)
|
|
336
|
+
if raw.endswith(("Z", "z")):
|
|
337
|
+
normalized = raw[:-1] + "+00:00"
|
|
338
|
+
else:
|
|
339
|
+
normalized = raw
|
|
340
|
+
try:
|
|
341
|
+
parsed = datetime.fromisoformat(normalized)
|
|
342
|
+
except ValueError as exc:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
"temporal value must be a valid ISO-8601 timestamp string"
|
|
345
|
+
) from exc
|
|
346
|
+
if parsed.tzinfo is None or parsed.tzinfo.utcoffset(parsed) is None:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
"temporal value must include a timezone offset (e.g. 'Z' or '+00:00')"
|
|
349
|
+
)
|
|
350
|
+
return parsed
|
|
351
|
+
return value
|
|
352
|
+
|
|
353
|
+
@model_validator(mode="after")
|
|
354
|
+
def validate_temporal_range(self) -> "V4SearchRequest":
|
|
355
|
+
if self.valid_from and self.valid_to and self.valid_from > self.valid_to:
|
|
356
|
+
raise ValueError("valid_from must not be after valid_to")
|
|
357
|
+
return self
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class V4ScopeAudit(BaseModel):
|
|
361
|
+
"""Versioned proof that the canonical candidate pool was scoped pre-rank."""
|
|
362
|
+
|
|
363
|
+
model_config = ConfigDict(frozen=True)
|
|
364
|
+
|
|
365
|
+
contract_version: str
|
|
366
|
+
enforcement_stage: Literal["pre_rank"]
|
|
367
|
+
requested_scope: dict[str, Any]
|
|
368
|
+
requested_scope_identity: str
|
|
369
|
+
query_identity: str
|
|
370
|
+
evaluated_candidate_count: int = Field(ge=0)
|
|
371
|
+
excluded_candidate_count: int = Field(ge=0)
|
|
372
|
+
eligible_candidate_count: int = Field(ge=0)
|
|
373
|
+
exclusion_audit_hash: str
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class V4GraphAblation(BaseModel):
|
|
377
|
+
"""Versioned identity for a supported graph ON/OFF matched execution."""
|
|
378
|
+
|
|
379
|
+
model_config = ConfigDict(frozen=True)
|
|
380
|
+
|
|
381
|
+
contract_version: str
|
|
382
|
+
mode: Literal["enabled", "disabled"]
|
|
383
|
+
pair_identity: str
|
|
384
|
+
query_identity: str
|
|
385
|
+
retrieval_config_identity: str
|
|
386
|
+
scope_identity: str
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class V4VectorLaneDiagnostics(BaseModel):
|
|
390
|
+
"""Bounded explanation of vector-lane availability for one search."""
|
|
391
|
+
|
|
392
|
+
model_config = ConfigDict(frozen=True)
|
|
393
|
+
|
|
394
|
+
contract_version: str
|
|
395
|
+
status: str
|
|
396
|
+
representation_version: str
|
|
397
|
+
embedding_space_id: str | None = None
|
|
398
|
+
registry_artifact_count: int = Field(ge=0)
|
|
399
|
+
compatible_registry_artifact_count: int = Field(ge=0)
|
|
400
|
+
allowed_vector_id_count: int = Field(ge=0)
|
|
401
|
+
rejected_artifact_count: int = Field(ge=0)
|
|
402
|
+
rejection_reasons: dict[str, int]
|
|
403
|
+
search_executed: bool
|
|
404
|
+
search_result_count: int = Field(ge=0)
|
|
405
|
+
candidate_count: int = Field(ge=0)
|
|
406
|
+
physical_vector_id_count: int = Field(ge=0)
|
|
407
|
+
missing_vector_id_count: int = Field(ge=0)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class V4SearchResponse(BaseModel):
|
|
411
|
+
model_config = ConfigDict(frozen=True)
|
|
412
|
+
|
|
413
|
+
session_id: str
|
|
414
|
+
dataset_ids: list[str]
|
|
415
|
+
results: list[dict[str, Any]]
|
|
416
|
+
degraded_sources: list[str] = []
|
|
417
|
+
scope_audit: V4ScopeAudit | None = None
|
|
418
|
+
graph_ablation: V4GraphAblation | None = None
|
|
419
|
+
vector_lane: V4VectorLaneDiagnostics | None = None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _active_principal(request: Request):
|
|
423
|
+
principal = getattr(request.state, "principal", None)
|
|
424
|
+
if principal is None or getattr(principal, "status", None) != "active":
|
|
425
|
+
raise HTTPException(
|
|
426
|
+
status_code=401, detail="Active authenticated principal required"
|
|
427
|
+
)
|
|
428
|
+
return principal
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def _require_control_admin(
|
|
432
|
+
request: Request,
|
|
433
|
+
access_control: AccessControl,
|
|
434
|
+
*,
|
|
435
|
+
hide_existence: bool = False,
|
|
436
|
+
):
|
|
437
|
+
principal = _active_principal(request)
|
|
438
|
+
if not await access_control.check_control_role(
|
|
439
|
+
str(principal.principal_id), "ADMIN"
|
|
440
|
+
):
|
|
441
|
+
if hide_existence:
|
|
442
|
+
raise HTTPException(status_code=404, detail="Operation not found")
|
|
443
|
+
raise HTTPException(
|
|
444
|
+
status_code=403, detail="Control administrator role required"
|
|
445
|
+
)
|
|
446
|
+
return principal
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _public_operation(operation: dict) -> V4OperationResponse:
|
|
450
|
+
"""Whitelist bounded operation fields safe to expose outside storage."""
|
|
451
|
+
state = str(operation["state"])
|
|
452
|
+
return V4OperationResponse(
|
|
453
|
+
operation_id=str(operation["operation_id"]),
|
|
454
|
+
state=state,
|
|
455
|
+
attempt=int(operation["attempt_count"]),
|
|
456
|
+
progress=V4OperationProgress(
|
|
457
|
+
completed=int(operation["progress_completed"]),
|
|
458
|
+
total=int(operation["progress_total"]),
|
|
459
|
+
),
|
|
460
|
+
error_class=(
|
|
461
|
+
str(operation["last_error_class"])
|
|
462
|
+
if operation.get("last_error_class") is not None
|
|
463
|
+
else None
|
|
464
|
+
),
|
|
465
|
+
retry_available=state == "RETRYABLE_FAILED",
|
|
466
|
+
cancel_available=state in {"PENDING", "RETRYABLE_FAILED"},
|
|
467
|
+
created_at=str(operation["created_at"]),
|
|
468
|
+
updated_at=str(operation["updated_at"]),
|
|
469
|
+
completed_at=(
|
|
470
|
+
str(operation["completed_at"])
|
|
471
|
+
if operation.get("completed_at") is not None
|
|
472
|
+
else None
|
|
473
|
+
),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
async def _require_session_access(
|
|
478
|
+
request: Request,
|
|
479
|
+
access_control: AccessControl,
|
|
480
|
+
*,
|
|
481
|
+
agent_id: str,
|
|
482
|
+
session_id: str,
|
|
483
|
+
level: str,
|
|
484
|
+
allow_closed: bool = False,
|
|
485
|
+
) -> None:
|
|
486
|
+
principal = _active_principal(request)
|
|
487
|
+
if not await access_control.check_principal_session_access(
|
|
488
|
+
principal.principal_id, agent_id, session_id, level
|
|
489
|
+
):
|
|
490
|
+
raise HTTPException(status_code=403, detail="Principal/session access denied")
|
|
491
|
+
if not allow_closed and not await access_control.check_access(
|
|
492
|
+
agent_id, session_id, level
|
|
493
|
+
):
|
|
494
|
+
raise HTTPException(
|
|
495
|
+
status_code=403, detail="Session is not active for requested operation"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
async def _require_dataset_roles(
|
|
500
|
+
request: Request,
|
|
501
|
+
access_control: AccessControl,
|
|
502
|
+
*,
|
|
503
|
+
tenant_id: str,
|
|
504
|
+
workspace_id: str,
|
|
505
|
+
dataset_ids: list[str],
|
|
506
|
+
required_role: str,
|
|
507
|
+
) -> None:
|
|
508
|
+
principal = _active_principal(request)
|
|
509
|
+
for dataset_id in sorted(set(dataset_ids)):
|
|
510
|
+
if not await access_control.check_scope_role(
|
|
511
|
+
principal.principal_id,
|
|
512
|
+
tenant_id=tenant_id,
|
|
513
|
+
workspace_id=workspace_id,
|
|
514
|
+
dataset_id=dataset_id,
|
|
515
|
+
required_role=required_role,
|
|
516
|
+
):
|
|
517
|
+
raise HTTPException(
|
|
518
|
+
status_code=403,
|
|
519
|
+
detail="Principal lacks required dataset role",
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
async def _authorized_v4_session(
|
|
524
|
+
request: Request,
|
|
525
|
+
dao: MemoryDAO,
|
|
526
|
+
access_control: AccessControl,
|
|
527
|
+
session_id: str,
|
|
528
|
+
*,
|
|
529
|
+
level: str,
|
|
530
|
+
allow_closed: bool = False,
|
|
531
|
+
) -> dict:
|
|
532
|
+
principal = _active_principal(request)
|
|
533
|
+
session = await dao.get_v4_session(
|
|
534
|
+
session_id,
|
|
535
|
+
principal_id=str(principal.principal_id),
|
|
536
|
+
)
|
|
537
|
+
if session is None:
|
|
538
|
+
raise HTTPException(status_code=404, detail="Unknown session")
|
|
539
|
+
await _require_session_access(
|
|
540
|
+
request,
|
|
541
|
+
access_control,
|
|
542
|
+
agent_id=str(session["agent_id"]),
|
|
543
|
+
session_id=session_id,
|
|
544
|
+
level=level,
|
|
545
|
+
allow_closed=allow_closed,
|
|
546
|
+
)
|
|
547
|
+
await _require_dataset_roles(
|
|
548
|
+
request,
|
|
549
|
+
access_control,
|
|
550
|
+
tenant_id=str(session["tenant_id"]),
|
|
551
|
+
workspace_id=str(session["workspace_id"]),
|
|
552
|
+
dataset_ids=list(session["dataset_ids"]),
|
|
553
|
+
required_role="WRITER" if level == "WRITE" else "READER",
|
|
554
|
+
)
|
|
555
|
+
if not allow_closed and session["status"] != "ACTIVE" and level == "WRITE":
|
|
556
|
+
raise HTTPException(status_code=409, detail="Session is not active")
|
|
557
|
+
return session
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _require_historical_mutation_scope(mutation: dict, session: dict) -> None:
|
|
561
|
+
"""Bind historical administration to the mutation's immutable session scope."""
|
|
562
|
+
mutation_workspace = mutation.get("workspace_id")
|
|
563
|
+
if (
|
|
564
|
+
str(mutation.get("tenant_id") or "") != str(session["tenant_id"])
|
|
565
|
+
or str(mutation.get("agent_id") or "") != str(session["agent_id"])
|
|
566
|
+
or str(mutation.get("dataset_id") or "")
|
|
567
|
+
not in {str(item) for item in session["dataset_ids"]}
|
|
568
|
+
or (
|
|
569
|
+
mutation_workspace
|
|
570
|
+
and str(mutation_workspace) != str(session["workspace_id"])
|
|
571
|
+
)
|
|
572
|
+
):
|
|
573
|
+
raise HTTPException(
|
|
574
|
+
status_code=403,
|
|
575
|
+
detail="Mutation is outside historical session scope",
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def create_v4_router(
|
|
580
|
+
get_dao: Callable[[], MemoryDAO],
|
|
581
|
+
*,
|
|
582
|
+
get_access_control: Callable[[], AccessControl],
|
|
583
|
+
get_composed_validation_policy: Callable[[], ValidationPolicy | None] | None = None,
|
|
584
|
+
get_extraction_enabled: Callable[[], bool] | None = None,
|
|
585
|
+
) -> APIRouter:
|
|
586
|
+
router = APIRouter(prefix="/v4", tags=["v4-full-cognitive"])
|
|
587
|
+
# ponytail: serialize document uploads; use per-document locks if throughput requires it.
|
|
588
|
+
document_admission_lock = asyncio.Lock()
|
|
589
|
+
|
|
590
|
+
def current_validation_policy() -> ValidationPolicy | None:
|
|
591
|
+
return (
|
|
592
|
+
get_composed_validation_policy()
|
|
593
|
+
if get_composed_validation_policy is not None
|
|
594
|
+
else None
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def is_extraction_enabled() -> bool:
|
|
598
|
+
if get_extraction_enabled is not None:
|
|
599
|
+
return bool(get_extraction_enabled())
|
|
600
|
+
return True
|
|
601
|
+
|
|
602
|
+
@router.post("/catalog/workspaces", status_code=201)
|
|
603
|
+
async def create_workspace(
|
|
604
|
+
request: Request,
|
|
605
|
+
payload: V4WorkspaceRequest,
|
|
606
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
607
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
608
|
+
) -> dict:
|
|
609
|
+
await _require_dataset_roles(
|
|
610
|
+
request,
|
|
611
|
+
access_control,
|
|
612
|
+
tenant_id=payload.tenant_id,
|
|
613
|
+
workspace_id=payload.workspace_id,
|
|
614
|
+
dataset_ids=[""],
|
|
615
|
+
required_role="OWNER",
|
|
616
|
+
)
|
|
617
|
+
await _require_mutation_admission(dao)
|
|
618
|
+
try:
|
|
619
|
+
return await dao.create_v4_workspace(
|
|
620
|
+
tenant_id=payload.tenant_id,
|
|
621
|
+
workspace_id=payload.workspace_id,
|
|
622
|
+
tenant_name=payload.tenant_name,
|
|
623
|
+
workspace_name=payload.workspace_name,
|
|
624
|
+
)
|
|
625
|
+
except ValueError as exc:
|
|
626
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
627
|
+
|
|
628
|
+
@router.get("/catalog/workspaces")
|
|
629
|
+
async def list_workspaces(
|
|
630
|
+
tenant_id: PublicIdentifier,
|
|
631
|
+
request: Request,
|
|
632
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
633
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
634
|
+
) -> dict:
|
|
635
|
+
workspaces = await dao.list_v4_workspaces(tenant_id=tenant_id)
|
|
636
|
+
visible = []
|
|
637
|
+
for workspace in workspaces:
|
|
638
|
+
try:
|
|
639
|
+
await _require_dataset_roles(
|
|
640
|
+
request,
|
|
641
|
+
access_control,
|
|
642
|
+
tenant_id=tenant_id,
|
|
643
|
+
workspace_id=str(workspace["workspace_id"]),
|
|
644
|
+
dataset_ids=[""],
|
|
645
|
+
required_role="READER",
|
|
646
|
+
)
|
|
647
|
+
except HTTPException:
|
|
648
|
+
continue
|
|
649
|
+
visible.append(workspace)
|
|
650
|
+
return {"workspaces": visible}
|
|
651
|
+
|
|
652
|
+
@router.post("/catalog/datasets", status_code=201)
|
|
653
|
+
async def create_dataset(
|
|
654
|
+
request: Request,
|
|
655
|
+
payload: V4DatasetRequest,
|
|
656
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
657
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
658
|
+
) -> dict:
|
|
659
|
+
await _require_dataset_roles(
|
|
660
|
+
request,
|
|
661
|
+
access_control,
|
|
662
|
+
tenant_id=payload.tenant_id,
|
|
663
|
+
workspace_id=payload.workspace_id,
|
|
664
|
+
dataset_ids=[payload.dataset_id],
|
|
665
|
+
required_role="OWNER",
|
|
666
|
+
)
|
|
667
|
+
await _require_mutation_admission(dao)
|
|
668
|
+
try:
|
|
669
|
+
await dao.ensure_v4_catalog_scope(
|
|
670
|
+
tenant_id=payload.tenant_id,
|
|
671
|
+
workspace_id=payload.workspace_id,
|
|
672
|
+
dataset_id=payload.dataset_id,
|
|
673
|
+
tenant_name=payload.tenant_name,
|
|
674
|
+
workspace_name=payload.workspace_name,
|
|
675
|
+
dataset_name=payload.dataset_name,
|
|
676
|
+
)
|
|
677
|
+
except ValueError as exc:
|
|
678
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
679
|
+
return payload.model_dump()
|
|
680
|
+
|
|
681
|
+
@router.get("/catalog/datasets")
|
|
682
|
+
async def list_datasets(
|
|
683
|
+
tenant_id: PublicIdentifier,
|
|
684
|
+
workspace_id: PublicIdentifier,
|
|
685
|
+
request: Request,
|
|
686
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
687
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
688
|
+
) -> dict:
|
|
689
|
+
datasets = await dao.list_v4_datasets(
|
|
690
|
+
tenant_id=tenant_id, workspace_id=workspace_id
|
|
691
|
+
)
|
|
692
|
+
visible = []
|
|
693
|
+
for dataset in datasets:
|
|
694
|
+
try:
|
|
695
|
+
await _require_dataset_roles(
|
|
696
|
+
request,
|
|
697
|
+
access_control,
|
|
698
|
+
tenant_id=tenant_id,
|
|
699
|
+
workspace_id=workspace_id,
|
|
700
|
+
dataset_ids=[str(dataset["dataset_id"])],
|
|
701
|
+
required_role="READER",
|
|
702
|
+
)
|
|
703
|
+
except HTTPException:
|
|
704
|
+
continue
|
|
705
|
+
visible.append(dataset)
|
|
706
|
+
return {"datasets": visible}
|
|
707
|
+
|
|
708
|
+
@router.post("/catalog/documents", status_code=201)
|
|
709
|
+
async def create_document(
|
|
710
|
+
request: Request,
|
|
711
|
+
payload: V4DocumentRequest,
|
|
712
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
713
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
714
|
+
) -> dict:
|
|
715
|
+
await _require_dataset_roles(
|
|
716
|
+
request,
|
|
717
|
+
access_control,
|
|
718
|
+
tenant_id=payload.tenant_id,
|
|
719
|
+
workspace_id=payload.workspace_id,
|
|
720
|
+
dataset_ids=[payload.dataset_id],
|
|
721
|
+
required_role="WRITER",
|
|
722
|
+
)
|
|
723
|
+
await _require_mutation_admission(dao)
|
|
724
|
+
try:
|
|
725
|
+
return await dao.create_v4_document(
|
|
726
|
+
tenant_id=payload.tenant_id,
|
|
727
|
+
dataset_id=payload.dataset_id,
|
|
728
|
+
document_id=payload.document_id,
|
|
729
|
+
title=payload.title,
|
|
730
|
+
external_ref=payload.external_ref,
|
|
731
|
+
)
|
|
732
|
+
except ValueError as exc:
|
|
733
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
734
|
+
|
|
735
|
+
@router.get("/catalog/documents")
|
|
736
|
+
async def list_documents(
|
|
737
|
+
tenant_id: PublicIdentifier,
|
|
738
|
+
workspace_id: PublicIdentifier,
|
|
739
|
+
dataset_id: PublicIdentifier,
|
|
740
|
+
request: Request,
|
|
741
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
742
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
743
|
+
) -> dict:
|
|
744
|
+
await _require_dataset_roles(
|
|
745
|
+
request,
|
|
746
|
+
access_control,
|
|
747
|
+
tenant_id=tenant_id,
|
|
748
|
+
workspace_id=workspace_id,
|
|
749
|
+
dataset_ids=[dataset_id],
|
|
750
|
+
required_role="READER",
|
|
751
|
+
)
|
|
752
|
+
return {
|
|
753
|
+
"documents": await dao.list_v4_documents(
|
|
754
|
+
tenant_id=tenant_id, dataset_id=dataset_id
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
@router.post("/catalog/revisions", status_code=201)
|
|
759
|
+
async def create_revision(
|
|
760
|
+
request: Request,
|
|
761
|
+
payload: V4RevisionRequest,
|
|
762
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
763
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
764
|
+
) -> dict:
|
|
765
|
+
await _require_dataset_roles(
|
|
766
|
+
request,
|
|
767
|
+
access_control,
|
|
768
|
+
tenant_id=payload.tenant_id,
|
|
769
|
+
workspace_id=payload.workspace_id,
|
|
770
|
+
dataset_ids=[payload.dataset_id],
|
|
771
|
+
required_role="WRITER",
|
|
772
|
+
)
|
|
773
|
+
await _require_mutation_admission(dao)
|
|
774
|
+
try:
|
|
775
|
+
return await dao.create_v4_revision(
|
|
776
|
+
tenant_id=payload.tenant_id,
|
|
777
|
+
dataset_id=payload.dataset_id,
|
|
778
|
+
document_id=payload.document_id,
|
|
779
|
+
revision_id=payload.revision_id,
|
|
780
|
+
revision_number=payload.revision_number,
|
|
781
|
+
content_hash=payload.content_sha256,
|
|
782
|
+
supersedes_revision_id=payload.supersedes_revision_id,
|
|
783
|
+
)
|
|
784
|
+
except ValueError as exc:
|
|
785
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
786
|
+
|
|
787
|
+
@router.get("/catalog/revisions")
|
|
788
|
+
async def list_revisions(
|
|
789
|
+
tenant_id: PublicIdentifier,
|
|
790
|
+
workspace_id: PublicIdentifier,
|
|
791
|
+
dataset_id: PublicIdentifier,
|
|
792
|
+
document_id: SourceIdentifier,
|
|
793
|
+
request: Request,
|
|
794
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
795
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
796
|
+
) -> dict:
|
|
797
|
+
await _require_dataset_roles(
|
|
798
|
+
request,
|
|
799
|
+
access_control,
|
|
800
|
+
tenant_id=tenant_id,
|
|
801
|
+
workspace_id=workspace_id,
|
|
802
|
+
dataset_ids=[dataset_id],
|
|
803
|
+
required_role="READER",
|
|
804
|
+
)
|
|
805
|
+
try:
|
|
806
|
+
return {
|
|
807
|
+
"revisions": await dao.list_v4_revisions(
|
|
808
|
+
tenant_id=tenant_id,
|
|
809
|
+
dataset_id=dataset_id,
|
|
810
|
+
document_id=document_id,
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
except CatalogIdentityNotFoundError as exc:
|
|
814
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
815
|
+
|
|
816
|
+
@router.post("/catalog/source-chunks", status_code=201)
|
|
817
|
+
async def create_source_chunk(
|
|
818
|
+
request: Request,
|
|
819
|
+
payload: V4SourceChunkRequest,
|
|
820
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
821
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
822
|
+
) -> dict:
|
|
823
|
+
await _require_dataset_roles(
|
|
824
|
+
request,
|
|
825
|
+
access_control,
|
|
826
|
+
tenant_id=payload.tenant_id,
|
|
827
|
+
workspace_id=payload.workspace_id,
|
|
828
|
+
dataset_ids=[payload.dataset_id],
|
|
829
|
+
required_role="WRITER",
|
|
830
|
+
)
|
|
831
|
+
await _require_mutation_admission(dao)
|
|
832
|
+
try:
|
|
833
|
+
return await dao.create_v4_source_chunk(
|
|
834
|
+
tenant_id=payload.tenant_id,
|
|
835
|
+
dataset_id=payload.dataset_id,
|
|
836
|
+
document_id=payload.document_id,
|
|
837
|
+
revision_id=payload.revision_id,
|
|
838
|
+
chunk_id=payload.chunk_id,
|
|
839
|
+
title=payload.title,
|
|
840
|
+
content_payload=payload.content,
|
|
841
|
+
source_ref=payload.source_ref,
|
|
842
|
+
revision_number=payload.revision_number,
|
|
843
|
+
chunk_ordinal=payload.chunk_ordinal,
|
|
844
|
+
finalize_revision=payload.finalize_revision,
|
|
845
|
+
external_ref=payload.external_ref,
|
|
846
|
+
supersedes_revision_id=payload.supersedes_revision_id,
|
|
847
|
+
)
|
|
848
|
+
except ValueError as exc:
|
|
849
|
+
msg = str(exc)
|
|
850
|
+
if any(
|
|
851
|
+
term in msg
|
|
852
|
+
for term in ("collides", "finalized", "immutable", "superseded", "belong")
|
|
853
|
+
):
|
|
854
|
+
raise HTTPException(status_code=409, detail=msg) from exc
|
|
855
|
+
raise HTTPException(status_code=422, detail=msg) from exc
|
|
856
|
+
|
|
857
|
+
@router.delete("/catalog/documents/{document_id}", status_code=202)
|
|
858
|
+
async def purge_document(
|
|
859
|
+
document_id: SourceIdentifier,
|
|
860
|
+
tenant_id: PublicIdentifier,
|
|
861
|
+
workspace_id: PublicIdentifier,
|
|
862
|
+
dataset_id: PublicIdentifier,
|
|
863
|
+
request: Request,
|
|
864
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
865
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
866
|
+
) -> dict:
|
|
867
|
+
await _require_dataset_roles(
|
|
868
|
+
request,
|
|
869
|
+
access_control,
|
|
870
|
+
tenant_id=tenant_id,
|
|
871
|
+
workspace_id=workspace_id,
|
|
872
|
+
dataset_ids=[dataset_id],
|
|
873
|
+
required_role="OWNER",
|
|
874
|
+
)
|
|
875
|
+
principal = _active_principal(request)
|
|
876
|
+
if not await access_control.check_dataset_permission(
|
|
877
|
+
principal.principal_id,
|
|
878
|
+
tenant_id=tenant_id,
|
|
879
|
+
dataset_id=dataset_id,
|
|
880
|
+
permission="PURGE",
|
|
881
|
+
):
|
|
882
|
+
raise HTTPException(status_code=403, detail="PURGE permission required")
|
|
883
|
+
await _require_mutation_admission(dao)
|
|
884
|
+
try:
|
|
885
|
+
return await dao.purge_v4_document(
|
|
886
|
+
tenant_id=tenant_id,
|
|
887
|
+
dataset_id=dataset_id,
|
|
888
|
+
document_id=document_id,
|
|
889
|
+
)
|
|
890
|
+
except ValueError as exc:
|
|
891
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
892
|
+
except PurgeRetryPendingError as exc:
|
|
893
|
+
raise HTTPException(
|
|
894
|
+
status_code=503,
|
|
895
|
+
detail="purge_cleanup_pending",
|
|
896
|
+
headers={"Retry-After": "5"},
|
|
897
|
+
) from exc
|
|
898
|
+
|
|
899
|
+
@router.post("/sessions/start", status_code=201)
|
|
900
|
+
async def start_session(
|
|
901
|
+
request: Request,
|
|
902
|
+
payload: V4SessionStartRequest,
|
|
903
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
904
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
905
|
+
) -> dict:
|
|
906
|
+
principal = _active_principal(request)
|
|
907
|
+
if not await access_control.check_principal_permission(
|
|
908
|
+
principal.principal_id, payload.agent_id, "SESSION_CREATE"
|
|
909
|
+
):
|
|
910
|
+
raise HTTPException(
|
|
911
|
+
status_code=403, detail="Principal lacks SESSION_CREATE permission"
|
|
912
|
+
)
|
|
913
|
+
await _require_dataset_roles(
|
|
914
|
+
request,
|
|
915
|
+
access_control,
|
|
916
|
+
tenant_id=payload.tenant_id,
|
|
917
|
+
workspace_id=payload.workspace_id,
|
|
918
|
+
dataset_ids=payload.dataset_ids,
|
|
919
|
+
required_role="WRITER",
|
|
920
|
+
)
|
|
921
|
+
await _require_mutation_admission(dao)
|
|
922
|
+
try:
|
|
923
|
+
session = await dao.create_v4_session(
|
|
924
|
+
tenant_id=payload.tenant_id,
|
|
925
|
+
workspace_id=payload.workspace_id,
|
|
926
|
+
dataset_ids=payload.dataset_ids,
|
|
927
|
+
agent_id=payload.agent_id,
|
|
928
|
+
principal_id=principal.principal_id,
|
|
929
|
+
)
|
|
930
|
+
except ValueError as exc:
|
|
931
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
932
|
+
session_id = str(session["session_id"])
|
|
933
|
+
await access_control.grant_access(payload.agent_id, session_id, "WRITE")
|
|
934
|
+
await access_control.grant_principal_session_access(
|
|
935
|
+
principal.principal_id, payload.agent_id, session_id, "WRITE"
|
|
936
|
+
)
|
|
937
|
+
return {"status": "started", **session}
|
|
938
|
+
|
|
939
|
+
@router.get("/capability", status_code=200)
|
|
940
|
+
async def get_capability(
|
|
941
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
942
|
+
) -> V4CapabilityResponse:
|
|
943
|
+
"""Return bounded capability truth without implying planned behaviour."""
|
|
944
|
+
vector_available = getattr(
|
|
945
|
+
getattr(dao, "_vec", None),
|
|
946
|
+
"semantic_operational",
|
|
947
|
+
getattr(getattr(dao, "_vec", None), "semantic_runtime_available", False),
|
|
948
|
+
)
|
|
949
|
+
vector_contract_available = True
|
|
950
|
+
if callable(getattr(type(dao), "vector_consumer_status", None)):
|
|
951
|
+
vector_consumer = await dao.vector_consumer_status()
|
|
952
|
+
vector_contract_available = (
|
|
953
|
+
vector_consumer.get("compatible_registry_artifacts", 0) > 0
|
|
954
|
+
)
|
|
955
|
+
graph_available = getattr(dao, "graph_operational", False)
|
|
956
|
+
canonical_writes_available = dao.canonical_v4_writes_enabled is not False
|
|
957
|
+
capabilities = V4CapabilityFlags(
|
|
958
|
+
projection_outbox=canonical_writes_available,
|
|
959
|
+
idempotent_ingestion=canonical_writes_available,
|
|
960
|
+
durable_rebuild=config.v4_rebuild_enabled,
|
|
961
|
+
vector_retrieval=(
|
|
962
|
+
vector_available and vector_contract_available
|
|
963
|
+
if isinstance(vector_available, bool)
|
|
964
|
+
else False
|
|
965
|
+
),
|
|
966
|
+
graph_projection=canonical_writes_available
|
|
967
|
+
and dao.graph_enabled is not False,
|
|
968
|
+
graph_neighbor_retrieval=(
|
|
969
|
+
graph_available if isinstance(graph_available, bool) else False
|
|
970
|
+
),
|
|
971
|
+
)
|
|
972
|
+
policy = current_validation_policy()
|
|
973
|
+
if policy is not None:
|
|
974
|
+
val_cap = V4ValidationCapability(
|
|
975
|
+
mode=policy.mode,
|
|
976
|
+
policy=policy.name,
|
|
977
|
+
llm_validation_enabled=policy.llm_validation_enabled,
|
|
978
|
+
validator_count=policy.validator_count,
|
|
979
|
+
)
|
|
980
|
+
elif config.effective_tier3_mode() == 0:
|
|
981
|
+
val_cap = V4ValidationCapability(
|
|
982
|
+
mode=0,
|
|
983
|
+
policy="deterministic_only",
|
|
984
|
+
llm_validation_enabled=False,
|
|
985
|
+
validator_count=0,
|
|
986
|
+
)
|
|
987
|
+
else:
|
|
988
|
+
val_cap = V4ValidationCapability(
|
|
989
|
+
mode=config.effective_tier3_mode(),
|
|
990
|
+
policy="not_composed",
|
|
991
|
+
llm_validation_enabled=False,
|
|
992
|
+
validator_count=0,
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
return V4CapabilityResponse(
|
|
996
|
+
features=[
|
|
997
|
+
name for name, enabled in capabilities.model_dump().items() if enabled
|
|
998
|
+
],
|
|
999
|
+
capabilities=capabilities,
|
|
1000
|
+
validation=val_cap,
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
async def submit_rebuild_operation(
|
|
1004
|
+
request: Request,
|
|
1005
|
+
idempotency_key: str,
|
|
1006
|
+
dao: MemoryDAO,
|
|
1007
|
+
access_control: AccessControl,
|
|
1008
|
+
) -> V4OperationResponse:
|
|
1009
|
+
principal = await _require_control_admin(request, access_control)
|
|
1010
|
+
if (
|
|
1011
|
+
not idempotency_key
|
|
1012
|
+
or idempotency_key.strip() != idempotency_key
|
|
1013
|
+
or len(idempotency_key) > 128
|
|
1014
|
+
or any(ord(char) < 32 for char in idempotency_key)
|
|
1015
|
+
):
|
|
1016
|
+
raise HTTPException(status_code=422, detail="Idempotency-Key is invalid")
|
|
1017
|
+
if not config.v4_rebuild_enabled:
|
|
1018
|
+
raise HTTPException(status_code=501, detail="Durable rebuild is disabled")
|
|
1019
|
+
payload_hash = hashlib.sha256(
|
|
1020
|
+
b'{"kind":"projection","scope":"storage_root","version":1}'
|
|
1021
|
+
).hexdigest()
|
|
1022
|
+
try:
|
|
1023
|
+
operation = await dao.operations.submit(
|
|
1024
|
+
requested_by_principal_id=str(principal.principal_id),
|
|
1025
|
+
idempotency_key=idempotency_key,
|
|
1026
|
+
payload_hash=payload_hash,
|
|
1027
|
+
)
|
|
1028
|
+
except OperationIdempotencyConflictError as exc:
|
|
1029
|
+
raise HTTPException(
|
|
1030
|
+
status_code=409, detail="Idempotency key conflicts with request"
|
|
1031
|
+
) from exc
|
|
1032
|
+
except OperationActiveConflictError as exc:
|
|
1033
|
+
raise HTTPException(
|
|
1034
|
+
status_code=409, detail="A projection rebuild is already active"
|
|
1035
|
+
) from exc
|
|
1036
|
+
return _public_operation(operation)
|
|
1037
|
+
|
|
1038
|
+
@router.post(
|
|
1039
|
+
"/operations/rebuild",
|
|
1040
|
+
response_model=V4OperationResponse,
|
|
1041
|
+
status_code=202,
|
|
1042
|
+
)
|
|
1043
|
+
async def create_rebuild_operation(
|
|
1044
|
+
request: Request,
|
|
1045
|
+
idempotency_key: str = Header(
|
|
1046
|
+
...,
|
|
1047
|
+
alias="Idempotency-Key",
|
|
1048
|
+
min_length=1,
|
|
1049
|
+
max_length=128,
|
|
1050
|
+
),
|
|
1051
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1052
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1053
|
+
) -> V4OperationResponse:
|
|
1054
|
+
return await submit_rebuild_operation(
|
|
1055
|
+
request, idempotency_key, dao, access_control
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
@router.get(
|
|
1059
|
+
"/operations/{operation_id}",
|
|
1060
|
+
response_model=V4OperationResponse,
|
|
1061
|
+
status_code=200,
|
|
1062
|
+
)
|
|
1063
|
+
async def get_rebuild_operation(
|
|
1064
|
+
operation_id: PublicIdentifier,
|
|
1065
|
+
request: Request,
|
|
1066
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1067
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1068
|
+
) -> V4OperationResponse:
|
|
1069
|
+
await _require_control_admin(request, access_control, hide_existence=True)
|
|
1070
|
+
operation = await dao.operations.get(operation_id)
|
|
1071
|
+
if operation is None:
|
|
1072
|
+
raise HTTPException(status_code=404, detail="Operation not found")
|
|
1073
|
+
return _public_operation(operation)
|
|
1074
|
+
|
|
1075
|
+
@router.post(
|
|
1076
|
+
"/operations/{operation_id}/cancel",
|
|
1077
|
+
response_model=V4OperationResponse,
|
|
1078
|
+
status_code=200,
|
|
1079
|
+
)
|
|
1080
|
+
async def cancel_rebuild_operation(
|
|
1081
|
+
operation_id: PublicIdentifier,
|
|
1082
|
+
request: Request,
|
|
1083
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1084
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1085
|
+
) -> V4OperationResponse:
|
|
1086
|
+
await _require_control_admin(request, access_control, hide_existence=True)
|
|
1087
|
+
try:
|
|
1088
|
+
operation = await dao.operations.cancel(operation_id)
|
|
1089
|
+
except OperationNotFoundError as exc:
|
|
1090
|
+
raise HTTPException(status_code=404, detail="Operation not found") from exc
|
|
1091
|
+
except OperationStateError as exc:
|
|
1092
|
+
raise HTTPException(
|
|
1093
|
+
status_code=409, detail="Operation cannot be cancelled"
|
|
1094
|
+
) from exc
|
|
1095
|
+
return _public_operation(operation)
|
|
1096
|
+
|
|
1097
|
+
@router.post(
|
|
1098
|
+
"/operations/{operation_id}/retry",
|
|
1099
|
+
response_model=V4OperationResponse,
|
|
1100
|
+
status_code=202,
|
|
1101
|
+
)
|
|
1102
|
+
async def retry_rebuild_operation(
|
|
1103
|
+
operation_id: PublicIdentifier,
|
|
1104
|
+
request: Request,
|
|
1105
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1106
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1107
|
+
) -> V4OperationResponse:
|
|
1108
|
+
await _require_control_admin(request, access_control, hide_existence=True)
|
|
1109
|
+
if not config.v4_rebuild_enabled:
|
|
1110
|
+
raise HTTPException(status_code=501, detail="Durable rebuild is disabled")
|
|
1111
|
+
try:
|
|
1112
|
+
operation = await dao.operations.retry(operation_id)
|
|
1113
|
+
except OperationNotFoundError as exc:
|
|
1114
|
+
raise HTTPException(status_code=404, detail="Operation not found") from exc
|
|
1115
|
+
except OperationStateError as exc:
|
|
1116
|
+
raise HTTPException(
|
|
1117
|
+
status_code=409, detail="Operation cannot be retried"
|
|
1118
|
+
) from exc
|
|
1119
|
+
return _public_operation(operation)
|
|
1120
|
+
|
|
1121
|
+
@router.post(
|
|
1122
|
+
"/rebuild",
|
|
1123
|
+
response_model=V4OperationResponse,
|
|
1124
|
+
status_code=202,
|
|
1125
|
+
deprecated=True,
|
|
1126
|
+
)
|
|
1127
|
+
async def rebuild_index(
|
|
1128
|
+
request: Request,
|
|
1129
|
+
tenant_id: PublicIdentifier | None = None,
|
|
1130
|
+
workspace_id: PublicIdentifier | None = None,
|
|
1131
|
+
dataset_id: PublicIdentifier | None = None,
|
|
1132
|
+
idempotency_key: str = Header(
|
|
1133
|
+
...,
|
|
1134
|
+
alias="Idempotency-Key",
|
|
1135
|
+
min_length=1,
|
|
1136
|
+
max_length=128,
|
|
1137
|
+
),
|
|
1138
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1139
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1140
|
+
) -> V4OperationResponse:
|
|
1141
|
+
await _require_control_admin(request, access_control)
|
|
1142
|
+
if tenant_id is not None or workspace_id is not None or dataset_id is not None:
|
|
1143
|
+
raise HTTPException(
|
|
1144
|
+
status_code=409,
|
|
1145
|
+
detail="Scoped projection rebuild is not supported",
|
|
1146
|
+
)
|
|
1147
|
+
return await submit_rebuild_operation(
|
|
1148
|
+
request, idempotency_key, dao, access_control
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
@router.post("/memory/insert", status_code=202)
|
|
1152
|
+
async def insert_memory(
|
|
1153
|
+
request: Request,
|
|
1154
|
+
payload: V4MemoryInsertRequest,
|
|
1155
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1156
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1157
|
+
) -> dict:
|
|
1158
|
+
session = await _authorized_v4_session(
|
|
1159
|
+
request,
|
|
1160
|
+
dao,
|
|
1161
|
+
access_control,
|
|
1162
|
+
payload.session_id,
|
|
1163
|
+
level="WRITE",
|
|
1164
|
+
)
|
|
1165
|
+
if payload.dataset_id not in session["dataset_ids"]:
|
|
1166
|
+
raise HTTPException(
|
|
1167
|
+
status_code=403, detail="Dataset is outside session scope"
|
|
1168
|
+
)
|
|
1169
|
+
await _require_mutation_admission(dao, require_projection_consumer=True)
|
|
1170
|
+
payload_hash = hashlib.sha256(
|
|
1171
|
+
payload.model_dump_json(exclude={"session_id", "idempotency_key"}).encode()
|
|
1172
|
+
).hexdigest()
|
|
1173
|
+
embedding_identity = configured_embedding_identity()
|
|
1174
|
+
validation_policy = current_validation_policy()
|
|
1175
|
+
try:
|
|
1176
|
+
admission = await dao.admit_v4_memory(
|
|
1177
|
+
tenant_id=str(session["tenant_id"]),
|
|
1178
|
+
workspace_id=str(session["workspace_id"]),
|
|
1179
|
+
dataset_id=payload.dataset_id,
|
|
1180
|
+
agent_id=str(session["agent_id"]),
|
|
1181
|
+
session_id=payload.session_id,
|
|
1182
|
+
document_id=payload.document_id,
|
|
1183
|
+
revision_id=payload.revision_id,
|
|
1184
|
+
chunk_id=payload.chunk_id,
|
|
1185
|
+
title=payload.title,
|
|
1186
|
+
content_payload=payload.content,
|
|
1187
|
+
source_ref=payload.source_ref,
|
|
1188
|
+
evidence_span=payload.evidence_span,
|
|
1189
|
+
revision_number=payload.revision_number,
|
|
1190
|
+
chunk_ordinal=payload.chunk_ordinal,
|
|
1191
|
+
supersedes_revision_id=payload.supersedes_revision_id,
|
|
1192
|
+
metadata=payload.metadata,
|
|
1193
|
+
legal_identity=(
|
|
1194
|
+
payload.legal_identity.model_dump(exclude_none=True)
|
|
1195
|
+
if payload.legal_identity is not None
|
|
1196
|
+
else None
|
|
1197
|
+
),
|
|
1198
|
+
embedding_provider=embedding_identity.provider,
|
|
1199
|
+
embedding_model=embedding_identity.model,
|
|
1200
|
+
embedding_version=embedding_identity.version,
|
|
1201
|
+
embedding_dimension=embedding_identity.dimension,
|
|
1202
|
+
embedding_space_id=embedding_identity.embedding_space_id,
|
|
1203
|
+
embedding_model_revision=embedding_identity.model_revision,
|
|
1204
|
+
embedding_normalized=embedding_identity.normalized,
|
|
1205
|
+
policy=config.queue_admission_policy,
|
|
1206
|
+
validation_mode=(
|
|
1207
|
+
validation_policy.mode
|
|
1208
|
+
if validation_policy is not None
|
|
1209
|
+
else config.effective_tier3_mode()
|
|
1210
|
+
),
|
|
1211
|
+
idempotency_key=payload.idempotency_key,
|
|
1212
|
+
payload_hash=payload_hash if payload.idempotency_key else None,
|
|
1213
|
+
finalize_revision=payload.finalize_revision,
|
|
1214
|
+
)
|
|
1215
|
+
except ValueError as exc:
|
|
1216
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
1217
|
+
except QueueRecordTooLargeError:
|
|
1218
|
+
raise HTTPException(status_code=413, detail="queue_record_too_large")
|
|
1219
|
+
except QueueOverCapacityError:
|
|
1220
|
+
raise HTTPException(status_code=503, detail="queue_over_capacity")
|
|
1221
|
+
except QueueUnavailableError:
|
|
1222
|
+
raise HTTPException(status_code=503, detail="queue_unavailable")
|
|
1223
|
+
if admission["outcome"] == "IN_PROGRESS":
|
|
1224
|
+
raise HTTPException(
|
|
1225
|
+
status_code=409, detail="idempotency_key is in progress"
|
|
1226
|
+
)
|
|
1227
|
+
response = dict(admission["response"])
|
|
1228
|
+
if admission["outcome"] == "DUPLICATE":
|
|
1229
|
+
response["duplicate"] = True
|
|
1230
|
+
return response
|
|
1231
|
+
|
|
1232
|
+
@router.post("/documents", response_model=V4DocumentIngestResponse, status_code=202)
|
|
1233
|
+
async def ingest_document(
|
|
1234
|
+
request: Request,
|
|
1235
|
+
payload: V4DocumentIngestRequest,
|
|
1236
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1237
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1238
|
+
) -> dict:
|
|
1239
|
+
session = await _authorized_v4_session(
|
|
1240
|
+
request, dao, access_control, payload.session_id, level="WRITE"
|
|
1241
|
+
)
|
|
1242
|
+
if payload.dataset_id not in session["dataset_ids"]:
|
|
1243
|
+
raise HTTPException(
|
|
1244
|
+
status_code=403, detail="Dataset is outside session scope"
|
|
1245
|
+
)
|
|
1246
|
+
await _require_mutation_admission(dao, require_projection_consumer=True)
|
|
1247
|
+
content_hash = hashlib.sha256(payload.content.encode("utf-8")).hexdigest()
|
|
1248
|
+
revision_id = (
|
|
1249
|
+
"rev-"
|
|
1250
|
+
+ hashlib.sha256(
|
|
1251
|
+
(payload.document_id + "\n" + content_hash).encode("utf-8")
|
|
1252
|
+
).hexdigest()[:32]
|
|
1253
|
+
)
|
|
1254
|
+
scope = {
|
|
1255
|
+
"tenant_id": str(session["tenant_id"]),
|
|
1256
|
+
"dataset_id": payload.dataset_id,
|
|
1257
|
+
}
|
|
1258
|
+
async with document_admission_lock:
|
|
1259
|
+
try:
|
|
1260
|
+
document = await dao.create_v4_document(
|
|
1261
|
+
**scope, document_id=payload.document_id, title=payload.title
|
|
1262
|
+
)
|
|
1263
|
+
if document["status"] == "PURGED":
|
|
1264
|
+
raise ValueError("document is purged")
|
|
1265
|
+
revisions = await dao.list_v4_revisions(
|
|
1266
|
+
**scope, document_id=payload.document_id
|
|
1267
|
+
)
|
|
1268
|
+
revision = next(
|
|
1269
|
+
(r for r in revisions if r["revision_id"] == revision_id), None
|
|
1270
|
+
)
|
|
1271
|
+
if revision is None:
|
|
1272
|
+
active = next(
|
|
1273
|
+
(r for r in revisions if r["status"] == "ACTIVE"), None
|
|
1274
|
+
)
|
|
1275
|
+
revision = await dao.create_v4_revision(
|
|
1276
|
+
**scope,
|
|
1277
|
+
document_id=payload.document_id,
|
|
1278
|
+
revision_id=revision_id,
|
|
1279
|
+
revision_number=max(
|
|
1280
|
+
(r["revision_number"] for r in revisions), default=0
|
|
1281
|
+
)
|
|
1282
|
+
+ 1,
|
|
1283
|
+
content_hash=content_hash,
|
|
1284
|
+
supersedes_revision_id=active["revision_id"]
|
|
1285
|
+
if active
|
|
1286
|
+
else None,
|
|
1287
|
+
)
|
|
1288
|
+
if revision["status"] not in {"ACTIVE", "PENDING"}:
|
|
1289
|
+
raise ValueError(
|
|
1290
|
+
f"content equals revision {revision['revision_number']} "
|
|
1291
|
+
f"({revision['status']}); restoring an earlier revision "
|
|
1292
|
+
"is not supported in v0.9"
|
|
1293
|
+
)
|
|
1294
|
+
admitted = await dao.list_v4_revision_mutations(
|
|
1295
|
+
**scope, revision_id=revision_id
|
|
1296
|
+
)
|
|
1297
|
+
except ValueError as exc:
|
|
1298
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
1299
|
+
if revision["status"] == "ACTIVE":
|
|
1300
|
+
return {
|
|
1301
|
+
"document_id": payload.document_id,
|
|
1302
|
+
"revision_id": revision_id,
|
|
1303
|
+
"revision_number": revision["revision_number"],
|
|
1304
|
+
"chunk_count": len(admitted),
|
|
1305
|
+
"mutation_ids": [admitted[i] for i in sorted(admitted)],
|
|
1306
|
+
"duplicate": True,
|
|
1307
|
+
}
|
|
1308
|
+
chunks = chunk_document(payload.content)
|
|
1309
|
+
duplicate = len(admitted) == len(chunks)
|
|
1310
|
+
for ordinal, chunk in enumerate(chunks):
|
|
1311
|
+
if ordinal in admitted:
|
|
1312
|
+
continue
|
|
1313
|
+
chunk_id = f"{revision_id}-c{ordinal:05d}"
|
|
1314
|
+
response = await insert_memory(
|
|
1315
|
+
request,
|
|
1316
|
+
V4MemoryInsertRequest(
|
|
1317
|
+
**payload.model_dump(exclude={"content"}),
|
|
1318
|
+
content=chunk,
|
|
1319
|
+
revision_id=revision_id,
|
|
1320
|
+
chunk_id=chunk_id,
|
|
1321
|
+
revision_number=revision["revision_number"],
|
|
1322
|
+
chunk_ordinal=ordinal,
|
|
1323
|
+
supersedes_revision_id=revision["supersedes_revision_id"],
|
|
1324
|
+
finalize_revision=ordinal == len(chunks) - 1,
|
|
1325
|
+
idempotency_key=chunk_id,
|
|
1326
|
+
),
|
|
1327
|
+
dao,
|
|
1328
|
+
access_control,
|
|
1329
|
+
)
|
|
1330
|
+
admitted[ordinal] = response["mutation_id"]
|
|
1331
|
+
return {
|
|
1332
|
+
"document_id": payload.document_id,
|
|
1333
|
+
"revision_id": revision_id,
|
|
1334
|
+
"revision_number": revision["revision_number"],
|
|
1335
|
+
"chunk_count": len(chunks),
|
|
1336
|
+
"mutation_ids": [admitted[i] for i in range(len(chunks))],
|
|
1337
|
+
"duplicate": duplicate,
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
@router.post("/memory/search", response_model=V4SearchResponse)
|
|
1341
|
+
async def search_memory(
|
|
1342
|
+
request: Request,
|
|
1343
|
+
payload: V4SearchRequest,
|
|
1344
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1345
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1346
|
+
) -> dict:
|
|
1347
|
+
session = await _authorized_v4_session(
|
|
1348
|
+
request, dao, access_control, payload.session_id, level="READ"
|
|
1349
|
+
)
|
|
1350
|
+
datasets = payload.dataset_ids or list(session["dataset_ids"])
|
|
1351
|
+
if not set(datasets).issubset(set(session["dataset_ids"])):
|
|
1352
|
+
raise HTTPException(
|
|
1353
|
+
status_code=403, detail="Dataset is outside session scope"
|
|
1354
|
+
)
|
|
1355
|
+
try:
|
|
1356
|
+
certification_metadata: dict[str, Any] = {}
|
|
1357
|
+
principal = _active_principal(request)
|
|
1358
|
+
results = await dao.search_v4_memory(
|
|
1359
|
+
tenant_id=str(session["tenant_id"]),
|
|
1360
|
+
agent_id=str(session["agent_id"]),
|
|
1361
|
+
dataset_ids=datasets,
|
|
1362
|
+
query=payload.query,
|
|
1363
|
+
limit=payload.limit,
|
|
1364
|
+
jurisdiction=payload.jurisdiction,
|
|
1365
|
+
valid_at=payload.valid_at.isoformat() if payload.valid_at else None,
|
|
1366
|
+
valid_from=(
|
|
1367
|
+
payload.valid_from.isoformat() if payload.valid_from else None
|
|
1368
|
+
),
|
|
1369
|
+
valid_to=payload.valid_to.isoformat() if payload.valid_to else None,
|
|
1370
|
+
graph_enabled=payload.graph_mode == "enabled",
|
|
1371
|
+
request_principal_id=str(principal.principal_id),
|
|
1372
|
+
certification_metadata=certification_metadata,
|
|
1373
|
+
)
|
|
1374
|
+
except EmbeddingMigrationRequiredError:
|
|
1375
|
+
raise HTTPException(
|
|
1376
|
+
status_code=409,
|
|
1377
|
+
detail="embedding_migration_required",
|
|
1378
|
+
)
|
|
1379
|
+
except (
|
|
1380
|
+
VectorSearchError,
|
|
1381
|
+
EmbeddingUnavailableError,
|
|
1382
|
+
EmbeddingGenerationError,
|
|
1383
|
+
ExternalProviderForbiddenError,
|
|
1384
|
+
EmbeddingIdentityMismatchError,
|
|
1385
|
+
):
|
|
1386
|
+
raise HTTPException(
|
|
1387
|
+
status_code=503,
|
|
1388
|
+
detail="vector_backend_unavailable",
|
|
1389
|
+
)
|
|
1390
|
+
except GraphSearchError:
|
|
1391
|
+
raise HTTPException(
|
|
1392
|
+
status_code=503,
|
|
1393
|
+
detail="graph_backend_unavailable",
|
|
1394
|
+
)
|
|
1395
|
+
return {
|
|
1396
|
+
"session_id": payload.session_id,
|
|
1397
|
+
"dataset_ids": datasets,
|
|
1398
|
+
"results": results,
|
|
1399
|
+
**certification_metadata,
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
@router.get("/mutations/{mutation_id}", response_model=V4MutationStatusResponse)
|
|
1403
|
+
async def mutation_status(
|
|
1404
|
+
mutation_id: PublicIdentifier,
|
|
1405
|
+
request: Request,
|
|
1406
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1407
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1408
|
+
) -> V4MutationStatusResponse:
|
|
1409
|
+
mutation = await dao.get_mutation_summary(mutation_id)
|
|
1410
|
+
if mutation is None:
|
|
1411
|
+
raise HTTPException(status_code=404, detail="Unknown mutation")
|
|
1412
|
+
await _authorized_v4_session(
|
|
1413
|
+
request,
|
|
1414
|
+
dao,
|
|
1415
|
+
access_control,
|
|
1416
|
+
str(mutation["session_id"]),
|
|
1417
|
+
level="READ",
|
|
1418
|
+
)
|
|
1419
|
+
pipeline = (
|
|
1420
|
+
await dao.get_pipeline_run(str(mutation["pipeline_run_id"]))
|
|
1421
|
+
if mutation.get("pipeline_run_id")
|
|
1422
|
+
else None
|
|
1423
|
+
)
|
|
1424
|
+
return V4MutationStatusResponse(
|
|
1425
|
+
mutation_id=str(mutation["mutation_id"]),
|
|
1426
|
+
candidate_id=str(mutation["candidate_id"]),
|
|
1427
|
+
state=str(mutation["state"]),
|
|
1428
|
+
failure_class=mutation.get("failure_class"),
|
|
1429
|
+
rejection_reason=mutation.get("rejection_reason"),
|
|
1430
|
+
tier3_audit=mutation.get("tier3_audit"),
|
|
1431
|
+
pipeline_run=pipeline,
|
|
1432
|
+
artifacts=mutation["artifacts"],
|
|
1433
|
+
projections=mutation["projections"],
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
@router.post("/mutations/{mutation_id}/rollback", status_code=202)
|
|
1437
|
+
async def rollback_mutation(
|
|
1438
|
+
mutation_id: PublicIdentifier,
|
|
1439
|
+
request: Request,
|
|
1440
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1441
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1442
|
+
) -> dict:
|
|
1443
|
+
mutation = await dao.get_mutation_summary(mutation_id)
|
|
1444
|
+
if mutation is None:
|
|
1445
|
+
raise HTTPException(status_code=404, detail="Unknown mutation")
|
|
1446
|
+
session = await _authorized_v4_session(
|
|
1447
|
+
request,
|
|
1448
|
+
dao,
|
|
1449
|
+
access_control,
|
|
1450
|
+
str(mutation["session_id"]),
|
|
1451
|
+
level="WRITE",
|
|
1452
|
+
allow_closed=True,
|
|
1453
|
+
)
|
|
1454
|
+
_require_historical_mutation_scope(mutation, session)
|
|
1455
|
+
principal = _active_principal(request)
|
|
1456
|
+
if not await access_control.check_dataset_permission(
|
|
1457
|
+
principal.principal_id,
|
|
1458
|
+
tenant_id=str(session["tenant_id"]),
|
|
1459
|
+
dataset_id=str(mutation["dataset_id"]),
|
|
1460
|
+
permission="ROLLBACK",
|
|
1461
|
+
):
|
|
1462
|
+
raise HTTPException(status_code=403, detail="ROLLBACK permission required")
|
|
1463
|
+
await _require_mutation_admission(dao)
|
|
1464
|
+
try:
|
|
1465
|
+
return await dao.request_pipeline_rollback(str(mutation["pipeline_run_id"]))
|
|
1466
|
+
except NonHeadRollbackConflictError as exc:
|
|
1467
|
+
raise HTTPException(
|
|
1468
|
+
status_code=409,
|
|
1469
|
+
detail="NON_HEAD_ROLLBACK_CONFLICT",
|
|
1470
|
+
) from exc
|
|
1471
|
+
except ValueError as exc:
|
|
1472
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
1473
|
+
|
|
1474
|
+
@router.post("/mutations/{mutation_id}/replay", status_code=202)
|
|
1475
|
+
async def replay_mutation(
|
|
1476
|
+
mutation_id: PublicIdentifier,
|
|
1477
|
+
request: Request,
|
|
1478
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1479
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1480
|
+
) -> dict:
|
|
1481
|
+
mutation = await dao.get_mutation_summary(mutation_id)
|
|
1482
|
+
if mutation is None:
|
|
1483
|
+
raise HTTPException(status_code=404, detail="Unknown mutation")
|
|
1484
|
+
session = await _authorized_v4_session(
|
|
1485
|
+
request,
|
|
1486
|
+
dao,
|
|
1487
|
+
access_control,
|
|
1488
|
+
str(mutation["session_id"]),
|
|
1489
|
+
level="WRITE",
|
|
1490
|
+
allow_closed=True,
|
|
1491
|
+
)
|
|
1492
|
+
_require_historical_mutation_scope(mutation, session)
|
|
1493
|
+
principal = _active_principal(request)
|
|
1494
|
+
if not await access_control.check_dataset_permission(
|
|
1495
|
+
principal.principal_id,
|
|
1496
|
+
tenant_id=str(session["tenant_id"]),
|
|
1497
|
+
dataset_id=str(mutation["dataset_id"]),
|
|
1498
|
+
permission="ROLLBACK",
|
|
1499
|
+
):
|
|
1500
|
+
raise HTTPException(status_code=403, detail="ROLLBACK permission required")
|
|
1501
|
+
await _require_mutation_admission(dao)
|
|
1502
|
+
try:
|
|
1503
|
+
return await dao.replay_pipeline_run(
|
|
1504
|
+
str(mutation["pipeline_run_id"]),
|
|
1505
|
+
target_mutation_id=mutation_id,
|
|
1506
|
+
)
|
|
1507
|
+
except NonReplayableMutationConflictError as exc:
|
|
1508
|
+
raise HTTPException(status_code=409, detail="NON_REPLAYABLE") from exc
|
|
1509
|
+
except ValueError as exc:
|
|
1510
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
1511
|
+
|
|
1512
|
+
@router.get("/sessions/{session_id}/context")
|
|
1513
|
+
async def get_context(
|
|
1514
|
+
session_id: PublicIdentifier,
|
|
1515
|
+
request: Request,
|
|
1516
|
+
query: str = "",
|
|
1517
|
+
token_budget: int = 2048,
|
|
1518
|
+
jurisdiction: str | None = None,
|
|
1519
|
+
valid_at: datetime | None = None,
|
|
1520
|
+
valid_from: datetime | None = None,
|
|
1521
|
+
valid_to: datetime | None = None,
|
|
1522
|
+
retrieval_mode: Literal["single", "adaptive"] = "single",
|
|
1523
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1524
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1525
|
+
) -> dict:
|
|
1526
|
+
session = await _authorized_v4_session(
|
|
1527
|
+
request, dao, access_control, session_id, level="READ"
|
|
1528
|
+
)
|
|
1529
|
+
if token_budget <= 0:
|
|
1530
|
+
raise HTTPException(
|
|
1531
|
+
status_code=422, detail="token_budget must be positive"
|
|
1532
|
+
)
|
|
1533
|
+
if valid_from and valid_to and valid_from > valid_to:
|
|
1534
|
+
raise HTTPException(
|
|
1535
|
+
status_code=422, detail="valid_from must not be after valid_to"
|
|
1536
|
+
)
|
|
1537
|
+
agent_id = str(session["agent_id"])
|
|
1538
|
+
adaptive_retriever = None
|
|
1539
|
+
if retrieval_mode == "adaptive":
|
|
1540
|
+
planner = None
|
|
1541
|
+
if is_extraction_enabled():
|
|
1542
|
+
try:
|
|
1543
|
+
planner = BoundedQueryPlanner(AdapterFactory.get_adapter())
|
|
1544
|
+
except Exception as exc:
|
|
1545
|
+
logger.warning(
|
|
1546
|
+
"QUERY_PLANNER_COMPOSITION_UNAVAILABLE | exception_type=%s",
|
|
1547
|
+
type(exc).__name__,
|
|
1548
|
+
)
|
|
1549
|
+
adaptive_retriever = BoundedAdaptiveQueryRetriever(dao, planner)
|
|
1550
|
+
builder = ContextBuilder(dao, adaptive_retriever=adaptive_retriever)
|
|
1551
|
+
try:
|
|
1552
|
+
ctx = await builder.build_context(
|
|
1553
|
+
tenant_id=str(session["tenant_id"]),
|
|
1554
|
+
agent_id=agent_id,
|
|
1555
|
+
dataset_ids=list(session.get("dataset_ids", [])),
|
|
1556
|
+
query=query,
|
|
1557
|
+
session_id=session_id,
|
|
1558
|
+
token_budget=token_budget,
|
|
1559
|
+
jurisdiction=jurisdiction,
|
|
1560
|
+
valid_at=valid_at.isoformat() if valid_at else None,
|
|
1561
|
+
valid_from=valid_from.isoformat() if valid_from else None,
|
|
1562
|
+
valid_to=valid_to.isoformat() if valid_to else None,
|
|
1563
|
+
retrieval_mode=retrieval_mode,
|
|
1564
|
+
)
|
|
1565
|
+
except EmbeddingMigrationRequiredError:
|
|
1566
|
+
raise HTTPException(
|
|
1567
|
+
status_code=409,
|
|
1568
|
+
detail="embedding_migration_required",
|
|
1569
|
+
)
|
|
1570
|
+
except (
|
|
1571
|
+
VectorSearchError,
|
|
1572
|
+
EmbeddingUnavailableError,
|
|
1573
|
+
EmbeddingGenerationError,
|
|
1574
|
+
ExternalProviderForbiddenError,
|
|
1575
|
+
EmbeddingIdentityMismatchError,
|
|
1576
|
+
):
|
|
1577
|
+
raise HTTPException(
|
|
1578
|
+
status_code=503,
|
|
1579
|
+
detail="vector_backend_unavailable",
|
|
1580
|
+
)
|
|
1581
|
+
except GraphSearchError:
|
|
1582
|
+
raise HTTPException(
|
|
1583
|
+
status_code=503,
|
|
1584
|
+
detail="graph_backend_unavailable",
|
|
1585
|
+
)
|
|
1586
|
+
mutations = await dao.list_session_mutation_summaries(
|
|
1587
|
+
agent_id, session_id, limit=20
|
|
1588
|
+
)
|
|
1589
|
+
return {
|
|
1590
|
+
"tenant_id": session["tenant_id"],
|
|
1591
|
+
"workspace_id": session["workspace_id"],
|
|
1592
|
+
"dataset_ids": session["dataset_ids"],
|
|
1593
|
+
"agent_id": agent_id,
|
|
1594
|
+
"session_id": session_id,
|
|
1595
|
+
"context": ctx["formatted_context"],
|
|
1596
|
+
"canonical_memories": ctx["canonical_memories"],
|
|
1597
|
+
"estimated_token_count": ctx["estimated_token_count"],
|
|
1598
|
+
"context_status": ctx["context_status"],
|
|
1599
|
+
"context_diagnostics": ctx["context_diagnostics"],
|
|
1600
|
+
"mutations": mutations,
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
@router.post("/sessions/{session_id}/end")
|
|
1604
|
+
async def end_session(
|
|
1605
|
+
session_id: PublicIdentifier,
|
|
1606
|
+
request: Request,
|
|
1607
|
+
dao: MemoryDAO = Depends(get_dao),
|
|
1608
|
+
access_control: AccessControl = Depends(get_access_control),
|
|
1609
|
+
) -> dict:
|
|
1610
|
+
session = await _authorized_v4_session(
|
|
1611
|
+
request, dao, access_control, session_id, level="WRITE"
|
|
1612
|
+
)
|
|
1613
|
+
await _require_mutation_admission(dao)
|
|
1614
|
+
finalization = await dao.request_session_finalization(
|
|
1615
|
+
str(session["agent_id"]), session_id
|
|
1616
|
+
)
|
|
1617
|
+
await dao.end_v4_session(session_id)
|
|
1618
|
+
return {
|
|
1619
|
+
"status": "ended" if finalization["state"] == "COMPLETED" else "pending",
|
|
1620
|
+
"session_id": session_id,
|
|
1621
|
+
"finalization_id": finalization["finalization_id"],
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
return router
|