feat(observatory): ship modular AI inference labs

This commit is contained in:
DCCONSTRUCTIONS
2026-09-04 17:59:05 +03:00
parent eff60e490a
commit cada687173
145 changed files with 17651 additions and 1667 deletions
+108 -64
View File
@@ -42,11 +42,19 @@ from k1link.observatory import (
ObservatoryRunPreparationLedger,
load_observatory_run_preparation_ledger,
)
from k1link.observatory.composition_runs import CompositionRunError, CompositionRunStore
from k1link.observatory.domain_ontology import (
ObservatoryDomainOntology,
ObservatoryOntologyError,
)
from k1link.observatory.lab_view_profiles import LabViewProfileError, LabViewProfileStore
from k1link.observatory.m49_queue_binding import (
M49QueueBindingConfig,
M49QueueBindingError,
M49RecordedQueueBindingService,
)
from k1link.observatory.modular_composition import CompositionError, ModuleRegistry
from k1link.observatory.modular_composition_store import ModularCompositionStore
from k1link.observatory.portable_publication_reconciler import (
PortablePublicationReconciler,
)
@@ -196,6 +204,7 @@ from k1link.web.map_api import (
build_map_router,
)
from k1link.web.map_view_api import build_map_view_router
from k1link.web.modular_observatory_api import build_modular_observatory_router
from k1link.web.observatory_api import build_observatory_router
from k1link.web.observatory_worker_api import (
ObservatoryWorkerAuthentication,
@@ -288,6 +297,37 @@ plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
session_store = SessionStore(REPOSITORY_ROOT)
OBSERVATORY_AI_COMPOSITIONS: ModularCompositionStore | None
OBSERVATORY_AI_COMPOSITION_RUNS: CompositionRunStore | None
OBSERVATORY_DOMAIN_ONTOLOGY: ObservatoryDomainOntology | None
OBSERVATORY_LAB_VIEW_PROFILES: LabViewProfileStore | None
try:
OBSERVATORY_DOMAIN_ONTOLOGY = ObservatoryDomainOntology.from_file(
REPOSITORY_ROOT / "config" / "observatory-domain-ontology.json"
)
OBSERVATORY_AI_COMPOSITIONS = ModularCompositionStore(
session_store.data_dir / "observatory-ai-compositions",
ModuleRegistry.from_file(REPOSITORY_ROOT / "config" / "observatory-ai-modules.json"),
)
OBSERVATORY_AI_COMPOSITION_RUNS = CompositionRunStore(
session_store.data_dir / "observatory-ai-composition-runs"
)
OBSERVATORY_LAB_VIEW_PROFILES = LabViewProfileStore(
session_store.data_dir / "observatory-lab-view-profiles"
)
except (
CompositionError,
CompositionRunError,
LabViewProfileError,
ObservatoryOntologyError,
OSError,
ValueError,
):
# A modular catalog failure cannot disable recordings, existing LABs or Legacy.
OBSERVATORY_AI_COMPOSITIONS = None
OBSERVATORY_AI_COMPOSITION_RUNS = None
OBSERVATORY_DOMAIN_ONTOLOGY = None
OBSERVATORY_LAB_VIEW_PROFILES = None
OBSERVATORY_PORTABLE_DEFINITION_REGISTRY: PortableRunDefinitionRegistry | None
OBSERVATORY_PORTABLE_DEFINITION_REGISTRY_ERROR: str | None
@@ -322,9 +362,7 @@ def _resolve_observatory_calculation_profile(
summary: SessionSummary,
) -> dict[str, object] | None:
if OBSERVATORY_LABORATORY_SETUP_REGISTRY is not None:
legacy = OBSERVATORY_LABORATORY_SETUP_REGISTRY.observatory_calculation_profile(
summary
)
legacy = OBSERVATORY_LABORATORY_SETUP_REGISTRY.observatory_calculation_profile(summary)
if legacy is not None:
return legacy
if (
@@ -437,47 +475,30 @@ try:
or "portable definition registry is unavailable"
)
if OBSERVATORY_PORTABLE_CALCULATION_PROFILES is None:
raise PortableWorkerIntegrationError(
"portable calculation profile registry is unavailable"
)
raise PortableWorkerIntegrationError("portable calculation profile registry is unavailable")
if OBSERVATORY_PORTABLE_RESULT_VALIDATORS is None:
raise PortableWorkerIntegrationError(
"portable result validator registry is unavailable"
)
raise PortableWorkerIntegrationError("portable result validator registry is unavailable")
if OBSERVATORY_RECORDED_JOB_QUEUE is None:
raise PortableWorkerIntegrationError(
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR
or "Observatory recorded-job queue is unavailable"
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR or "Observatory recorded-job queue is unavailable"
)
if session_artifact_gateway is None:
raise PortableWorkerIntegrationError(
"central artifact store is not configured"
)
raise PortableWorkerIntegrationError("central artifact store is not configured")
if session_artifact_gateway.status().central_status != "ready":
raise PortableWorkerIntegrationError(
"central artifact store is unavailable"
)
OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS = (
PortableWorkerStorageRoots.from_environment(
artifact_store_root=session_artifact_gateway.store.root,
)
raise PortableWorkerIntegrationError("central artifact store is unavailable")
OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS = PortableWorkerStorageRoots.from_environment(
artifact_store_root=session_artifact_gateway.store.root,
)
OBSERVATORY_PORTABLE_WORKER_INTEGRATION = (
build_portable_observatory_worker_integration(
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
session_store=session_store,
media_inspector=session_recorded_media_inspector,
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
artifact_store=session_artifact_gateway.store,
calculation_profiles=OBSERVATORY_PORTABLE_CALCULATION_PROFILES,
validators=OBSERVATORY_PORTABLE_RESULT_VALIDATORS,
source_cas_root=(
OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.source_cas_root
),
result_staging_root=(
OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.result_staging_root
),
)
OBSERVATORY_PORTABLE_WORKER_INTEGRATION = build_portable_observatory_worker_integration(
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
session_store=session_store,
media_inspector=session_recorded_media_inspector,
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
artifact_store=session_artifact_gateway.store,
calculation_profiles=OBSERVATORY_PORTABLE_CALCULATION_PROFILES,
validators=OBSERVATORY_PORTABLE_RESULT_VALIDATORS,
source_cas_root=(OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.source_cas_root),
result_staging_root=(OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.result_staging_root),
)
OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR = None
except (PortableWorkerIntegrationError, OSError, ValueError) as exc:
@@ -487,10 +508,7 @@ except (PortableWorkerIntegrationError, OSError, ValueError) as exc:
OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR = str(exc)
OBSERVATORY_PUBLICATION_RECONCILER = (
None
if (
OBSERVATORY_RECORDED_JOB_QUEUE is None
or OBSERVATORY_PORTABLE_WORKER_INTEGRATION is None
)
if (OBSERVATORY_RECORDED_JOB_QUEUE is None or OBSERVATORY_PORTABLE_WORKER_INTEGRATION is None)
else PortablePublicationReconciler(
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
artifact_transport=OBSERVATORY_PORTABLE_WORKER_INTEGRATION.artifact_transport,
@@ -499,12 +517,10 @@ OBSERVATORY_PUBLICATION_RECONCILER = (
)
OBSERVATORY_WORKER_API_GATE_ENABLED = OBSERVATORY_WORKER_LOCAL_ENABLED
OBSERVATORY_WORKER_CLAIM_LEASE_READY = (
OBSERVATORY_WORKER_API_GATE_ENABLED
and OBSERVATORY_RECORDED_JOB_QUEUE is not None
OBSERVATORY_WORKER_API_GATE_ENABLED and OBSERVATORY_RECORDED_JOB_QUEUE is not None
)
OBSERVATORY_WORKER_VERIFIED_RESULT_PUBLISHER_READY = (
OBSERVATORY_WORKER_API_GATE_ENABLED
and OBSERVATORY_PORTABLE_WORKER_INTEGRATION is not None
OBSERVATORY_WORKER_API_GATE_ENABLED and OBSERVATORY_PORTABLE_WORKER_INTEGRATION is not None
)
OBSERVATORY_WORKER_DISPATCH_READY = (
OBSERVATORY_WORKER_CLAIM_LEASE_READY
@@ -527,17 +543,13 @@ else:
)
if OBSERVATORY_WORKER_AUTHENTICATION_ERROR is not None:
worker_api_errors.append(
"authentication unavailable: "
f"{OBSERVATORY_WORKER_AUTHENTICATION_ERROR}"
f"authentication unavailable: {OBSERVATORY_WORKER_AUTHENTICATION_ERROR}"
)
if OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR is not None:
worker_api_errors.append(
"integration unavailable: "
f"{OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR}"
f"integration unavailable: {OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR}"
)
OBSERVATORY_WORKER_API_ERROR = "Worker pull API is disabled; " + "; ".join(
worker_api_errors
)
OBSERVATORY_WORKER_API_ERROR = "Worker pull API is disabled; " + "; ".join(worker_api_errors)
OBSERVATORY_PORTABLE_BINDING_SERVICE: PortableRecordedQueueBindingService | None
OBSERVATORY_PORTABLE_SETUP_PROJECTOR: PortableSetupProjector | None
OBSERVATORY_PORTABLE_SETUP_PROJECTOR_ERROR: str | None
@@ -554,7 +566,8 @@ try:
and OBSERVATORY_PORTABLE_CALCULATION_PROFILES is not None
):
OBSERVATORY_PORTABLE_RESULT_CACHE = PortableResultCache(
sessions=session_store, artifacts=session_artifact_gateway.store,
sessions=session_store,
artifacts=session_artifact_gateway.store,
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
calculation_profiles=OBSERVATORY_PORTABLE_CALCULATION_PROFILES,
@@ -566,7 +579,8 @@ try:
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
published_result_available=(
None if OBSERVATORY_PORTABLE_RESULT_CACHE is None
None
if OBSERVATORY_PORTABLE_RESULT_CACHE is None
else OBSERVATORY_PORTABLE_RESULT_CACHE.available
),
)
@@ -874,10 +888,19 @@ async def _portable_result_publication_reconciler() -> None:
await asyncio.sleep(15.0)
async def _recorded_blueprint_resource_reaper() -> None:
from k1link.viewer.recorded import recorded_blueprint_sessions
while True:
await asyncio.sleep(30.0)
await asyncio.to_thread(recorded_blueprint_sessions.expire)
@asynccontextmanager
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
reconciler: asyncio.Task[None] | None = None
publication_reconciler: asyncio.Task[None] | None = None
blueprint_reaper: asyncio.Task[None] | None = None
try:
configure_scanner_diagnostics(session_store.data_dir / "logs")
session_recording_preparation_manager.start()
@@ -891,11 +914,17 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
# expensive on field captures. Start it immediately in the background
# instead of holding the ASGI startup gate.
reconciler = asyncio.create_task(_recording_preparation_reconciler())
publication_reconciler = asyncio.create_task(
_portable_result_publication_reconciler()
)
publication_reconciler = asyncio.create_task(_portable_result_publication_reconciler())
blueprint_reaper = asyncio.create_task(_recorded_blueprint_resource_reaper())
yield
finally:
from k1link.viewer.recorded import recorded_blueprint_sessions
if blueprint_reaper is not None:
blueprint_reaper.cancel()
with suppress(asyncio.CancelledError):
await blueprint_reaper
await asyncio.to_thread(recorded_blueprint_sessions.close)
await map_gateway_proxy.close()
if reconciler is not None:
reconciler.cancel()
@@ -1066,7 +1095,9 @@ if session_artifact_gateway is not None and _ffmpeg is not None:
media=session_recorded_media_inspector,
recording_source=_canonical_lab_recording_source,
ffmpeg_path=_ffmpeg,
)
),
composition_runs=OBSERVATORY_AI_COMPOSITION_RUNS,
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
)
)
@@ -1128,6 +1159,23 @@ app.include_router(
),
)
)
if (
OBSERVATORY_AI_COMPOSITIONS is not None
and OBSERVATORY_AI_COMPOSITION_RUNS is not None
and OBSERVATORY_DOMAIN_ONTOLOGY is not None
):
app.include_router(
build_modular_observatory_router(
store=session_store,
compositions=OBSERVATORY_AI_COMPOSITIONS,
composition_runs=OBSERVATORY_AI_COMPOSITION_RUNS,
ontology=OBSERVATORY_DOMAIN_ONTOLOGY,
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
binding=OBSERVATORY_PORTABLE_BINDING_SERVICE,
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
view_profiles=OBSERVATORY_LAB_VIEW_PROFILES,
)
)
if OBSERVATORY_WORKER_DISPATCH_READY:
assert OBSERVATORY_RECORDED_JOB_QUEUE is not None
assert OBSERVATORY_WORKER_AUTHENTICATION is not None
@@ -1136,12 +1184,8 @@ if OBSERVATORY_WORKER_DISPATCH_READY:
build_observatory_worker_router(
OBSERVATORY_RECORDED_JOB_QUEUE,
authentication=OBSERVATORY_WORKER_AUTHENTICATION,
artifact_transport=(
OBSERVATORY_PORTABLE_WORKER_INTEGRATION.artifact_transport
),
result_publisher=(
OBSERVATORY_PORTABLE_WORKER_INTEGRATION.result_publisher
),
artifact_transport=(OBSERVATORY_PORTABLE_WORKER_INTEGRATION.artifact_transport),
result_publisher=(OBSERVATORY_PORTABLE_WORKER_INTEGRATION.result_publisher),
)
)
app.include_router(
+469
View File
@@ -0,0 +1,469 @@
from __future__ import annotations
import hashlib
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field
from k1link.observatory.composition_runs import (
CompositionRun,
CompositionRunError,
CompositionRunStore,
)
from k1link.observatory.domain_ontology import ObservatoryDomainOntology, ObservatoryOntologyError
from k1link.observatory.lab_view_profiles import (
PROFILE_SCHEMA as LAB_VIEW_PROFILE_SCHEMA,
)
from k1link.observatory.lab_view_profiles import (
LabSceneProfile,
LabViewProfile,
LabViewProfileError,
LabViewProfileStore,
)
from k1link.observatory.modular_composition import COMPOSITION_SCHEMA, CompositionError
from k1link.observatory.modular_composition_store import ModularCompositionStore
from k1link.observatory.portable_queue_binding import (
PortableQueueBindingError,
PortableRecordedQueueBindingService,
)
from k1link.observatory.portable_run_definitions import PortableRunDefinitionRegistry
from k1link.observatory.recorded_jobs import (
ObservatoryRecordedJobQueue,
ObservatoryRecordedQueueDuplicateError,
ObservatoryRecordedQueueError,
)
from k1link.observatory.source_admission import PortableSourceNotPreparedError
from k1link.sessions import SessionNotFoundError, SessionStore
_EXECUTABLE_SINGLE_MODULE_SETUPS = {
"ddrnet": "ai-segmentation-ddrnet-v1",
"eomt": "ai-segmentation-eomt-v1",
"tgs": "m49-tgs-portable-v2",
"rf-detr": "ai-detection-rf-detr-v1",
"object-distance": "ai-range-object-distance-v1",
}
def _composition_error_detail(error: CompositionError) -> str:
detail = str(error)
if detail.startswith("unsupported value for "):
return (
"Параметры выбранного AI-модуля устарели. "
"Закройте окно, откройте его снова и повторите расчёт."
)
if detail == "module version is not installed":
return (
"Версия выбранного AI-модуля обновилась. "
"Закройте окно, откройте его снова и повторите расчёт."
)
if detail.startswith("select only one provider for "):
return "В одном слое можно выбрать только один AI-модуль."
if (
detail.startswith("select a module providing ")
or detail.startswith("ambiguous provider for ")
or detail == "unresolved module dependencies"
or detail == "cyclic module dependencies"
):
return "Для выбранной конфигурации не хватает обязательного связанного модуля."
if detail == "select at least one AI module":
return "Выберите хотя бы один AI-модуль."
return "Конфигурацию AI-слоя не удалось проверить. Обновите окно и повторите выбор."
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid")
class AICompositionRequest(_Strict):
schema_version: str
source_session_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
selections: list[dict[str, Any]] = Field(min_length=1, max_length=6)
idempotency_key: str = Field(
min_length=1,
max_length=160,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$",
)
class AICompositionRunRenameRequest(_Strict):
schema_version: str
display_name: str = Field(min_length=1, max_length=160)
class LabSceneProfileRequest(_Strict):
point_size: float = Field(ge=0.1, allow_inf_nan=False)
accumulation_seconds: float = Field(ge=0, allow_inf_nan=False)
color_mode: str = Field(pattern=r"^(intensity|height|distance|rgb|class)$")
palette: str = Field(pattern=r"^(turbo|viridis|plasma|grayscale)$")
show_grid: bool
show_labels: bool
show_camera_frustums: bool
class LabViewProfileRequest(_Strict):
schema_version: str
result_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
scene_settings: LabSceneProfileRequest
def build_modular_observatory_router(
*,
store: SessionStore,
compositions: ModularCompositionStore,
composition_runs: CompositionRunStore | None = None,
ontology: ObservatoryDomainOntology | None = None,
definitions: PortableRunDefinitionRegistry | None = None,
binding: PortableRecordedQueueBindingService | None = None,
queue: ObservatoryRecordedJobQueue | None = None,
view_profiles: LabViewProfileStore | None = None,
) -> APIRouter:
router = APIRouter()
@router.get("/api/v1/observatory/ai-module-catalog")
def catalog() -> dict[str, object]:
return {
**compositions.registry.catalog(),
"authority": {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
},
}
@router.get("/api/v1/observatory/lab-view-profiles/{result_id}")
def get_lab_view_profile(result_id: str) -> dict[str, object]:
if view_profiles is None:
raise HTTPException(503, "Профили отображения LAB недоступны.")
try:
profile = view_profiles.get(result_id)
except LabViewProfileError as exc:
raise HTTPException(422, "Некорректный профиль отображения LAB.") from exc
if profile is None:
raise HTTPException(404, "Профиль отображения LAB ещё не сохранён.")
return profile.as_dict()
@router.put("/api/v1/observatory/lab-view-profiles/{result_id}")
def put_lab_view_profile(result_id: str, request: LabViewProfileRequest) -> dict[str, object]:
if view_profiles is None:
raise HTTPException(503, "Профили отображения LAB недоступны.")
if request.schema_version != LAB_VIEW_PROFILE_SCHEMA or request.result_id != result_id:
raise HTTPException(422, "Профиль отображения относится к другой LAB.")
try:
settings = request.scene_settings
profile = LabViewProfile(
result_id=result_id,
scene_settings=LabSceneProfile(**settings.model_dump()),
updated_at_utc=datetime.now(UTC)
.isoformat(timespec="milliseconds")
.replace("+00:00", "Z"),
)
return view_profiles.save(profile).as_dict()
except LabViewProfileError as exc:
raise HTTPException(422, "Некорректные настройки отображения LAB.") from exc
@router.post("/api/v1/observatory/ai-compositions")
def save(request: AICompositionRequest) -> dict[str, object]:
if request.schema_version != COMPOSITION_SCHEMA:
raise HTTPException(
status_code=422,
detail="Версия конфигурации AI-слоя не поддерживается.",
)
try:
source = store.get_session(request.source_session_id).summary
except SessionNotFoundError as exc:
raise HTTPException(status_code=404, detail="Запись Обсерватории не найдена.") from exc
if source.lab is not None:
raise HTTPException(
status_code=409,
detail="AI-слой настраивается для исходной записи.",
)
selection = {"schema_version": request.schema_version, "selections": request.selections}
try:
composition, created = compositions.save(selection)
except CompositionError as exc:
raise HTTPException(status_code=409, detail=_composition_error_detail(exc)) from exc
selected_modules = tuple(
node.module.module_id
for node in composition.nodes
if node.module.group != "preparation"
)
selected = set(selected_modules)
for previous in (
()
if composition_runs is None
else composition_runs.list(source_session_id=source.session_id)
):
if previous.composition_sha256 != composition.sha256:
continue
try:
previous_jobs = (
tuple(queue.get(job_id) for job_id in previous.job_ids) if queue else ()
)
except (ObservatoryRecordedQueueError, ValueError):
previous_jobs = ()
if previous_jobs and all(
job.state != "failed" and job.publication_state != "failed" for job in previous_jobs
):
raise HTTPException(
status_code=409,
detail=(
"Эта конфигурация уже рассчитана или поставлена в очередь. "
"Выберите другую конфигурацию."
),
)
setup_ids: list[str] = []
for module_id in ("ddrnet", "eomt", "tgs"):
if module_id in selected:
setup_ids.append(_EXECUTABLE_SINGLE_MODULE_SETUPS[module_id])
if "object-distance" in selected:
setup_ids.append(_EXECUTABLE_SINGLE_MODULE_SETUPS["object-distance"])
elif "rf-detr" in selected:
setup_ids.append(_EXECUTABLE_SINGLE_MODULE_SETUPS["rf-detr"])
jobs = []
reason = "Для этой композиции ещё не установлен исполняемый пакет Worker 006."
if setup_ids and definitions is not None and binding is not None:
try:
checked = []
existing_by_setup = {}
for setup_id in setup_ids:
definition = definitions.resolve_setup(setup_id)
existing = None
if queue is not None:
candidates = queue.list_jobs(
source_session_id=source.session_id,
setup_id=setup_id,
definition_sha256=definition.definition_sha256,
limit=20,
)
existing = next(
(job for job in candidates if job.state != "failed"),
None,
)
if existing is not None:
existing_by_setup[setup_id] = existing
continue
try:
check = binding.check(
source_session_id=source.session_id,
setup_id=setup_id,
definition_sha256=definition.definition_sha256,
)
except PortableSourceNotPreparedError:
check = binding.prepare_check(
source_session_id=source.session_id,
setup_id=setup_id,
definition_sha256=definition.definition_sha256,
)
checked.append((setup_id, definition, check))
for setup_id, definition, check in checked:
key = hashlib.sha256(
f"{request.idempotency_key}\0{setup_id}".encode()
).hexdigest()
job, _created = binding.submit(
source_session_id=source.session_id,
setup_id=setup_id,
definition_sha256=definition.definition_sha256,
expected_check_sha256=check.check_sha256,
idempotency_key=f"ai-layer:{key}",
)
existing_by_setup[setup_id] = job
jobs = [existing_by_setup[setup_id] for setup_id in setup_ids]
if composition_runs is None and not checked:
raise ObservatoryRecordedQueueDuplicateError("existing-composition")
reason = (
"Недостающие модули поставлены в очередь Worker 006; "
"готовые результаты использованы повторно."
if len(checked) < len(setup_ids)
else "Композиция поставлена в очередь Worker 006."
)
except ObservatoryRecordedQueueDuplicateError as exc:
raise HTTPException(
status_code=409,
detail=(
"Эта конфигурация уже рассчитана или поставлена в очередь. "
"Выберите другую конфигурацию."
),
) from exc
except (PortableQueueBindingError, ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(
status_code=409,
detail="Композицию не удалось поставить в очередь Worker 006.",
) from exc
if not jobs and definitions is not None and binding is not None:
raise HTTPException(
status_code=409,
detail="Для этой конфигурации пока нет исполняемых модулей Worker 006.",
)
run_projection: dict[str, object] | None = None
try:
if composition_runs is None or ontology is None or not jobs:
raise StopIteration
run = composition_runs.save(
source_session_id=source.session_id,
composition=composition,
setup_ids=tuple(setup_ids),
job_ids=tuple(job.job_id for job in jobs),
idempotency_key=request.idempotency_key,
created_at_utc=datetime.now(UTC)
.isoformat(timespec="milliseconds")
.replace("+00:00", "Z"),
)
presentation = ontology.project_composition(composition)
run_projection = {**run.as_dict(), "presentation": presentation}
except StopIteration:
pass
except (CompositionRunError, ObservatoryOntologyError) as exc:
raise HTTPException(
status_code=409,
detail="Связь композиции с результатами не удалось сохранить.",
) from exc
return {
"schema_version": (
"missioncore.observatory-ai-composition-receipt/v3"
if run_projection is not None
else "missioncore.observatory-ai-composition-receipt/v2"
),
"source_session_id": source.session_id,
"composition": composition.as_dict(),
"composition_sha256": composition.sha256,
"created": created,
**({"run": run_projection} if run_projection is not None else {}),
"dispatch": {
"ready": len(jobs) == len(setup_ids) and bool(jobs),
"reason": reason,
"setup_ids": setup_ids,
"jobs": [job.as_dict() for job in jobs],
},
}
def project_run(run: CompositionRun) -> dict[str, object]:
if ontology is None:
raise CompositionRunError("composition ontology is unavailable")
try:
exact = composition_runs.get(run.run_id)
jobs = [queue.get(job_id) for job_id in exact.job_ids] if queue else []
except (CompositionRunError, ObservatoryRecordedQueueError, ValueError):
raise
# The immutable composition document is the authority for projection;
# reconstruct the selected module projection from the run's sealed IDs.
presentation = ontology.project_module_ids(exact.module_ids)
published = bool(jobs) and all(
job.state == "succeeded" and job.publication_state == "published" and job.result_id
for job in jobs
)
failed = any(job.state == "failed" or job.publication_state == "failed" for job in jobs)
return {
**exact.as_dict(),
"state": "ready" if published else "failed" if failed else "running",
"configuration_label": (
f"{store.get_session(exact.source_session_id).summary.display_name} · "
f"{presentation['configuration_label']}"
),
"display_name": composition_runs.display_name(exact.run_id),
"presentation": presentation,
"jobs": [job.as_dict() for job in jobs],
"result_ids": [job.result_id for job in jobs if job.result_id is not None],
}
@router.get("/api/v1/observatory/ai-composition-runs")
def composition_run_list(
source_session_id: str = Query(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
),
) -> dict[str, object]:
try:
items = (
[]
if composition_runs is None
else [
project_run(run)
for run in composition_runs.list(
source_session_id=source_session_id,
include_hidden=False,
)
]
)
except (
CompositionRunError,
ObservatoryRecordedQueueError,
ObservatoryOntologyError,
ValueError,
) as exc:
raise HTTPException(503, "Композиции AI-слоя недоступны.") from exc
return {
"schema_version": "missioncore.observatory-ai-composition-run-list/v1",
"items": items,
}
@router.patch("/api/v1/observatory/ai-composition-runs/{run_id}")
def rename_composition_run_projection(
run_id: str,
request: AICompositionRunRenameRequest,
) -> dict[str, object]:
if request.schema_version != "missioncore.observatory-ai-composition-run-rename/v1":
raise HTTPException(422, "Версия переименования результата не поддерживается.")
if composition_runs is None:
raise HTTPException(503, "Композиции AI-слоя недоступны.")
try:
display_name = composition_runs.rename_projection(run_id, request.display_name)
except CompositionRunError as exc:
raise HTTPException(404, "Результат AI inference не найден.") from exc
return {
"schema_version": "missioncore.observatory-ai-composition-run-projection/v1",
"run_id": run_id,
"display_name": display_name,
}
@router.delete(
"/api/v1/observatory/ai-composition-runs/{run_id}",
status_code=204,
)
def delete_composition_run_projection(run_id: str) -> Response:
if composition_runs is None:
raise HTTPException(503, "Композиции AI-слоя недоступны.")
try:
composition_runs.delete_projection(run_id)
except CompositionRunError as exc:
raise HTTPException(404, "Результат AI inference не найден.") from exc
return Response(status_code=204)
@router.get("/api/v1/observatory/ai-runs")
def runs(
source_session_id: str = Query(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
),
) -> dict[str, object]:
if queue is None:
raise HTTPException(status_code=503, detail="Очередь AI-слоёв недоступна.")
try:
jobs = [
job
for setup_id in _EXECUTABLE_SINGLE_MODULE_SETUPS.values()
for job in queue.list_jobs(
source_session_id=source_session_id,
setup_id=setup_id,
limit=20,
)
]
except (ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(status_code=503, detail="Очередь AI-слоёв недоступна.") from exc
jobs.sort(key=lambda job: job.created_at_utc, reverse=True)
return {
"schema_version": "missioncore.observatory-recorded-job-list/v1",
"items": [job.as_dict() for job in jobs[:20]],
"authority": {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
},
}
return router
+101 -2
View File
@@ -9,15 +9,33 @@ from fastapi import APIRouter, HTTPException, Path, Query, Response
from fastapi.responses import FileResponse
from k1link.laboratory.canonical_rerun_overlay import CanonicalLabOverlayError
from k1link.observatory.composition_runs import CompositionRunError, CompositionRunStore
from k1link.observatory.portable_replay import PortableReplayService
from k1link.observatory.portable_result_view import PortableResultViewError
from k1link.observatory.recorded_jobs import (
ObservatoryRecordedJobQueue,
ObservatoryRecordedQueueError,
)
ResultId = Annotated[str, Path(pattern=r"^m49-tgs-portable-review-[a-f0-9]{64}$")]
ResultId = Annotated[
str,
Path(
pattern=(
r"^(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|"
r"ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}$"
)
),
]
BaseSha = Annotated[str, Path(pattern=r"^[a-f0-9]{64}$")]
_LOG = logging.getLogger(__name__)
def build_portable_replay_router(service: PortableReplayService) -> APIRouter:
def build_portable_replay_router(
service: PortableReplayService,
*,
composition_runs: CompositionRunStore | None = None,
queue: ObservatoryRecordedJobQueue | None = None,
) -> APIRouter:
router = APIRouter(tags=["observatory"])
path = "/api/v1/observatory/portable-results/{result_id}/replays/{base_sha}/recording.rrd"
@@ -72,4 +90,85 @@ def build_portable_replay_router(service: PortableReplayService) -> APIRouter:
},
)
composition_path = (
"/api/v1/observatory/ai-composition-runs/{run_id}/replays/{base_sha}/recording.rrd"
)
def composition_members(run_id: str) -> tuple[str, ...]:
if composition_runs is None or queue is None:
raise HTTPException(503, "Составной просмотр AI-слоя недоступен.")
try:
run = composition_runs.get(run_id)
jobs = tuple(queue.get(job_id) for job_id in run.job_ids)
except (CompositionRunError, ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(409, "Составной запуск AI-слоя недоступен.") from exc
if not jobs or any(
job.state != "succeeded"
or job.publication_state != "published"
or job.result_id is None
for job in jobs
):
raise HTTPException(409, "Расчёт всех модулей этой конфигурации ещё не завершён.")
return tuple(job.result_id for job in jobs if job.result_id is not None)
@router.head(composition_path)
def prepare_composition(
run_id: Annotated[str, Path(pattern=r"^ai-composition-[a-f0-9]{64}$")],
base_sha: BaseSha,
) -> Response:
try:
artifact = service.prepare_composition(run_id, composition_members(run_id), base_sha)
except (
ValueError,
OSError,
KeyError,
TypeError,
CanonicalLabOverlayError,
PortableResultViewError,
) as exc:
_LOG.exception("Composition replay packaging rejected")
raise HTTPException(
409, "Составной результат не удалось подготовить к просмотру."
) from exc
return Response(
media_type="application/vnd.rerun.rrd",
headers={
"Content-Length": str(artifact.byte_length),
"ETag": f'"{artifact.sha256}"',
"X-Rerun-Format": "RRF2",
"Cache-Control": "private, no-store",
},
)
@router.get(composition_path)
def read_composition(
run_id: Annotated[str, Path(pattern=r"^ai-composition-[a-f0-9]{64}$")],
base_sha: BaseSha,
generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
) -> FileResponse:
try:
artifact = service.cached_composition(run_id, composition_members(run_id), base_sha)
except (
ValueError,
OSError,
KeyError,
TypeError,
PortableResultViewError,
) as exc:
raise HTTPException(409, "Кэш составного результата не прошёл проверку.") from exc
if artifact is None:
raise HTTPException(409, "Составной просмотр ещё не подготовлен.")
if artifact.sha256 != generation:
raise HTTPException(412, "Версия составного просмотра изменилась.")
return FileResponse(
artifact.path,
media_type="application/vnd.rerun.rrd",
headers={
"ETag": f'"{artifact.sha256}"',
"X-Rerun-Format": "RRF2",
"Cache-Control": "private, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
)
return router
+117 -13
View File
@@ -49,7 +49,10 @@ from k1link.viewer.recorded import (
from k1link.viewer.recorded import (
RecordedBlueprintError,
recorded_blueprint_rrd,
recorded_blueprint_sessions,
)
from k1link.viewer.recorded_blueprint_lifecycle import BlueprintSessionReleased
from k1link.viewer.recorded_camera_bounds import recorded_orbital_radius_limit
from k1link.viewer.rerun_bridge import RerunSceneSettings
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
@@ -102,7 +105,7 @@ class ReplayRequest(StrictApiModel):
loop: bool = False
class RecordedBlueprintRequest(StrictApiModel):
class RecordedBlueprintIdentity(StrictApiModel):
application_id: Literal["nodedc_mission_core_recorded"]
recording_id: str = Field(
min_length=1,
@@ -114,25 +117,58 @@ class RecordedBlueprintRequest(StrictApiModel):
max_length=32,
pattern=r"^[a-f0-9]{32}$",
)
accumulation_seconds: float = Field(strict=True, ge=0.0, le=3600.0)
class RecordedBlueprintLifecycleRequest(RecordedBlueprintIdentity):
action: Literal["renew", "release"]
EyeCoordinate = Annotated[float, Field(strict=True, allow_inf_nan=False)]
EyeVector = tuple[EyeCoordinate, EyeCoordinate, EyeCoordinate]
class RecordedBlueprintRequest(RecordedBlueprintIdentity):
accumulation_seconds: float = Field(strict=True, ge=0.0, allow_inf_nan=False)
show_points: StrictBool
show_trajectory: StrictBool
show_grid: StrictBool
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
point_size: float = Field(default=2.5, strict=True, ge=0.1, allow_inf_nan=False)
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
view_reset_generation: Literal[0, 1] = 0
unified_perception: StrictBool = False
unified_camera_share: float = Field(strict=True, ge=0.1, le=0.9, default=0.46)
semantic_layer: Literal["city", "vegetation"] | None = None
plan_view: StrictBool = False
show_detections_2d: StrictBool = False
show_camera_image: StrictBool = True
show_segmentation: StrictBool = False
show_cuboids_3d: StrictBool = False
show_costmap: StrictBool = False
reactivate_updates: StrictBool = False
follow_trajectory: StrictBool = False
eye_position: EyeVector | None = None
eye_look_target: EyeVector | None = None
eye_up: EyeVector | None = None
current_time_ns: int | None = Field(default=None, strict=True, ge=0, le=MAX_SAFE_INTEGER)
@model_validator(mode="after")
def validate_eye_vectors(self) -> RecordedBlueprintRequest:
vectors = (self.eye_position, self.eye_look_target, self.eye_up)
if any(vector is None for vector in vectors):
if not all(vector is None for vector in vectors):
raise ValueError("all eye vectors must be supplied together")
return self
assert self.eye_position is not None
assert self.eye_look_target is not None
assert self.eye_up is not None
if self.eye_position == self.eye_look_target:
raise ValueError("eye position and look target must differ")
if sum(value * value for value in self.eye_up) <= 1.0e-12:
raise ValueError("eye up vector must be non-zero")
return self
class RecordedPerceptionRequest(StrictApiModel):
@@ -365,6 +401,7 @@ def build_session_router(
cursor: str | None = Query(default=None, max_length=128),
scope: Literal["all", "source", "laboratory"] = "all",
lab_contract: Literal["v1", "v2", "v3"] = "v1",
pagination: Literal["cursor-v1"] | None = None,
) -> dict[str, Any]:
try:
_refresh_catalog(catalog_refresher)
@@ -375,6 +412,14 @@ def build_session_router(
include_capability_projections=lab_contract in ("v2", "v3"),
)
return {
**(
{
"schema_version": "missioncore.observation-session-page/v1",
"next_cursor": page.next_cursor,
}
if pagination == "cursor-v1"
else {}
),
"items": [
{
"id": item.session_id,
@@ -391,9 +436,7 @@ def build_session_router(
else item.capture_attestation.as_dict()
),
**(
{
"lab": lab_catalog_document(item, lab_contract)
}
{"lab": lab_catalog_document(item, lab_contract)}
if item.lab is not None
else {}
),
@@ -411,7 +454,7 @@ def build_session_router(
}
for item in page.items
if item.started_at_utc is not None
]
],
}
except SessionNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -870,9 +913,7 @@ def build_session_router(
**response_kwargs,
)
@router.get(
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame"
)
@router.get("/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame")
async def get_observation_session_canonical_lab_spatial_frame(
session_id: str,
generation: Annotated[str, Query(min_length=64, max_length=64)],
@@ -937,20 +978,37 @@ def build_session_router(
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"ETag": (
f'"{generation}:{CANONICAL_LAB_SPATIAL_PROFILE}:'
f'{payload["source_time_ns"]}"'
f'"{generation}:{CANONICAL_LAB_SPATIAL_PROFILE}:{payload["source_time_ns"]}"'
),
"X-Content-Type-Options": "nosniff",
},
)
@router.post("/api/v1/observation-sessions/{session_id}/blueprint-lifecycle")
async def update_recorded_blueprint_lifecycle(
session_id: str,
request: RecordedBlueprintLifecycleRequest,
) -> Response:
# Releasing an ephemeral owner must still work after source removal.
# The random viewport identity authorizes only its own memory resource;
# this route never materializes or deletes recordings/artifacts.
if not SAFE_SOURCE_ID.fullmatch(session_id):
raise HTTPException(status_code=422, detail="Некорректный идентификатор сессии.")
key = (request.application_id, request.recording_id, request.blueprint_session_id)
if request.action == "release":
await run_in_threadpool(recorded_blueprint_sessions.release, key)
else:
await run_in_threadpool(recorded_blueprint_sessions.renew, key)
return Response(status_code=204, headers={"Cache-Control": "no-store"})
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
async def get_observation_session_blueprint(
session_id: str,
request: RecordedBlueprintRequest,
) -> Response:
camera_max_orbital_radius: float | None = None
try:
await run_in_threadpool(
command = await run_in_threadpool(
_prepare_replay,
store,
catalog_refresher,
@@ -977,19 +1035,55 @@ def build_session_router(
active_view=request.active_view,
view_reset_generation=request.view_reset_generation,
unified_perception=request.unified_perception,
unified_camera_share=request.unified_camera_share,
semantic_layer=request.semantic_layer,
plan_view=request.plan_view,
show_detections_2d=request.show_detections_2d,
show_camera_image=request.show_camera_image,
show_segmentation=request.show_segmentation,
show_cuboids_3d=request.show_cuboids_3d,
show_costmap=request.show_costmap,
reactivate_updates=request.reactivate_updates,
follow_trajectory=request.follow_trajectory,
eye_position=request.eye_position,
eye_look_target=request.eye_look_target,
eye_up=request.eye_up,
)
if request.current_time_ns is not None:
camera_recording = None
if recording_preparation_manager is not None:
snapshot = recording_preparation_manager.status(session_id)
if (
snapshot is not None
and snapshot.state == "ready"
and snapshot.recording is not None
):
camera_recording = snapshot.recording
# A composition replay consumes the immutable base launch but
# does not GET its recording. Its short launch reservation can
# therefore expire while the combined RRD remains open. Restore
# the already-published base descriptor with bounded stat checks
# so later layer toggles still receive the native zoom limit.
if camera_recording is None and recording_materializer is not None:
camera_recording = await run_in_threadpool(
recording_materializer.restore_published,
command,
)
if camera_recording is not None:
camera_max_orbital_radius = await run_in_threadpool(
recorded_orbital_radius_limit,
camera_recording.path,
current_time_ns=request.current_time_ns,
accumulation_seconds=request.accumulation_seconds,
show_points=request.show_points,
show_trajectory=request.show_trajectory,
)
except SessionNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (SessionNotReplayableError, SessionIntegrityError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except BlueprintSessionReleased as exc:
raise HTTPException(status_code=410, detail="Сессия визуализатора закрыта.") from exc
except RecordedBlueprintError as exc:
raise HTTPException(
status_code=500,
@@ -1007,6 +1101,16 @@ def build_session_router(
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Content-Disposition": 'inline; filename="blueprint.rrd"',
**(
{
"X-MissionCore-Camera-Max-Orbital-Radius": format(
camera_max_orbital_radius,
".9g",
)
}
if camera_max_orbital_radius is not None
else {}
),
},
)