feat(observatory): ship modular AI inference labs
This commit is contained in:
+227
-42
@@ -9,6 +9,7 @@ import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -41,6 +42,7 @@ from k1link.web.camera_archive import CameraArchiveWriter
|
||||
from k1link.web.observatory_api import build_observatory_router
|
||||
from k1link.web.session_api import (
|
||||
LayoutPutRequest,
|
||||
RecordedBlueprintLifecycleRequest,
|
||||
RecordedBlueprintRequest,
|
||||
RecordedPerceptionRequest,
|
||||
RecordedPointColorsRequest,
|
||||
@@ -441,20 +443,15 @@ def test_session_router_versions_capability_and_calculation_profile_projections(
|
||||
)["items"]
|
||||
v3_by_id = {item["id"]: item for item in v3_labs}
|
||||
assert v3_by_id[legacy.session_id]["lab"]["calculation_profile"] is None
|
||||
assert (
|
||||
v3_by_id[canonical.session_id]["lab"]["calculation_profile"]
|
||||
== calculation_profile
|
||||
)
|
||||
assert (
|
||||
v3_by_id[canonical.session_id]["lab"]["replay_capability"]
|
||||
== capability.as_dict()
|
||||
)
|
||||
assert v3_by_id[canonical.session_id]["lab"]["calculation_profile"] == calculation_profile
|
||||
assert v3_by_id[canonical.session_id]["lab"]["replay_capability"] == capability.as_dict()
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(router)
|
||||
assert TestClient(application).get(
|
||||
"/api/v1/observation-sessions?lab_contract=v4"
|
||||
).status_code == 422
|
||||
assert (
|
||||
TestClient(application).get("/api/v1/observation-sessions?lab_contract=v4").status_code
|
||||
== 422
|
||||
)
|
||||
|
||||
|
||||
def test_observatory_projection_api_renames_alias_and_deletes_only_projection(
|
||||
@@ -508,28 +505,37 @@ def test_observatory_projection_api_renames_alias_and_deletes_only_projection(
|
||||
client = TestClient(application)
|
||||
url = f"/api/v1/observatory/lab-projections/{projection.session_id}"
|
||||
|
||||
assert client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v0",
|
||||
"display_name": "Неверная версия",
|
||||
},
|
||||
).status_code == 422
|
||||
assert client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||
"display_name": "Новый разбор",
|
||||
"unexpected": True,
|
||||
},
|
||||
).status_code == 422
|
||||
assert client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||
"display_name": " ",
|
||||
},
|
||||
).status_code == 422
|
||||
assert (
|
||||
client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v0",
|
||||
"display_name": "Неверная версия",
|
||||
},
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||
"display_name": "Новый разбор",
|
||||
"unexpected": True,
|
||||
},
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||
"display_name": " ",
|
||||
},
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
|
||||
renamed = client.patch(
|
||||
url,
|
||||
@@ -785,16 +791,16 @@ def test_canonical_lab_spatial_frame_uses_ready_immutable_recording(
|
||||
"GET",
|
||||
)
|
||||
try:
|
||||
response = asyncio.run(spatial_route(
|
||||
session_id=session.name,
|
||||
generation=generation,
|
||||
time_ns=500_000_000,
|
||||
profile="source-paced-ground-v3",
|
||||
))
|
||||
assert json.loads(response.body) == expected
|
||||
assert response.headers["etag"] == (
|
||||
f'"{generation}:source-paced-ground-v3:499000000"'
|
||||
response = asyncio.run(
|
||||
spatial_route(
|
||||
session_id=session.name,
|
||||
generation=generation,
|
||||
time_ns=500_000_000,
|
||||
profile="source-paced-ground-v3",
|
||||
)
|
||||
)
|
||||
assert json.loads(response.body) == expected
|
||||
assert response.headers["etag"] == (f'"{generation}:source-paced-ground-v3:499000000"')
|
||||
assert response.headers["cache-control"].endswith("immutable")
|
||||
finally:
|
||||
manager.close()
|
||||
@@ -1561,6 +1567,89 @@ def test_production_router_never_materializes_recording_inline(tmp_path: Path) -
|
||||
assert calls == 0
|
||||
|
||||
|
||||
def test_blueprint_lifecycle_releases_without_repreparing_source(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[str, tuple[str, str, str]]] = []
|
||||
|
||||
class Sessions:
|
||||
def release(self, key: tuple[str, str, str]) -> None:
|
||||
calls.append(("release", key))
|
||||
|
||||
def renew(self, key: tuple[str, str, str]) -> bool:
|
||||
calls.append(("renew", key))
|
||||
return True
|
||||
|
||||
def forbidden_prepare(*args: object, **kwargs: object) -> None:
|
||||
pytest.fail("lifecycle must not reprepare a recording")
|
||||
|
||||
monkeypatch.setattr("k1link.web.session_api.recorded_blueprint_sessions", Sessions())
|
||||
monkeypatch.setattr("k1link.web.session_api._prepare_replay", forbidden_prepare)
|
||||
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
||||
route = endpoint(
|
||||
build_session_router(store),
|
||||
"/api/v1/observation-sessions/{session_id}/blueprint-lifecycle",
|
||||
"POST",
|
||||
)
|
||||
for action in ("renew", "release"):
|
||||
request = RecordedBlueprintLifecycleRequest.model_validate(
|
||||
{
|
||||
"action": action,
|
||||
"application_id": "nodedc_mission_core_recorded",
|
||||
"recording_id": "recording",
|
||||
"blueprint_session_id": "f" * 32,
|
||||
}
|
||||
)
|
||||
response = asyncio.run(route(session_id="removed-source", request=request))
|
||||
assert response.status_code == 204
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert calls == [
|
||||
(action, ("nodedc_mission_core_recorded", "recording", "f" * 32))
|
||||
for action in ("renew", "release")
|
||||
]
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(route(session_id="../unsafe", request=request))
|
||||
assert error.value.status_code == 422
|
||||
with pytest.raises(ValueError):
|
||||
RecordedBlueprintLifecycleRequest.model_validate({**request.model_dump(), "path": "/tmp"})
|
||||
|
||||
|
||||
def test_released_blueprint_update_returns_gone(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from k1link.viewer.recorded_blueprint_lifecycle import BlueprintSessionReleased
|
||||
|
||||
def released(*args: object, **kwargs: object) -> bytes:
|
||||
raise BlueprintSessionReleased("closed")
|
||||
|
||||
monkeypatch.setattr("k1link.web.session_api._prepare_replay", lambda *args: None)
|
||||
monkeypatch.setattr("k1link.web.session_api.recorded_blueprint_rrd", released)
|
||||
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
||||
route = endpoint(
|
||||
build_session_router(store),
|
||||
"/api/v1/observation-sessions/{session_id}/blueprint.rrd",
|
||||
"POST",
|
||||
)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(
|
||||
route(
|
||||
session_id="source",
|
||||
request=RecordedBlueprintRequest(
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording",
|
||||
blueprint_session_id="f" * 32,
|
||||
accumulation_seconds=0.0,
|
||||
show_points=True,
|
||||
show_trajectory=False,
|
||||
show_grid=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
assert error.value.status_code == 410
|
||||
|
||||
|
||||
def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1607,10 +1696,14 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||
active_view="perception",
|
||||
view_reset_generation=1,
|
||||
unified_perception=True,
|
||||
unified_camera_share=0.73,
|
||||
show_detections_2d=True,
|
||||
show_segmentation=True,
|
||||
show_cuboids_3d=True,
|
||||
follow_trajectory=True,
|
||||
eye_position=(3.0, 4.0, 5.0),
|
||||
eye_look_target=(1.0, 2.0, 0.0),
|
||||
eye_up=(0.0, 0.0, 1.0),
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -1629,10 +1722,14 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||
assert observed_kwargs[0]["active_view"] == "perception"
|
||||
assert observed_kwargs[0]["view_reset_generation"] == 1
|
||||
assert observed_kwargs[0]["unified_perception"] is True
|
||||
assert observed_kwargs[0]["unified_camera_share"] == pytest.approx(0.73)
|
||||
assert observed_kwargs[0]["show_detections_2d"] is True
|
||||
assert observed_kwargs[0]["show_segmentation"] is True
|
||||
assert observed_kwargs[0]["show_cuboids_3d"] is True
|
||||
assert observed_kwargs[0]["follow_trajectory"] is True
|
||||
assert observed_kwargs[0]["eye_position"] == (3.0, 4.0, 5.0)
|
||||
assert observed_kwargs[0]["eye_look_target"] == (1.0, 2.0, 0.0)
|
||||
assert observed_kwargs[0]["eye_up"] == (0.0, 0.0, 1.0)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
RecordedBlueprintRequest.model_validate(
|
||||
@@ -1644,6 +1741,32 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||
"source_url": "https://outside.invalid/recording.rrd",
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
RecordedBlueprintRequest.model_validate(
|
||||
{
|
||||
"application_id": "nodedc_mission_core_recorded",
|
||||
"recording_id": "recording-001",
|
||||
"blueprint_session_id": "a" * 32,
|
||||
"accumulation_seconds": 12.0,
|
||||
"show_points": True,
|
||||
"show_trajectory": True,
|
||||
"show_grid": True,
|
||||
"unified_camera_share": 1.2,
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
RecordedBlueprintRequest.model_validate(
|
||||
{
|
||||
"application_id": "nodedc_mission_core_recorded",
|
||||
"recording_id": "recording-001",
|
||||
"blueprint_session_id": "a" * 32,
|
||||
"accumulation_seconds": 12.0,
|
||||
"show_points": True,
|
||||
"show_trajectory": True,
|
||||
"show_grid": True,
|
||||
"eye_position": [3.0, 4.0, 5.0],
|
||||
}
|
||||
)
|
||||
with pytest.raises(HTTPException) as missing:
|
||||
asyncio.run(
|
||||
blueprint_route(
|
||||
@@ -1662,6 +1785,68 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
|
||||
def test_recorded_blueprint_restores_camera_bounds_after_base_launch_lease_expires(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
recording_path = tmp_path / "published.rrd"
|
||||
calls: list[tuple[Path, dict[str, object]]] = []
|
||||
|
||||
class PublishedMaterializer:
|
||||
def restore_published(self, command: ReplayCommand) -> object:
|
||||
assert command.session_id == session.name
|
||||
return SimpleNamespace(path=recording_path)
|
||||
|
||||
def camera_bounds(path: Path, **kwargs: object) -> float:
|
||||
calls.append((path, kwargs))
|
||||
return 123.5
|
||||
|
||||
monkeypatch.setattr(
|
||||
"k1link.web.session_api.recorded_blueprint_rrd",
|
||||
lambda *_args, **_kwargs: b"RRF2",
|
||||
)
|
||||
monkeypatch.setattr("k1link.web.session_api.recorded_orbital_radius_limit", camera_bounds)
|
||||
route = endpoint(
|
||||
build_session_router(store, recording_materializer=PublishedMaterializer()), # type: ignore[arg-type]
|
||||
"/api/v1/observation-sessions/{session_id}/blueprint.rrd",
|
||||
"POST",
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
route(
|
||||
session_id=session.name,
|
||||
request=RecordedBlueprintRequest(
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-camera-bounds",
|
||||
blueprint_session_id="b" * 32,
|
||||
accumulation_seconds=305.0,
|
||||
show_points=True,
|
||||
show_trajectory=False,
|
||||
show_grid=True,
|
||||
current_time_ns=39_215_000_000,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert response.headers["x-missioncore-camera-max-orbital-radius"] == "123.5"
|
||||
assert calls == [
|
||||
(
|
||||
recording_path,
|
||||
{
|
||||
"current_time_ns": 39_215_000_000,
|
||||
"accumulation_seconds": 305.0,
|
||||
"show_points": True,
|
||||
"show_trajectory": False,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_recorded_perception_endpoint_returns_one_complete_optional_overlay(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user