feat(lab): complete E30 evidence review gate
This commit is contained in:
@@ -33,6 +33,9 @@ from k1link.sessions import (
|
||||
SessionStore,
|
||||
)
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.e30_engineering_api import build_e30_engineering_router
|
||||
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
||||
from k1link.web.e30_review_api import build_e30_review_router
|
||||
from k1link.web.environment_api import build_environment_router
|
||||
from k1link.web.laboratory_api import build_laboratory_router
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
@@ -493,6 +496,88 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e30_review_router(
|
||||
materialization_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "materializations"
|
||||
),
|
||||
review_pack_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "review-packs"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e30_engineering_router(
|
||||
generation_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "engineering-generations"
|
||||
),
|
||||
materialization_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "materializations"
|
||||
),
|
||||
review_pack_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "review-packs"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e30_human_review_router(
|
||||
materialization_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "materializations"
|
||||
),
|
||||
review_pack_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "review-packs"
|
||||
),
|
||||
engineering_generation_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "engineering-generations"
|
||||
),
|
||||
draft_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "human-review-drafts"
|
||||
),
|
||||
generation_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "human-review-generations"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from k1link.compute.e30_engineering_generation import (
|
||||
E30_ENGINEERING_CAUSES_SCHEMA,
|
||||
E30_ENGINEERING_DECISION_SCHEMA,
|
||||
E30_ENGINEERING_DECISION_SCHEMAS,
|
||||
E30_ENGINEERING_EXCEPTION_SCHEMA,
|
||||
E30_ENGINEERING_EXCEPTION_SCHEMAS,
|
||||
E30_ENGINEERING_GENERATION_SCHEMA,
|
||||
E30_ENGINEERING_SUMMARY_SCHEMA,
|
||||
EXCEPTION_DISPOSITIONS,
|
||||
)
|
||||
from k1link.web.e30_review_api import (
|
||||
E30ReviewEvidenceError,
|
||||
e30_review_item_summary,
|
||||
load_verified_e30_review,
|
||||
)
|
||||
|
||||
LABORATORY_E30_ENGINEERING_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e30-engineering-generations/v1"
|
||||
)
|
||||
LABORATORY_E30_ENGINEERING_DECISION_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e30-engineering-decision/v1"
|
||||
)
|
||||
LABORATORY_E30_ENGINEERING_EXCEPTIONS_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e30-engineering-exceptions/v1"
|
||||
)
|
||||
|
||||
_GENERATION_ID = re.compile(r"^e30-engineering-generation-[a-f0-9]{64}$")
|
||||
_MATERIALIZATION_ID = re.compile(r"^e30-materialization-[a-f0-9]{64}$")
|
||||
_ITEM_ID = re.compile(r"^e30-review-item-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_MAX_MANIFEST_BYTES: Final = 1024 * 1024
|
||||
_MAX_DECISIONS_BYTES: Final = 8 * 1024 * 1024
|
||||
_MAX_AUXILIARY_BYTES: Final = 2 * 1024 * 1024
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
|
||||
class E30EngineeringEvidenceError(ValueError):
|
||||
"""An engineering generation is incomplete, changed, or incompatible."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path, maximum_bytes: int) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise E30EngineeringEvidenceError("engineering JSON is unavailable")
|
||||
size = path.stat().st_size
|
||||
if not 0 < size <= maximum_bytes:
|
||||
raise E30EngineeringEvidenceError("engineering JSON is out of bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E30EngineeringEvidenceError("engineering JSON is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30EngineeringEvidenceError("engineering JSON must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_file(root: Path, value: object) -> Path:
|
||||
if not isinstance(value, str) or not value or Path(value).is_absolute():
|
||||
raise E30EngineeringEvidenceError("engineering artifact path is invalid")
|
||||
if ".." in Path(value).parts:
|
||||
raise E30EngineeringEvidenceError("engineering artifact escaped its root")
|
||||
candidate = root / value
|
||||
if candidate.is_symlink():
|
||||
raise E30EngineeringEvidenceError("engineering artifact is a symlink")
|
||||
candidate = candidate.resolve(strict=True)
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering artifact escaped its root"
|
||||
) from exc
|
||||
if not candidate.is_file():
|
||||
raise E30EngineeringEvidenceError("engineering artifact is unavailable")
|
||||
return candidate
|
||||
|
||||
|
||||
def _artifact(
|
||||
root: Path,
|
||||
value: object,
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
) -> tuple[Path, dict[str, Any]]:
|
||||
if not isinstance(value, dict):
|
||||
raise E30EngineeringEvidenceError("engineering artifact metadata is invalid")
|
||||
byte_length = value.get("byte_length")
|
||||
digest = value.get("sha256")
|
||||
if (
|
||||
not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or byte_length < (0 if allow_empty else 1)
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering artifact metadata is invalid")
|
||||
path = _safe_file(root, value.get("path"))
|
||||
if path.stat().st_size != byte_length or _sha256(path) != digest:
|
||||
raise E30EngineeringEvidenceError("engineering artifact content changed")
|
||||
return path, value
|
||||
|
||||
|
||||
def _read_jsonl(path: Path, maximum_bytes: int) -> list[dict[str, Any]]:
|
||||
if path.stat().st_size > maximum_bytes:
|
||||
raise E30EngineeringEvidenceError("engineering JSONL is out of bounds")
|
||||
values: list[dict[str, Any]] = []
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering JSONL is invalid"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering JSONL row must be an object"
|
||||
)
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
|
||||
def _signature(root: Path) -> tuple[int, ...]:
|
||||
paths = [
|
||||
root / "manifest.json",
|
||||
root / "engineering-decisions.jsonl",
|
||||
root / "human-exceptions.jsonl",
|
||||
root / "summary.json",
|
||||
root / "cause-distribution.json",
|
||||
]
|
||||
signature: list[int] = []
|
||||
for path in paths:
|
||||
stat = path.stat()
|
||||
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||
return tuple(signature)
|
||||
|
||||
|
||||
def _authority(value: object) -> None:
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("commands_enabled") is not False
|
||||
or value.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation must remain diagnostic-only"
|
||||
)
|
||||
|
||||
|
||||
def _verify_materialization_binding(
|
||||
*,
|
||||
materialization_root: Path,
|
||||
source: dict[str, Any],
|
||||
) -> None:
|
||||
result_id = source.get("materialization_id")
|
||||
if (
|
||||
not isinstance(result_id, str)
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
):
|
||||
raise E30EngineeringEvidenceError("materialization binding is invalid")
|
||||
candidate = materialization_root / result_id
|
||||
manifest_path = candidate / "manifest.json"
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise E30EngineeringEvidenceError("materialization binding is unavailable")
|
||||
manifest = _read_json(manifest_path, _MAX_MANIFEST_BYTES)
|
||||
if (
|
||||
manifest.get("result_id") != result_id
|
||||
or manifest.get("identity_sha256")
|
||||
!= source.get("materialization_identity_sha256")
|
||||
or _sha256(manifest_path) != source.get("materialization_manifest_sha256")
|
||||
or _sha256(candidate / "materialized-items.jsonl")
|
||||
!= source.get("materialization_index_sha256")
|
||||
):
|
||||
raise E30EngineeringEvidenceError("materialization binding changed")
|
||||
|
||||
|
||||
def _validate_decisions(
|
||||
values: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, object], dict[str, object]]:
|
||||
if (
|
||||
len(values) != 486
|
||||
or len({value.get("item_id") for value in values}) != len(values)
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering coverage is incomplete")
|
||||
verdicts: Counter[str] = Counter()
|
||||
detector: Counter[str] = Counter()
|
||||
projection: Counter[str] = Counter()
|
||||
ownership: Counter[str] = Counter()
|
||||
causes: Counter[str] = Counter()
|
||||
exceptions = 0
|
||||
confidence_total = 0.0
|
||||
for sequence, value in enumerate(values):
|
||||
confidence = value.get("confidence")
|
||||
schema_version = value.get("schema_version")
|
||||
exception_required = value.get("human_exception_required")
|
||||
review_prompt = value.get("review_prompt")
|
||||
if (
|
||||
schema_version not in E30_ENGINEERING_DECISION_SCHEMAS
|
||||
or value.get("sequence") != sequence
|
||||
or _ITEM_ID.fullmatch(str(value.get("item_id"))) is None
|
||||
or not isinstance(value.get("verdict"), str)
|
||||
or not isinstance(value.get("detector_assessment"), str)
|
||||
or not isinstance(value.get("projection_assessment"), str)
|
||||
or not isinstance(value.get("point_ownership"), str)
|
||||
or not isinstance(confidence, (int, float))
|
||||
or isinstance(confidence, bool)
|
||||
or not math.isfinite(float(confidence))
|
||||
or not isinstance(exception_required, bool)
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering decision is invalid")
|
||||
if schema_version == E30_ENGINEERING_DECISION_SCHEMA and (
|
||||
exception_required != _valid_review_prompt(review_prompt)
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering exception prompt is invalid"
|
||||
)
|
||||
verdicts[value["verdict"]] += 1
|
||||
detector[value["detector_assessment"]] += 1
|
||||
projection[value["projection_assessment"]] += 1
|
||||
ownership[value["point_ownership"]] += 1
|
||||
if isinstance(value.get("cause_code"), str):
|
||||
causes[value["cause_code"]] += 1
|
||||
if value["human_exception_required"]:
|
||||
exceptions += 1
|
||||
confidence_total += float(confidence)
|
||||
summary: dict[str, object] = {
|
||||
"schema_version": E30_ENGINEERING_SUMMARY_SCHEMA,
|
||||
"item_count": len(values),
|
||||
"reviewed_item_count": len(values),
|
||||
"verdict_distribution": dict(sorted(verdicts.items())),
|
||||
"detector_distribution": dict(sorted(detector.items())),
|
||||
"projection_distribution": dict(sorted(projection.items())),
|
||||
"point_ownership_distribution": dict(sorted(ownership.items())),
|
||||
"human_exception_count": exceptions,
|
||||
"mean_confidence": round(confidence_total / len(values), 4),
|
||||
}
|
||||
cause_document: dict[str, object] = {
|
||||
"schema_version": E30_ENGINEERING_CAUSES_SCHEMA,
|
||||
"item_count_with_cause": sum(causes.values()),
|
||||
"reasons": [
|
||||
{"reason_code": reason, "count": count}
|
||||
for reason, count in sorted(causes.items())
|
||||
],
|
||||
}
|
||||
return summary, cause_document
|
||||
|
||||
|
||||
def _valid_review_prompt(value: object) -> bool:
|
||||
if not isinstance(value, dict) or set(value) != {"question", "focus", "effects"}:
|
||||
return False
|
||||
effects = value.get("effects")
|
||||
return (
|
||||
isinstance(value.get("question"), str)
|
||||
and bool(value["question"].strip())
|
||||
and isinstance(value.get("focus"), str)
|
||||
and bool(value["focus"].strip())
|
||||
and isinstance(effects, dict)
|
||||
and set(effects) == set(EXCEPTION_DISPOSITIONS)
|
||||
and all(
|
||||
isinstance(effects.get(disposition), str)
|
||||
and bool(effects[disposition].strip())
|
||||
for disposition in EXCEPTION_DISPOSITIONS
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _generation_cached(
|
||||
root_text: str,
|
||||
materialization_root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> tuple[dict[str, object], tuple[dict[str, Any], ...]]:
|
||||
del signature
|
||||
root = Path(root_text)
|
||||
materialization_root = Path(materialization_root_text)
|
||||
if (
|
||||
root.is_symlink()
|
||||
or not root.is_dir()
|
||||
or _GENERATION_ID.fullmatch(root.name) is None
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering generation id is invalid")
|
||||
manifest = _read_json(root / "manifest.json", _MAX_MANIFEST_BYTES)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != E30_ENGINEERING_GENERATION_SCHEMA
|
||||
or manifest.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or root.name != f"e30-engineering-generation-{identity_sha256}"
|
||||
or manifest.get("ai_review_complete") is not True
|
||||
or manifest.get("human_review_complete") is not False
|
||||
or manifest.get("lab_published") is not False
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation identity is invalid"
|
||||
)
|
||||
_authority(manifest.get("authority"))
|
||||
source = identity.get("source")
|
||||
producer = identity.get("producer")
|
||||
if (
|
||||
not isinstance(source, dict)
|
||||
or not isinstance(producer, dict)
|
||||
or producer.get("kind") != "ai-assisted-engineering-review"
|
||||
or producer.get("claims_human_ground_truth") is not False
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering provenance is invalid")
|
||||
_verify_materialization_binding(
|
||||
materialization_root=materialization_root,
|
||||
source=source,
|
||||
)
|
||||
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise E30EngineeringEvidenceError("engineering artifacts are invalid")
|
||||
by_role = {
|
||||
str(value.get("role")): value
|
||||
for value in artifacts
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
required_roles = {
|
||||
"engineering-decisions",
|
||||
"human-exceptions",
|
||||
"engineering-summary",
|
||||
"cause-distribution",
|
||||
}
|
||||
if set(by_role) != required_roles or len(artifacts) != len(required_roles):
|
||||
raise E30EngineeringEvidenceError("engineering artifacts are incomplete")
|
||||
decisions_path, decisions_artifact = _artifact(
|
||||
root,
|
||||
by_role["engineering-decisions"],
|
||||
)
|
||||
exceptions_path, _ = _artifact(
|
||||
root,
|
||||
by_role["human-exceptions"],
|
||||
allow_empty=True,
|
||||
)
|
||||
summary_path, _ = _artifact(root, by_role["engineering-summary"])
|
||||
causes_path, _ = _artifact(root, by_role["cause-distribution"])
|
||||
decisions = _read_jsonl(decisions_path, _MAX_DECISIONS_BYTES)
|
||||
exceptions = _read_jsonl(exceptions_path, _MAX_AUXILIARY_BYTES)
|
||||
for exception in exceptions:
|
||||
exception_schema = exception.get("schema_version")
|
||||
if (
|
||||
exception_schema not in E30_ENGINEERING_EXCEPTION_SCHEMAS
|
||||
or (
|
||||
exception_schema == E30_ENGINEERING_EXCEPTION_SCHEMA
|
||||
and not _valid_review_prompt(exception.get("review_prompt"))
|
||||
)
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering exception contract is invalid"
|
||||
)
|
||||
calculated_summary, calculated_causes = _validate_decisions(decisions)
|
||||
summary = _read_json(summary_path, _MAX_AUXILIARY_BYTES)
|
||||
causes = _read_json(causes_path, _MAX_AUXILIARY_BYTES)
|
||||
if (
|
||||
summary != calculated_summary
|
||||
or causes != calculated_causes
|
||||
or manifest.get("summary") != summary
|
||||
or manifest.get("cause_distribution") != causes
|
||||
or len(exceptions) != summary["human_exception_count"]
|
||||
or identity.get("decision_content_sha256")
|
||||
!= decisions_artifact.get("sha256")
|
||||
or source.get("item_count") != len(decisions)
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering summary changed")
|
||||
exception_ids = {value.get("item_id") for value in exceptions}
|
||||
expected_exception_ids = {
|
||||
value["item_id"]
|
||||
for value in decisions
|
||||
if value["human_exception_required"]
|
||||
}
|
||||
if exception_ids != expected_exception_ids:
|
||||
raise E30EngineeringEvidenceError("engineering exception queue changed")
|
||||
catalog_item: dict[str, object] = {
|
||||
"generation_id": root.name,
|
||||
"created_at_utc": manifest.get("created_at_utc"),
|
||||
"materialization_id": source["materialization_id"],
|
||||
"producer": copy.deepcopy(producer),
|
||||
"summary": copy.deepcopy(summary),
|
||||
"cause_distribution": copy.deepcopy(causes),
|
||||
"human_exceptions": [
|
||||
{
|
||||
"item_id": value["item_id"],
|
||||
"review_key": value["review_key"],
|
||||
"source_stratum": value["source_stratum"],
|
||||
"confidence": value["confidence"],
|
||||
"review_prompt": copy.deepcopy(value.get("review_prompt")),
|
||||
}
|
||||
for value in exceptions
|
||||
],
|
||||
"ai_review_complete": True,
|
||||
"human_exception_complete": (
|
||||
manifest.get("human_exception_complete") is True
|
||||
),
|
||||
"human_review_complete": False,
|
||||
"lab_published": False,
|
||||
"access": "read-only",
|
||||
"authority": copy.deepcopy(manifest["authority"]),
|
||||
}
|
||||
return catalog_item, tuple(decisions)
|
||||
|
||||
|
||||
def load_verified_e30_engineering_generation(
|
||||
*,
|
||||
generation_root: Path,
|
||||
materialization_root: Path,
|
||||
result_id: str,
|
||||
generation_id: str,
|
||||
) -> tuple[dict[str, object], tuple[dict[str, Any], ...]]:
|
||||
generation_root = generation_root.resolve()
|
||||
materialization_root = materialization_root.resolve()
|
||||
if (
|
||||
not generation_root.is_dir()
|
||||
or not materialization_root.is_dir()
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
or _GENERATION_ID.fullmatch(generation_id) is None
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation is unavailable"
|
||||
)
|
||||
candidate = generation_root / generation_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation is unavailable"
|
||||
)
|
||||
catalog_item, decisions = _generation_cached(
|
||||
str(candidate.resolve()),
|
||||
str(materialization_root),
|
||||
_signature(candidate),
|
||||
)
|
||||
if catalog_item["materialization_id"] != result_id:
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation materialization differs"
|
||||
)
|
||||
return copy.deepcopy(catalog_item), decisions
|
||||
|
||||
|
||||
def build_e30_engineering_router(
|
||||
*,
|
||||
generation_root_provider: RootProvider = lambda: None,
|
||||
materialization_root_provider: RootProvider = lambda: None,
|
||||
review_pack_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/e30", tags=["laboratory"])
|
||||
|
||||
def roots() -> tuple[Path, Path] | None:
|
||||
generation_root = generation_root_provider()
|
||||
materialization_root = materialization_root_provider()
|
||||
if generation_root is None or materialization_root is None:
|
||||
return None
|
||||
generation_root = generation_root.resolve()
|
||||
materialization_root = materialization_root.resolve()
|
||||
if not generation_root.is_dir() or not materialization_root.is_dir():
|
||||
return None
|
||||
return generation_root, materialization_root
|
||||
|
||||
@router.get("/reviews/{result_id}/engineering-generations")
|
||||
def list_engineering_generations(
|
||||
result_id: str,
|
||||
limit: int = Query(default=1, ge=1, le=10),
|
||||
) -> dict[str, object]:
|
||||
configured_roots = roots()
|
||||
if (
|
||||
configured_roots is None
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
):
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ENGINEERING_CATALOG_SCHEMA,
|
||||
"configured": configured_roots is not None,
|
||||
"items": [],
|
||||
"candidate_total": 0,
|
||||
"invalid_total": 0,
|
||||
"access": "read-only",
|
||||
}
|
||||
generation_root, materialization_root = configured_roots
|
||||
candidates = sorted(
|
||||
(
|
||||
candidate
|
||||
for candidate in generation_root.iterdir()
|
||||
if candidate.is_dir()
|
||||
and _GENERATION_ID.fullmatch(candidate.name) is not None
|
||||
),
|
||||
key=lambda candidate: candidate.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
matching_total = 0
|
||||
for candidate in candidates:
|
||||
try:
|
||||
item, _ = _generation_cached(
|
||||
str(candidate.resolve()),
|
||||
str(materialization_root),
|
||||
_signature(candidate),
|
||||
)
|
||||
if item["materialization_id"] != result_id:
|
||||
continue
|
||||
matching_total += 1
|
||||
if len(items) < limit:
|
||||
items.append(copy.deepcopy(item))
|
||||
except (E30EngineeringEvidenceError, OSError):
|
||||
invalid_total += 1
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ENGINEERING_CATALOG_SCHEMA,
|
||||
"configured": True,
|
||||
"items": items,
|
||||
"candidate_total": matching_total,
|
||||
"invalid_total": invalid_total,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
@router.get(
|
||||
"/reviews/{result_id}/engineering-generations/"
|
||||
"{generation_id}/exceptions"
|
||||
)
|
||||
def list_engineering_exceptions(
|
||||
result_id: str,
|
||||
generation_id: str,
|
||||
limit: int = Query(default=48, ge=1, le=128),
|
||||
cursor: int = Query(default=0, ge=0),
|
||||
) -> dict[str, object]:
|
||||
configured_roots = roots()
|
||||
review_pack_root = review_pack_root_provider()
|
||||
if (
|
||||
configured_roots is None
|
||||
or review_pack_root is None
|
||||
or not review_pack_root.is_dir()
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
or _GENERATION_ID.fullmatch(generation_id) is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering exception queue не найдена",
|
||||
)
|
||||
generation_root, materialization_root = configured_roots
|
||||
candidate = generation_root / generation_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering exception queue не найдена",
|
||||
)
|
||||
try:
|
||||
catalog_item, _ = _generation_cached(
|
||||
str(candidate.resolve()),
|
||||
str(materialization_root),
|
||||
_signature(candidate),
|
||||
)
|
||||
if catalog_item["materialization_id"] != result_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering exception queue не найдена",
|
||||
)
|
||||
_, rows, _ = load_verified_e30_review(
|
||||
materialization_root=materialization_root,
|
||||
review_pack_root=review_pack_root,
|
||||
result_id=result_id,
|
||||
)
|
||||
rows_by_id = {row["item_id"]: row for row in rows}
|
||||
exception_ids = [
|
||||
value["item_id"]
|
||||
for value in catalog_item["human_exceptions"]
|
||||
]
|
||||
if any(item_id not in rows_by_id for item_id in exception_ids):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering exception source item is unavailable"
|
||||
)
|
||||
page_ids = exception_ids[cursor : cursor + limit]
|
||||
next_cursor = cursor + len(page_ids)
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ENGINEERING_EXCEPTIONS_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"generation_id": generation_id,
|
||||
"items": [
|
||||
e30_review_item_summary(rows_by_id[item_id])
|
||||
for item_id in page_ids
|
||||
],
|
||||
"total": len(exception_ids),
|
||||
"next_cursor": (
|
||||
next_cursor if next_cursor < len(exception_ids) else None
|
||||
),
|
||||
"access": "read-only",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except (
|
||||
E30EngineeringEvidenceError,
|
||||
E30ReviewEvidenceError,
|
||||
OSError,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A3 engineering exception queue не прошла проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.get(
|
||||
"/reviews/{result_id}/engineering-generations/"
|
||||
"{generation_id}/items/{item_id}"
|
||||
)
|
||||
def get_engineering_decision(
|
||||
result_id: str,
|
||||
generation_id: str,
|
||||
item_id: str,
|
||||
) -> dict[str, object]:
|
||||
configured_roots = roots()
|
||||
if (
|
||||
configured_roots is None
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
or _GENERATION_ID.fullmatch(generation_id) is None
|
||||
or _ITEM_ID.fullmatch(item_id) is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering decision не найден",
|
||||
)
|
||||
generation_root, materialization_root = configured_roots
|
||||
candidate = generation_root / generation_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering decision не найден",
|
||||
)
|
||||
try:
|
||||
catalog_item, decisions = _generation_cached(
|
||||
str(candidate.resolve()),
|
||||
str(materialization_root),
|
||||
_signature(candidate),
|
||||
)
|
||||
if catalog_item["materialization_id"] != result_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering decision не найден",
|
||||
)
|
||||
decision = next(
|
||||
(value for value in decisions if value["item_id"] == item_id),
|
||||
None,
|
||||
)
|
||||
if decision is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering decision не найден",
|
||||
)
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ENGINEERING_DECISION_SCHEMA,
|
||||
"generation_id": generation_id,
|
||||
"decision": copy.deepcopy(decision),
|
||||
"access": "read-only",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except (E30EngineeringEvidenceError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A3 engineering generation не прошла проверку целостности",
|
||||
) from exc
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Path as ApiPath
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.compute.e30_human_review import (
|
||||
E30HumanReviewConflictError,
|
||||
E30HumanReviewIntegrityError,
|
||||
E30HumanReviewNotFoundError,
|
||||
E30HumanReviewStore,
|
||||
E30HumanReviewValidationError,
|
||||
E30ExceptionDisposition,
|
||||
E30ReviewSubject,
|
||||
E30ReviewSubstrate,
|
||||
)
|
||||
from k1link.web.e30_engineering_api import (
|
||||
E30EngineeringEvidenceError,
|
||||
load_verified_e30_engineering_generation,
|
||||
)
|
||||
from k1link.web.e30_review_api import (
|
||||
E30ReviewEvidenceError,
|
||||
load_verified_e30_review,
|
||||
)
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
|
||||
class E30HumanReviewCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reviewer_id: str = Field(min_length=1, max_length=128)
|
||||
engineering_generation_id: str = Field(
|
||||
pattern=r"^e30-engineering-generation-[a-f0-9]{64}$"
|
||||
)
|
||||
|
||||
|
||||
class E30HumanReviewDecisionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=0)
|
||||
idempotency_key: str = Field(min_length=1, max_length=128)
|
||||
disposition: E30ExceptionDisposition
|
||||
notes: str | None = Field(default=None, max_length=2_000)
|
||||
|
||||
|
||||
class E30HumanReviewFinalizeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=0)
|
||||
confirm_generation: Literal[True]
|
||||
|
||||
|
||||
def build_e30_human_review_router(
|
||||
*,
|
||||
materialization_root_provider: RootProvider = lambda: None,
|
||||
review_pack_root_provider: RootProvider = lambda: None,
|
||||
engineering_generation_root_provider: RootProvider = lambda: None,
|
||||
draft_root_provider: RootProvider = lambda: None,
|
||||
generation_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/e30", tags=["laboratory"])
|
||||
|
||||
def source(
|
||||
result_id: str,
|
||||
engineering_generation_id: str,
|
||||
) -> E30ReviewSubstrate:
|
||||
materialization_root = materialization_root_provider()
|
||||
review_pack_root = review_pack_root_provider()
|
||||
engineering_generation_root = engineering_generation_root_provider()
|
||||
if (
|
||||
materialization_root is None
|
||||
or review_pack_root is None
|
||||
or engineering_generation_root is None
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="E30 review не найден")
|
||||
try:
|
||||
_, _, source_substrate = load_verified_e30_review(
|
||||
materialization_root=materialization_root,
|
||||
review_pack_root=review_pack_root,
|
||||
result_id=result_id,
|
||||
)
|
||||
generation, _ = load_verified_e30_engineering_generation(
|
||||
generation_root=engineering_generation_root,
|
||||
materialization_root=materialization_root,
|
||||
result_id=result_id,
|
||||
generation_id=engineering_generation_id,
|
||||
)
|
||||
subjects_by_id = {
|
||||
subject.item_id: subject
|
||||
for subject in source_substrate.subjects
|
||||
}
|
||||
exception_ids = [
|
||||
value["item_id"]
|
||||
for value in generation["human_exceptions"]
|
||||
]
|
||||
if (
|
||||
not exception_ids
|
||||
or any(item_id not in subjects_by_id for item_id in exception_ids)
|
||||
):
|
||||
raise E30HumanReviewValidationError(
|
||||
"engineering exception substrate is invalid"
|
||||
)
|
||||
return E30ReviewSubstrate(
|
||||
materialization_id=source_substrate.materialization_id,
|
||||
materialization_identity_sha256=(
|
||||
source_substrate.materialization_identity_sha256
|
||||
),
|
||||
review_pack_id=source_substrate.review_pack_id,
|
||||
review_items_sha256=source_substrate.review_items_sha256,
|
||||
reason_taxonomy=(),
|
||||
subjects=tuple(
|
||||
E30ReviewSubject(
|
||||
item_id=item_id,
|
||||
sequence=sequence,
|
||||
source_stratum=subjects_by_id[item_id].source_stratum,
|
||||
)
|
||||
for sequence, item_id in enumerate(exception_ids)
|
||||
),
|
||||
engineering_generation_id=engineering_generation_id,
|
||||
)
|
||||
except (
|
||||
E30EngineeringEvidenceError,
|
||||
E30ReviewEvidenceError,
|
||||
E30HumanReviewValidationError,
|
||||
OSError,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 source evidence не прошло проверку целостности",
|
||||
) from exc
|
||||
|
||||
def store() -> E30HumanReviewStore:
|
||||
draft_root = draft_root_provider()
|
||||
generation_root = generation_root_provider()
|
||||
if draft_root is None or generation_root is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="A3 human review не настроен",
|
||||
)
|
||||
try:
|
||||
return E30HumanReviewStore(
|
||||
draft_root=draft_root,
|
||||
generation_root=generation_root,
|
||||
)
|
||||
except (E30HumanReviewIntegrityError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="A3 human review storage недоступен",
|
||||
) from exc
|
||||
|
||||
def invoke(
|
||||
operation: Callable[[], dict[str, object]],
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return operation()
|
||||
except E30HumanReviewNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 human review draft не найден",
|
||||
) from exc
|
||||
except E30HumanReviewValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except E30HumanReviewConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (E30HumanReviewIntegrityError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A3 human review не прошло проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.post("/reviews/{result_id}/human-review")
|
||||
def create_or_resume_human_review(
|
||||
result_id: str,
|
||||
request: E30HumanReviewCreateRequest,
|
||||
) -> dict[str, object]:
|
||||
substrate = source(result_id, request.engineering_generation_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.create_or_resume(
|
||||
substrate=substrate,
|
||||
reviewer_id=request.reviewer_id,
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("/reviews/{result_id}/human-review/{draft_id}")
|
||||
def get_human_review(
|
||||
result_id: str,
|
||||
draft_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-human-draft-[a-f0-9]{64}$"),
|
||||
],
|
||||
engineering_generation_id: str,
|
||||
) -> dict[str, object]:
|
||||
substrate = source(result_id, engineering_generation_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.get(draft_id=draft_id, substrate=substrate)
|
||||
)
|
||||
|
||||
@router.put(
|
||||
"/reviews/{result_id}/human-review/{draft_id}/decisions/{item_id}"
|
||||
)
|
||||
def record_human_review_decision(
|
||||
result_id: str,
|
||||
draft_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-human-draft-[a-f0-9]{64}$"),
|
||||
],
|
||||
item_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-review-item-[a-f0-9]{64}$"),
|
||||
],
|
||||
request: E30HumanReviewDecisionRequest,
|
||||
engineering_generation_id: str,
|
||||
) -> dict[str, object]:
|
||||
substrate = source(result_id, engineering_generation_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=item_id,
|
||||
expected_revision=request.expected_revision,
|
||||
idempotency_key=request.idempotency_key,
|
||||
disposition=request.disposition,
|
||||
notes=request.notes,
|
||||
)
|
||||
)
|
||||
|
||||
@router.post("/reviews/{result_id}/human-review/{draft_id}/finalize")
|
||||
def finalize_human_review(
|
||||
result_id: str,
|
||||
draft_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-human-draft-[a-f0-9]{64}$"),
|
||||
],
|
||||
request: E30HumanReviewFinalizeRequest,
|
||||
engineering_generation_id: str,
|
||||
) -> dict[str, object]:
|
||||
substrate = source(result_id, engineering_generation_id)
|
||||
review_store = store()
|
||||
generation = invoke(
|
||||
lambda: review_store.finalize(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
expected_revision=request.expected_revision,
|
||||
)
|
||||
)
|
||||
draft = invoke(
|
||||
lambda: review_store.get(draft_id=draft_id, substrate=substrate)
|
||||
)
|
||||
return {
|
||||
"schema_version": "missioncore.laboratory-e30-human-review-finalized/v2",
|
||||
"draft": draft,
|
||||
"generation": generation,
|
||||
}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,818 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Final, Literal, cast
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from k1link.compute.e30_human_review import (
|
||||
E30ReviewSubject,
|
||||
E30ReviewSubstrate,
|
||||
E30Stratum,
|
||||
)
|
||||
from k1link.compute.e30_materialization import (
|
||||
E30_MATERIALIZATION_INDEX_NAME,
|
||||
E30_MATERIALIZATION_ITEM_SCHEMA,
|
||||
E30_MATERIALIZATION_MANIFEST_NAME,
|
||||
E30_MATERIALIZATION_SCHEMA,
|
||||
)
|
||||
|
||||
LABORATORY_E30_CATALOG_SCHEMA: Final = "missioncore.laboratory-e30-catalog/v1"
|
||||
LABORATORY_E30_ITEMS_SCHEMA: Final = "missioncore.laboratory-e30-items/v1"
|
||||
LABORATORY_E30_ITEM_DETAIL_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e30-item-detail/v1"
|
||||
)
|
||||
|
||||
_MATERIALIZATION_ID = re.compile(r"^e30-materialization-[a-f0-9]{64}$")
|
||||
_REVIEW_PACK_ID = re.compile(r"^e30-review-pack-[a-f0-9]{64}$")
|
||||
_REVIEW_ITEM_ID = re.compile(r"^e30-review-item-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_STRATA: Final = ("conflict", "agree", "camera-only", "unknown", "geometry-only")
|
||||
_MAX_MANIFEST_BYTES: Final = 512 * 1024
|
||||
_MAX_INDEX_BYTES: Final = 8 * 1024 * 1024
|
||||
_MAX_ITEM_BYTES: Final = 8 * 1024 * 1024
|
||||
_MAX_CAMERA_FRAME_BYTES: Final = 8 * 1024 * 1024
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
Stratum = Literal["conflict", "agree", "camera-only", "unknown", "geometry-only"]
|
||||
|
||||
|
||||
class E30ReviewEvidenceError(ValueError):
|
||||
"""E30 review evidence is incomplete, changed or incompatible."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path, maximum_bytes: int) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise E30ReviewEvidenceError("E30 JSON artifact is unavailable")
|
||||
size = path.stat().st_size
|
||||
if not 0 < size <= maximum_bytes:
|
||||
raise E30ReviewEvidenceError("E30 JSON artifact is out of bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E30ReviewEvidenceError("E30 JSON artifact is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30ReviewEvidenceError("E30 JSON artifact must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_relative_file(root: Path, value: object) -> Path:
|
||||
if not isinstance(value, str) or not value or Path(value).is_absolute():
|
||||
raise E30ReviewEvidenceError("E30 artifact path is invalid")
|
||||
if ".." in Path(value).parts:
|
||||
raise E30ReviewEvidenceError("E30 artifact path escaped its root")
|
||||
candidate = root / value
|
||||
if candidate.is_symlink():
|
||||
raise E30ReviewEvidenceError("E30 artifact must not be a symlink")
|
||||
candidate = candidate.resolve(strict=True)
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise E30ReviewEvidenceError("E30 artifact escaped its root") from exc
|
||||
if not candidate.is_file():
|
||||
raise E30ReviewEvidenceError("E30 artifact is unavailable")
|
||||
return candidate
|
||||
|
||||
|
||||
def _artifact(
|
||||
root: Path,
|
||||
value: object,
|
||||
*,
|
||||
expected_role: str | None = None,
|
||||
) -> tuple[Path, dict[str, Any]]:
|
||||
if not isinstance(value, dict):
|
||||
raise E30ReviewEvidenceError("E30 artifact metadata is invalid")
|
||||
role = value.get("role")
|
||||
byte_length = value.get("byte_length")
|
||||
digest = value.get("sha256")
|
||||
if (
|
||||
(expected_role is not None and role != expected_role)
|
||||
or not isinstance(byte_length, int)
|
||||
or byte_length <= 0
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 artifact metadata is invalid")
|
||||
path = _safe_relative_file(root, value.get("path"))
|
||||
if path.stat().st_size != byte_length or _sha256(path) != digest:
|
||||
raise E30ReviewEvidenceError("E30 artifact content changed")
|
||||
return path, value
|
||||
|
||||
|
||||
def _authority(value: object) -> None:
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("commands_enabled") is not False
|
||||
or value.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 evidence must remain diagnostic-only")
|
||||
|
||||
|
||||
def _signature(root: Path) -> tuple[int, ...]:
|
||||
paths = [
|
||||
root / E30_MATERIALIZATION_MANIFEST_NAME,
|
||||
root / E30_MATERIALIZATION_INDEX_NAME,
|
||||
*sorted((root / "items").glob("*.npz")),
|
||||
*sorted((root / "frames").glob("*.jpg")),
|
||||
]
|
||||
signature: list[int] = []
|
||||
for path in paths:
|
||||
stat = path.stat()
|
||||
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||
return tuple(signature)
|
||||
|
||||
|
||||
def _review_pack(
|
||||
root: Path,
|
||||
*,
|
||||
expected_id: str,
|
||||
expected_items_sha256: str,
|
||||
) -> dict[str, Any]:
|
||||
candidate = root / expected_id
|
||||
if (
|
||||
_REVIEW_PACK_ID.fullmatch(expected_id) is None
|
||||
or candidate.is_symlink()
|
||||
or not candidate.is_dir()
|
||||
):
|
||||
raise E30ReviewEvidenceError("linked E30 review pack is unavailable")
|
||||
manifest = _read_json(candidate / "manifest.json", _MAX_MANIFEST_BYTES)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != "missioncore.e30-evidence-review-pack/v1"
|
||||
or manifest.get("result_id") != expected_id
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or expected_id != f"e30-review-pack-{identity_sha256}"
|
||||
or manifest.get("human_review_complete") is not False
|
||||
or manifest.get("lab_published") is not False
|
||||
):
|
||||
raise E30ReviewEvidenceError("linked E30 review pack identity is invalid")
|
||||
_authority(manifest.get("authority"))
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list) or len(artifacts) != 1:
|
||||
raise E30ReviewEvidenceError("linked E30 review artifacts are invalid")
|
||||
_, artifact = _artifact(
|
||||
candidate,
|
||||
artifacts[0],
|
||||
expected_role="review-items",
|
||||
)
|
||||
if artifact.get("sha256") != expected_items_sha256:
|
||||
raise E30ReviewEvidenceError("linked E30 review item digest differs")
|
||||
return manifest
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _read_materialization_cached(
|
||||
root_text: str,
|
||||
review_root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> tuple[dict[str, Any], tuple[dict[str, Any], ...]]:
|
||||
del signature
|
||||
root = Path(root_text)
|
||||
review_root = Path(review_root_text)
|
||||
if (
|
||||
root.is_symlink()
|
||||
or not root.is_dir()
|
||||
or _MATERIALIZATION_ID.fullmatch(root.name) is None
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 materialization id is invalid")
|
||||
manifest = _read_json(
|
||||
root / E30_MATERIALIZATION_MANIFEST_NAME,
|
||||
_MAX_MANIFEST_BYTES,
|
||||
)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != E30_MATERIALIZATION_SCHEMA
|
||||
or manifest.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or root.name != f"e30-materialization-{identity_sha256}"
|
||||
or manifest.get("human_review_complete") is not False
|
||||
or manifest.get("lab_published") is not False
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 materialization identity is invalid")
|
||||
_authority(manifest.get("authority"))
|
||||
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list) or len(artifacts) != 1:
|
||||
raise E30ReviewEvidenceError("E30 materialization artifacts are invalid")
|
||||
index_path, _ = _artifact(
|
||||
root,
|
||||
artifacts[0],
|
||||
expected_role="materialized-items",
|
||||
)
|
||||
if index_path.stat().st_size > _MAX_INDEX_BYTES:
|
||||
raise E30ReviewEvidenceError("E30 materialization index is out of bounds")
|
||||
|
||||
review_binding = identity.get("review_pack")
|
||||
if not isinstance(review_binding, dict):
|
||||
raise E30ReviewEvidenceError("E30 review binding is invalid")
|
||||
review_manifest = _review_pack(
|
||||
review_root,
|
||||
expected_id=_string(review_binding, "result_id"),
|
||||
expected_items_sha256=_sha_value(review_binding, "items_sha256"),
|
||||
)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
counts: Counter[str] = Counter()
|
||||
with index_path.open("r", encoding="utf-8") as stream:
|
||||
for expected_sequence, line in enumerate(stream):
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 materialization item is invalid"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 materialization item must be an object"
|
||||
)
|
||||
item_id = value.get("item_id")
|
||||
stratum = value.get("stratum")
|
||||
if (
|
||||
value.get("schema_version") != E30_MATERIALIZATION_ITEM_SCHEMA
|
||||
or value.get("sequence") != expected_sequence
|
||||
or not isinstance(item_id, str)
|
||||
or _REVIEW_ITEM_ID.fullmatch(item_id) is None
|
||||
or stratum not in _STRATA
|
||||
or not isinstance(value.get("review"), dict)
|
||||
or value["review"].get("state") != "unreviewed"
|
||||
):
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 materialization item contract is invalid"
|
||||
)
|
||||
_authority(value.get("authority"))
|
||||
_, artifact = _artifact(root, value.get("artifact"))
|
||||
if artifact.get("path") != f"items/{item_id}.npz":
|
||||
raise E30ReviewEvidenceError("E30 item artifact path differs")
|
||||
camera_frame = value.get("camera_frame")
|
||||
if camera_frame is not None:
|
||||
camera_path, camera_artifact = _artifact(
|
||||
root,
|
||||
camera_frame,
|
||||
expected_role="camera-frame",
|
||||
)
|
||||
expected_frame = value.get("evidence_binding", {}).get(
|
||||
"source_frame_index"
|
||||
)
|
||||
if (
|
||||
not isinstance(expected_frame, int)
|
||||
or isinstance(expected_frame, bool)
|
||||
or expected_frame < 0
|
||||
or camera_path.stat().st_size > _MAX_CAMERA_FRAME_BYTES
|
||||
or camera_artifact.get("media_type") != "image/jpeg"
|
||||
or camera_artifact.get("source_frame_index")
|
||||
!= expected_frame
|
||||
or camera_artifact.get("exact_source_frame") is not True
|
||||
or camera_artifact.get("path")
|
||||
!= f"frames/frame-{expected_frame:06d}.jpg"
|
||||
):
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 camera frame binding is invalid"
|
||||
)
|
||||
triage = value.get("engineering_triage")
|
||||
if (
|
||||
not isinstance(triage, dict)
|
||||
or triage.get("schema_version")
|
||||
!= "missioncore.e30-engineering-triage/v1"
|
||||
or triage.get("semantic_verdict") is not None
|
||||
or triage.get("human_exception_required") is not None
|
||||
):
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 engineering triage contract is invalid"
|
||||
)
|
||||
rows.append(value)
|
||||
counts[stratum] += 1
|
||||
if (
|
||||
len(rows) != manifest.get("item_count")
|
||||
or len(rows) != review_binding.get("item_count")
|
||||
or len({row["item_id"] for row in rows}) != len(rows)
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 materialization item count differs")
|
||||
|
||||
review_identity = review_manifest.get("identity")
|
||||
reason_taxonomy = (
|
||||
review_identity.get("reason_taxonomy")
|
||||
if isinstance(review_identity, dict)
|
||||
else None
|
||||
)
|
||||
source = identity.get("source")
|
||||
projection = identity.get("projection")
|
||||
if (
|
||||
not isinstance(reason_taxonomy, list)
|
||||
or not all(isinstance(reason, str) for reason in reason_taxonomy)
|
||||
or not isinstance(source, dict)
|
||||
or not isinstance(projection, dict)
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 catalog metadata is incomplete")
|
||||
catalog_item = {
|
||||
"result_id": root.name,
|
||||
"created_at_utc": manifest.get("created_at_utc"),
|
||||
"review_pack_id": review_binding["result_id"],
|
||||
"e29_result_id": source.get("e29_result_id"),
|
||||
"source_session_id": source.get("source_session_id"),
|
||||
"item_count": len(rows),
|
||||
"stratum_counts": {stratum: counts[stratum] for stratum in _STRATA},
|
||||
"reason_taxonomy": reason_taxonomy,
|
||||
"projection": projection,
|
||||
"camera_evidence_available": (
|
||||
manifest.get("camera_evidence_available") is True
|
||||
),
|
||||
"human_review_complete": False,
|
||||
"lab_published": False,
|
||||
"access": "read-only",
|
||||
"authority": manifest.get("authority"),
|
||||
}
|
||||
return catalog_item, tuple(rows)
|
||||
|
||||
|
||||
def _roots(
|
||||
*,
|
||||
materialization_root_provider: RootProvider,
|
||||
review_pack_root_provider: RootProvider,
|
||||
) -> tuple[Path, Path] | None:
|
||||
materialization_root = materialization_root_provider()
|
||||
review_root = review_pack_root_provider()
|
||||
if materialization_root is None or review_root is None:
|
||||
return None
|
||||
materialization_root = materialization_root.resolve()
|
||||
review_root = review_root.resolve()
|
||||
if not materialization_root.is_dir() or not review_root.is_dir():
|
||||
return None
|
||||
return materialization_root, review_root
|
||||
|
||||
|
||||
def _candidate(
|
||||
materialization_root: Path,
|
||||
result_id: str,
|
||||
) -> Path:
|
||||
if _MATERIALIZATION_ID.fullmatch(result_id) is None:
|
||||
raise HTTPException(status_code=404, detail="E30 review не найден")
|
||||
candidate = materialization_root / result_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise HTTPException(status_code=404, detail="E30 review не найден")
|
||||
return candidate
|
||||
|
||||
|
||||
def e30_review_item_summary(row: dict[str, Any]) -> dict[str, object]:
|
||||
binding = row.get("evidence_binding")
|
||||
locator = row.get("e29_locator")
|
||||
snapshot = row.get("e29_snapshot")
|
||||
materialization = row.get("materialization")
|
||||
if (
|
||||
not isinstance(binding, dict)
|
||||
or not isinstance(locator, dict)
|
||||
or not isinstance(snapshot, dict)
|
||||
or not isinstance(materialization, dict)
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 item summary is incomplete")
|
||||
return {
|
||||
"item_id": row["item_id"],
|
||||
"sequence": row["sequence"],
|
||||
"review_key": row["review_key"],
|
||||
"stratum": row["stratum"],
|
||||
"range_bucket": row["range_bucket"],
|
||||
"frame_index": binding.get("frame_index"),
|
||||
"source_frame_index": binding.get("source_frame_index"),
|
||||
"session_seconds": binding.get("session_seconds"),
|
||||
"locator": locator,
|
||||
"snapshot": snapshot,
|
||||
"materialization": materialization,
|
||||
"camera_frame_available": isinstance(row.get("camera_frame"), dict),
|
||||
"engineering_triage": row.get("engineering_triage"),
|
||||
"review": row["review"],
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _logical_arrays_sha256(arrays: dict[str, npt.NDArray[Any]]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for name in sorted(arrays):
|
||||
array = np.ascontiguousarray(arrays[name])
|
||||
digest.update(name.encode())
|
||||
digest.update(array.dtype.str.encode())
|
||||
digest.update(_canonical_json(list(array.shape)))
|
||||
digest.update(array.tobytes(order="C"))
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _item_detail(root: Path, row: dict[str, Any]) -> dict[str, object]:
|
||||
path, artifact = _artifact(root, row.get("artifact"))
|
||||
if path.stat().st_size > _MAX_ITEM_BYTES:
|
||||
raise E30ReviewEvidenceError("E30 item artifact is out of bounds")
|
||||
expected_files = {
|
||||
"selected_source_indices",
|
||||
"selected_points_map_xyz_m",
|
||||
"candidate_source_indices",
|
||||
"candidate_points_map_xyz_m",
|
||||
"projected_source_indices",
|
||||
"projected_points_map_xyz_m",
|
||||
"projected_pixels_xy",
|
||||
"projected_depth_m",
|
||||
"projected_point_class",
|
||||
"projected_point_height_m",
|
||||
"projected_candidate_mask",
|
||||
"projected_selected_mask",
|
||||
"sensor_position_map_xyz_m",
|
||||
"sensor_orientation_map_from_lidar_xyzw",
|
||||
}
|
||||
with np.load(path, allow_pickle=False) as archive:
|
||||
if set(archive.files) != expected_files:
|
||||
raise E30ReviewEvidenceError("E30 item arrays are incompatible")
|
||||
arrays = {
|
||||
name: np.ascontiguousarray(archive[name])
|
||||
for name in expected_files
|
||||
}
|
||||
if _logical_arrays_sha256(arrays) != artifact.get("logical_sha256"):
|
||||
raise E30ReviewEvidenceError("E30 item logical content changed")
|
||||
projected_count = arrays["projected_source_indices"].shape[0]
|
||||
selected_count = arrays["selected_source_indices"].shape[0]
|
||||
candidate_count = arrays["candidate_source_indices"].shape[0]
|
||||
if (
|
||||
arrays["selected_source_indices"].shape != (selected_count,)
|
||||
or arrays["selected_points_map_xyz_m"].shape != (selected_count, 3)
|
||||
or arrays["candidate_source_indices"].shape != (candidate_count,)
|
||||
or arrays["candidate_points_map_xyz_m"].shape != (candidate_count, 3)
|
||||
or arrays["projected_points_map_xyz_m"].shape != (projected_count, 3)
|
||||
or arrays["projected_pixels_xy"].shape != (projected_count, 2)
|
||||
or arrays["projected_depth_m"].shape != (projected_count,)
|
||||
or arrays["projected_point_class"].shape != (projected_count,)
|
||||
or arrays["projected_point_height_m"].shape != (projected_count,)
|
||||
or arrays["projected_candidate_mask"].shape != (projected_count,)
|
||||
or arrays["projected_selected_mask"].shape != (projected_count,)
|
||||
or arrays["sensor_position_map_xyz_m"].shape != (3,)
|
||||
or arrays["sensor_orientation_map_from_lidar_xyzw"].shape != (4,)
|
||||
or not all(np.isfinite(value).all() for value in arrays.values())
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 item array shape is invalid")
|
||||
camera_frame = row.get("camera_frame")
|
||||
camera_document: dict[str, object] | None = None
|
||||
if camera_frame is not None:
|
||||
_, camera_artifact = _artifact(
|
||||
root,
|
||||
camera_frame,
|
||||
expected_role="camera-frame",
|
||||
)
|
||||
camera_document = {
|
||||
"available": True,
|
||||
"url": (
|
||||
f"/api/v1/laboratory/e30/reviews/{root.name}/items/"
|
||||
f"{row['item_id']}/camera-frame"
|
||||
f"?generation={camera_artifact['sha256']}"
|
||||
),
|
||||
"sha256": camera_artifact["sha256"],
|
||||
"width": camera_artifact.get("width"),
|
||||
"height": camera_artifact.get("height"),
|
||||
"source_frame_index": camera_artifact.get("source_frame_index"),
|
||||
"exact_source_frame": True,
|
||||
}
|
||||
return {
|
||||
**e30_review_item_summary(row),
|
||||
"camera_frame": camera_document,
|
||||
"selected": {
|
||||
"source_indices": arrays["selected_source_indices"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
"points_map_xyz_m": arrays["selected_points_map_xyz_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
},
|
||||
"candidate": {
|
||||
"source_indices": arrays["candidate_source_indices"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
"points_map_xyz_m": arrays["candidate_points_map_xyz_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
},
|
||||
"projection": {
|
||||
"source_indices": arrays["projected_source_indices"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
"points_map_xyz_m": arrays["projected_points_map_xyz_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
"pixels_xy": arrays["projected_pixels_xy"].astype(np.float64).tolist(),
|
||||
"depth_m": arrays["projected_depth_m"].astype(np.float64).tolist(),
|
||||
"point_class": arrays["projected_point_class"].astype(np.int64).tolist(),
|
||||
"point_height_m": arrays["projected_point_height_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
"candidate_mask": arrays["projected_candidate_mask"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
"selected_mask": arrays["projected_selected_mask"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
},
|
||||
"pose": {
|
||||
"position_map_xyz_m": arrays["sensor_position_map_xyz_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
"orientation_map_from_lidar_xyzw": arrays[
|
||||
"sensor_orientation_map_from_lidar_xyzw"
|
||||
]
|
||||
.astype(np.float64)
|
||||
.tolist(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _materialization(
|
||||
candidate: Path,
|
||||
review_root: Path,
|
||||
) -> tuple[dict[str, Any], tuple[dict[str, Any], ...]]:
|
||||
return _read_materialization_cached(
|
||||
str(candidate.resolve()),
|
||||
str(review_root.resolve()),
|
||||
_signature(candidate),
|
||||
)
|
||||
|
||||
|
||||
def load_verified_e30_review(
|
||||
*,
|
||||
materialization_root: Path,
|
||||
review_pack_root: Path,
|
||||
result_id: str,
|
||||
) -> tuple[
|
||||
dict[str, Any],
|
||||
tuple[dict[str, Any], ...],
|
||||
E30ReviewSubstrate,
|
||||
]:
|
||||
"""Load the verified A2 evidence and its exact A3 source binding."""
|
||||
|
||||
materialization_root = materialization_root.resolve()
|
||||
review_pack_root = review_pack_root.resolve()
|
||||
if (
|
||||
not materialization_root.is_dir()
|
||||
or not review_pack_root.is_dir()
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 materialization is unavailable")
|
||||
candidate = materialization_root / result_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise E30ReviewEvidenceError("E30 materialization is unavailable")
|
||||
catalog, rows = _materialization(candidate, review_pack_root)
|
||||
manifest = _read_json(
|
||||
candidate / E30_MATERIALIZATION_MANIFEST_NAME,
|
||||
_MAX_MANIFEST_BYTES,
|
||||
)
|
||||
identity = manifest.get("identity")
|
||||
review_binding = identity.get("review_pack") if isinstance(identity, dict) else None
|
||||
if (
|
||||
not isinstance(identity, dict)
|
||||
or not isinstance(review_binding, dict)
|
||||
or not isinstance(manifest.get("identity_sha256"), str)
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 reviewer source binding is incomplete")
|
||||
substrate = E30ReviewSubstrate(
|
||||
materialization_id=result_id,
|
||||
materialization_identity_sha256=cast(str, manifest["identity_sha256"]),
|
||||
review_pack_id=_string(review_binding, "result_id"),
|
||||
review_items_sha256=_sha_value(review_binding, "items_sha256"),
|
||||
reason_taxonomy=tuple(cast(list[str], catalog["reason_taxonomy"])),
|
||||
subjects=tuple(
|
||||
E30ReviewSubject(
|
||||
item_id=cast(str, row["item_id"]),
|
||||
sequence=cast(int, row["sequence"]),
|
||||
source_stratum=cast(E30Stratum, row["stratum"]),
|
||||
)
|
||||
for row in rows
|
||||
),
|
||||
)
|
||||
return catalog, rows, substrate
|
||||
|
||||
|
||||
def _string(document: dict[str, Any], key: str) -> str:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise E30ReviewEvidenceError(f"{key} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _sha_value(document: dict[str, Any], key: str) -> str:
|
||||
value = _string(document, key)
|
||||
if _SHA256.fullmatch(value) is None:
|
||||
raise E30ReviewEvidenceError(f"{key} must be a SHA-256 digest")
|
||||
return value
|
||||
|
||||
|
||||
def build_e30_review_router(
|
||||
*,
|
||||
materialization_root_provider: RootProvider = lambda: None,
|
||||
review_pack_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/e30", tags=["laboratory"])
|
||||
|
||||
@router.get("/reviews")
|
||||
def list_reviews(
|
||||
limit: int = Query(default=1, ge=1, le=10),
|
||||
) -> dict[str, object]:
|
||||
roots = _roots(
|
||||
materialization_root_provider=materialization_root_provider,
|
||||
review_pack_root_provider=review_pack_root_provider,
|
||||
)
|
||||
if roots is None:
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_CATALOG_SCHEMA,
|
||||
"configured": False,
|
||||
"items": [],
|
||||
"candidate_total": 0,
|
||||
"invalid_total": 0,
|
||||
"access": "read-only",
|
||||
}
|
||||
materialization_root, review_root = roots
|
||||
candidates = sorted(
|
||||
(
|
||||
candidate
|
||||
for candidate in materialization_root.iterdir()
|
||||
if candidate.is_dir()
|
||||
and _MATERIALIZATION_ID.fullmatch(candidate.name) is not None
|
||||
),
|
||||
key=lambda candidate: candidate.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
for candidate in candidates:
|
||||
try:
|
||||
item, _ = _materialization(candidate, review_root)
|
||||
if len(items) < limit:
|
||||
items.append(copy.deepcopy(item))
|
||||
except (E30ReviewEvidenceError, OSError):
|
||||
invalid_total += 1
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_CATALOG_SCHEMA,
|
||||
"configured": True,
|
||||
"items": items,
|
||||
"candidate_total": len(candidates),
|
||||
"invalid_total": invalid_total,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
@router.get("/reviews/{result_id}/items")
|
||||
def list_review_items(
|
||||
result_id: str,
|
||||
stratum: Annotated[Stratum, Query()] = "conflict",
|
||||
limit: Annotated[int, Query(ge=1, le=128)] = 48,
|
||||
cursor: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> dict[str, object]:
|
||||
roots = _roots(
|
||||
materialization_root_provider=materialization_root_provider,
|
||||
review_pack_root_provider=review_pack_root_provider,
|
||||
)
|
||||
if roots is None:
|
||||
raise HTTPException(status_code=404, detail="E30 review не найден")
|
||||
materialization_root, review_root = roots
|
||||
candidate = _candidate(materialization_root, result_id)
|
||||
try:
|
||||
catalog, rows = _materialization(candidate, review_root)
|
||||
filtered = [row for row in rows if row.get("stratum") == stratum]
|
||||
page = filtered[cursor : cursor + limit]
|
||||
next_cursor = cursor + len(page)
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ITEMS_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"stratum": stratum,
|
||||
"items": [e30_review_item_summary(row) for row in page],
|
||||
"total": len(filtered),
|
||||
"next_cursor": next_cursor if next_cursor < len(filtered) else None,
|
||||
"reason_taxonomy": catalog["reason_taxonomy"],
|
||||
"access": "read-only",
|
||||
}
|
||||
except (E30ReviewEvidenceError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 review evidence не прошло проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.get("/reviews/{result_id}/items/{item_id}")
|
||||
def get_review_item(
|
||||
result_id: str,
|
||||
item_id: str,
|
||||
) -> dict[str, object]:
|
||||
roots = _roots(
|
||||
materialization_root_provider=materialization_root_provider,
|
||||
review_pack_root_provider=review_pack_root_provider,
|
||||
)
|
||||
if roots is None or _REVIEW_ITEM_ID.fullmatch(item_id) is None:
|
||||
raise HTTPException(status_code=404, detail="E30 review item не найден")
|
||||
materialization_root, review_root = roots
|
||||
candidate = _candidate(materialization_root, result_id)
|
||||
try:
|
||||
_, rows = _materialization(candidate, review_root)
|
||||
row = next((value for value in rows if value.get("item_id") == item_id), None)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="E30 review item не найден",
|
||||
)
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ITEM_DETAIL_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"item": _item_detail(candidate, row),
|
||||
"access": "read-only",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except (E30ReviewEvidenceError, OSError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 review item не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.get("/reviews/{result_id}/items/{item_id}/camera-frame")
|
||||
def get_review_camera_frame(
|
||||
result_id: str,
|
||||
item_id: str,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
) -> FileResponse:
|
||||
roots = _roots(
|
||||
materialization_root_provider=materialization_root_provider,
|
||||
review_pack_root_provider=review_pack_root_provider,
|
||||
)
|
||||
if (
|
||||
roots is None
|
||||
or _REVIEW_ITEM_ID.fullmatch(item_id) is None
|
||||
or _SHA256.fullmatch(generation) is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="E30 camera frame не найден",
|
||||
)
|
||||
materialization_root, review_root = roots
|
||||
candidate = _candidate(materialization_root, result_id)
|
||||
try:
|
||||
_, rows = _materialization(candidate, review_root)
|
||||
row = next(
|
||||
(value for value in rows if value.get("item_id") == item_id),
|
||||
None,
|
||||
)
|
||||
if row is None or row.get("camera_frame") is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="E30 camera frame не найден",
|
||||
)
|
||||
path, artifact = _artifact(
|
||||
candidate,
|
||||
row["camera_frame"],
|
||||
expected_role="camera-frame",
|
||||
)
|
||||
if artifact.get("sha256") != generation:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 camera generation изменилась",
|
||||
)
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="image/jpeg",
|
||||
filename=f"e30-frame-{artifact['source_frame_index']:06d}.jpg",
|
||||
headers={
|
||||
"ETag": f'"{generation}"',
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except (E30ReviewEvidenceError, OSError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 camera frame не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user