feat(polygon): qualify GOOSE ground providers
This commit is contained in:
@@ -44,7 +44,7 @@ from k1link.web.plugin_runtime import (
|
||||
PluginNotFoundError,
|
||||
PluginRuntimeUnavailableError,
|
||||
)
|
||||
from k1link.web.polygon_api import build_polygon_router
|
||||
from k1link.web.polygon_api import build_polygon_router, configured_polygon_runs_root
|
||||
from k1link.web.session_api import build_session_router
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
@@ -398,7 +398,12 @@ app.include_router(
|
||||
point_color_renderers=plugin_environment.point_color_renderers,
|
||||
)
|
||||
)
|
||||
app.include_router(build_polygon_router())
|
||||
app.include_router(
|
||||
build_polygon_router(
|
||||
root_provider=lambda: configured_polygon_runs_root()
|
||||
or REPOSITORY_ROOT / ".runtime" / "polygon-runs"
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_lidar_router(
|
||||
root_provider=lambda: (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -33,6 +35,13 @@ POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL"
|
||||
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
|
||||
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
|
||||
DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1"
|
||||
GROUND_QUALIFICATION_SCHEMA: Final = "missioncore.polygon-ground-qualification/v1"
|
||||
GROUND_FAILURE_PREVIEW_SCHEMA: Final = "missioncore.polygon-ground-failure-preview/v1"
|
||||
GROUND_REPORT_ARTIFACT_KIND: Final = "goose-ground-qualification-report"
|
||||
GROUND_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview"
|
||||
GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1"
|
||||
GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/v1"
|
||||
MAX_QUALIFICATION_ARTIFACT_BYTES: Final = 32 * 1024**2
|
||||
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
|
||||
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
|
||||
"Архивный UI-0 публикует только квалификационные доказательства; "
|
||||
@@ -165,6 +174,90 @@ def build_polygon_router(
|
||||
"limitations": list(READ_ONLY_LIMITATIONS),
|
||||
}
|
||||
|
||||
@router.get("/runs/{run_id}/qualification")
|
||||
def get_polygon_ground_qualification(
|
||||
run_id: Annotated[
|
||||
str,
|
||||
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
store, run = _load_run(root_provider, run_id)
|
||||
report = _read_registered_json(
|
||||
store,
|
||||
run,
|
||||
artifact_kind=GROUND_REPORT_ARTIFACT_KIND,
|
||||
expected_schema=GROUND_REPORT_SCHEMA,
|
||||
)
|
||||
required = (
|
||||
"identity_sha256",
|
||||
"source_id",
|
||||
"split",
|
||||
"frame_count",
|
||||
"aggregates",
|
||||
"degradations",
|
||||
"checks",
|
||||
"worst_frames",
|
||||
"decision",
|
||||
"safety",
|
||||
)
|
||||
if any(key not in report for key in required):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Отчёт квалификации имеет неполный контракт.",
|
||||
)
|
||||
return {
|
||||
"schema_version": GROUND_QUALIFICATION_SCHEMA,
|
||||
"access": "read-only",
|
||||
"run_id": run.run_id,
|
||||
**{key: report[key] for key in required},
|
||||
}
|
||||
|
||||
@router.get("/runs/{run_id}/qualification/failures/{frame_id}")
|
||||
def get_polygon_ground_failure_preview(
|
||||
run_id: Annotated[
|
||||
str,
|
||||
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
||||
],
|
||||
frame_id: Annotated[
|
||||
str,
|
||||
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
store, run = _load_run(root_provider, run_id)
|
||||
preview = _read_registered_json(
|
||||
store,
|
||||
run,
|
||||
artifact_kind=GROUND_FAILURE_ARTIFACT_KIND,
|
||||
expected_schema=GROUND_FAILURE_SCHEMA,
|
||||
frame_id=frame_id,
|
||||
)
|
||||
required = (
|
||||
"source_id",
|
||||
"frame_id",
|
||||
"source_point_count",
|
||||
"point_count",
|
||||
"sampling",
|
||||
"points_xyz_m",
|
||||
"ground_truth_ground",
|
||||
"evaluated",
|
||||
"current_ground",
|
||||
"patchwork_ground",
|
||||
"current_disagreement",
|
||||
"patchwork_disagreement",
|
||||
"safety",
|
||||
)
|
||||
if any(key not in preview for key in required) or preview["frame_id"] != frame_id:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Preview проблемного кадра имеет неполный контракт.",
|
||||
)
|
||||
return {
|
||||
"schema_version": GROUND_FAILURE_PREVIEW_SCHEMA,
|
||||
"access": "read-only",
|
||||
"run_id": run.run_id,
|
||||
**{key: preview[key] for key in required},
|
||||
}
|
||||
|
||||
@router.get("/worker")
|
||||
def get_polygon_worker() -> dict[str, Any]:
|
||||
gateway = worker_provider()
|
||||
@@ -315,6 +408,83 @@ def _open_read_only_store(root_provider: RootProvider) -> QualificationRunStore:
|
||||
) from exc
|
||||
|
||||
|
||||
def _load_run(
|
||||
root_provider: RootProvider,
|
||||
run_id: str,
|
||||
) -> tuple[QualificationRunStore, QualificationRun]:
|
||||
store = _open_read_only_store(root_provider)
|
||||
try:
|
||||
return store, store.load(run_id)
|
||||
except QualificationRunNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Прогон Полигона не найден.") from exc
|
||||
except QualificationRunIntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Доказательства прогона не прошли проверку целостности.",
|
||||
) from exc
|
||||
|
||||
|
||||
def _read_registered_json(
|
||||
store: QualificationRunStore,
|
||||
run: QualificationRun,
|
||||
*,
|
||||
artifact_kind: str,
|
||||
expected_schema: str,
|
||||
frame_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
candidates = [artifact for artifact in run.artifacts if artifact.kind == artifact_kind]
|
||||
if frame_id is not None:
|
||||
candidates = [
|
||||
artifact for artifact in candidates if Path(artifact.relative_path).stem == frame_id
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Квалификационный артефакт для этого прогона не найден.",
|
||||
)
|
||||
artifact = candidates[0]
|
||||
if artifact.byte_length > MAX_QUALIFICATION_ARTIFACT_BYTES:
|
||||
raise HTTPException(status_code=500, detail="Квалификационный артефакт слишком велик.")
|
||||
run_root = (store.root / run.run_id).resolve()
|
||||
path = run_root / artifact.relative_path
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
resolved.relative_to(run_root)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Путь квалификационного артефакта нарушает границу прогона.",
|
||||
) from exc
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not resolved.is_file()
|
||||
or resolved.stat().st_size != artifact.byte_length
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Квалификационный артефакт не прошёл проверку файла.",
|
||||
)
|
||||
digest = hashlib.sha256(resolved.read_bytes()).hexdigest()
|
||||
if digest != artifact.sha256:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Квалификационный артефакт не прошёл проверку SHA-256.",
|
||||
)
|
||||
try:
|
||||
value = json.loads(resolved.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Квалификационный артефакт не является допустимым JSON.",
|
||||
) from exc
|
||||
if not isinstance(value, dict) or value.get("schema_version") != expected_schema:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Квалификационный артефакт имеет неизвестную схему.",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _run_summary(run: QualificationRun) -> dict[str, Any]:
|
||||
return {
|
||||
"run_id": run.run_id,
|
||||
|
||||
Reference in New Issue
Block a user