feat(lab): add lazy E40 evidence review
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
@@ -58,6 +59,10 @@ from k1link.compute.e40_perception_product_gate import (
|
||||
LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.laboratory-advanced-catalog/v1"
|
||||
)
|
||||
LABORATORY_ADVANCED_INDEX_SCHEMA: Final = (
|
||||
"missioncore.laboratory-advanced-index/v1"
|
||||
)
|
||||
_INDEX_DOCUMENT_MAX_BYTES: Final = 64 * 1024
|
||||
|
||||
_E31_RESULT_ID = re.compile(r"^e31-source-qualification-[a-f0-9]{64}$")
|
||||
_E32_RESULT_ID = re.compile(r"^e32-track-geometry-[a-f0-9]{64}$")
|
||||
@@ -71,6 +76,14 @@ _E40_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$")
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
_AdvancedIndexSpec = tuple[
|
||||
str,
|
||||
RootProvider,
|
||||
re.Pattern[str],
|
||||
str,
|
||||
str,
|
||||
]
|
||||
|
||||
|
||||
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||
signature: list[int] = []
|
||||
@@ -193,6 +206,82 @@ def _candidates(root: Path, pattern: re.Pattern[str]) -> list[Path]:
|
||||
)
|
||||
|
||||
|
||||
def _advanced_index_item(
|
||||
candidate: Path,
|
||||
*,
|
||||
work_id: str,
|
||||
document_name: str,
|
||||
schema_version: str,
|
||||
) -> dict[str, object]:
|
||||
document_path = candidate / document_name
|
||||
if document_path.is_symlink() or not document_path.is_file():
|
||||
raise ValueError("advanced LAB index document is missing")
|
||||
if document_path.stat().st_size > _INDEX_DOCUMENT_MAX_BYTES:
|
||||
raise ValueError("advanced LAB index document is too large")
|
||||
payload = json.loads(document_path.read_text(encoding="utf-8"))
|
||||
document = _object(payload, "advanced LAB index document")
|
||||
if document.get("schema_version") != schema_version:
|
||||
raise ValueError("advanced LAB index schema is invalid")
|
||||
if document.get("result_id") != candidate.name:
|
||||
raise ValueError("advanced LAB index result identity is invalid")
|
||||
identity_sha256 = document.get("identity_sha256")
|
||||
if (
|
||||
not isinstance(identity_sha256, str)
|
||||
or re.fullmatch(r"[a-f0-9]{64}", identity_sha256) is None
|
||||
or not candidate.name.endswith(identity_sha256)
|
||||
):
|
||||
raise ValueError("advanced LAB index digest is invalid")
|
||||
identity = _object(document.get("identity"), "advanced LAB identity")
|
||||
authority = _object(
|
||||
identity.get("authority"),
|
||||
"advanced LAB authority",
|
||||
)
|
||||
if (
|
||||
authority.get("commands_enabled") is not False
|
||||
or authority.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise ValueError("advanced LAB authority is invalid")
|
||||
if document.get("ground_truth") not in (None, False):
|
||||
raise ValueError("advanced LAB ground-truth claim is invalid")
|
||||
created_at_utc = document.get("created_at_utc")
|
||||
if not isinstance(created_at_utc, str) or not created_at_utc.strip():
|
||||
raise ValueError("advanced LAB creation time is invalid")
|
||||
return {
|
||||
"work_id": work_id,
|
||||
"result_id": candidate.name,
|
||||
"created_at_utc": created_at_utc,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _advanced_index(
|
||||
specs: tuple[_AdvancedIndexSpec, ...],
|
||||
) -> dict[str, object]:
|
||||
items: list[dict[str, object]] = []
|
||||
for work_id, provider, pattern, document_name, schema_version in specs:
|
||||
root = _configured_root(provider)
|
||||
if root is None:
|
||||
continue
|
||||
for candidate in _candidates(root, pattern):
|
||||
try:
|
||||
items.append(
|
||||
_advanced_index_item(
|
||||
candidate,
|
||||
work_id=work_id,
|
||||
document_name=document_name,
|
||||
schema_version=schema_version,
|
||||
)
|
||||
)
|
||||
break
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
continue
|
||||
return {
|
||||
"schema_version": LABORATORY_ADVANCED_INDEX_SCHEMA,
|
||||
"items": items,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} is invalid")
|
||||
@@ -739,6 +828,76 @@ def build_advanced_laboratory_router(
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"])
|
||||
|
||||
@router.get("/advanced-index")
|
||||
def list_advanced_results() -> dict[str, object]:
|
||||
return _advanced_index(
|
||||
(
|
||||
(
|
||||
"e31-source-binding",
|
||||
e31_root_provider,
|
||||
_E31_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e31-source-qualification/v1",
|
||||
),
|
||||
(
|
||||
"e32-track-geometry",
|
||||
e32_root_provider,
|
||||
_E32_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e32-track-geometry-replay/v1",
|
||||
),
|
||||
(
|
||||
"e33-worker-shadow",
|
||||
e33_root_provider,
|
||||
_E33_RESULT_ID,
|
||||
"result.json",
|
||||
"missioncore.e33-worker-shadow-result/v1",
|
||||
),
|
||||
(
|
||||
"e34-temporal-layer",
|
||||
e34_root_provider,
|
||||
_E34_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e34-temporal-occupied-result/v1",
|
||||
),
|
||||
(
|
||||
"e35-degradation-recovery",
|
||||
e35_root_provider,
|
||||
_E35_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e35-degradation-result/v1",
|
||||
),
|
||||
(
|
||||
"e37-ravnoves-acceptance",
|
||||
e37_root_provider,
|
||||
_E37_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e37-acceptance-contract/v1",
|
||||
),
|
||||
(
|
||||
"e38-perception-baseline",
|
||||
e38_root_provider,
|
||||
_E38_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e38-perception-baseline/v1",
|
||||
),
|
||||
(
|
||||
"e39-perception-refinement",
|
||||
e39_root_provider,
|
||||
_E39_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e39-perception-refinement/v1",
|
||||
),
|
||||
(
|
||||
"e40-perception-product-gate",
|
||||
e40_root_provider,
|
||||
_E40_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e40-perception-product-gate/v1",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("/e31/results")
|
||||
def list_e31_results(
|
||||
limit: int = Query(default=1, ge=1, le=10),
|
||||
|
||||
@@ -40,6 +40,7 @@ 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.e40_case_review_api import build_e40_case_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
|
||||
@@ -614,6 +615,24 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e40_case_review_router(
|
||||
e40_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e40"
|
||||
/ "results"
|
||||
),
|
||||
operator_review_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e40"
|
||||
/ "operator-reviews"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e30_engineering_router(
|
||||
generation_root_provider=lambda: (
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import Path as ApiPath
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.compute.e40_operator_review import (
|
||||
E40OperatorReviewConflictError,
|
||||
E40OperatorReviewIntegrityError,
|
||||
E40OperatorReviewStore,
|
||||
E40OperatorReviewSubject,
|
||||
E40OperatorReviewSubstrate,
|
||||
E40OperatorReviewValidationError,
|
||||
)
|
||||
from k1link.compute.e40_perception_product_gate import (
|
||||
E40_PREDICTION_SCHEMA,
|
||||
E40_PREDICTIONS_NAME,
|
||||
E40PerceptionProductGate,
|
||||
E40PerceptionProductGateError,
|
||||
read_e40_perception_product_gate,
|
||||
)
|
||||
|
||||
LABORATORY_E40_CASE_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e40-case-catalog/v1"
|
||||
)
|
||||
|
||||
_RESULT_ID = re.compile(r"^e40-perception-product-gate-[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}$")
|
||||
_MAX_PREDICTIONS_BYTES: Final = 8 * 1024 * 1024
|
||||
_AUTHORITY: Final = {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
_DIMENSIONS: Final = (
|
||||
"presence",
|
||||
"geometry_association",
|
||||
"freshness",
|
||||
)
|
||||
_STRATA: Final = {
|
||||
"agree",
|
||||
"camera-only",
|
||||
"conflict",
|
||||
"geometry-only",
|
||||
"unknown",
|
||||
}
|
||||
_SEVERITIES: Final = {"high", "medium", "standard"}
|
||||
_SPLITS: Final = {"development", "validation"}
|
||||
_VALUES: Final = {
|
||||
"presence": {
|
||||
"background-or-noise",
|
||||
"object-present",
|
||||
"occupied-environment",
|
||||
},
|
||||
"geometry_association": {
|
||||
"independent-occupied",
|
||||
"insufficient-support",
|
||||
"object-associated",
|
||||
"rejected-nonobject",
|
||||
"unknown",
|
||||
},
|
||||
"freshness": {"current", "stale", "unavailable"},
|
||||
}
|
||||
_SEVERITY_ORDER: Final = {"high": 0, "medium": 1, "standard": 2}
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
|
||||
class E40OperatorVerdictRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reviewer_id: str = Field(min_length=1, max_length=128)
|
||||
expected_revision: int = Field(ge=0)
|
||||
idempotency_key: str = Field(min_length=1, max_length=128)
|
||||
verdict: Literal["confirmed-error", "rejected-error"]
|
||||
|
||||
|
||||
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||
signature: list[int] = []
|
||||
for path in sorted(root.iterdir(), key=lambda item: item.name):
|
||||
if not path.is_file() or path.is_symlink():
|
||||
continue
|
||||
stat = path.stat()
|
||||
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||
return tuple(signature)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _read_result_cached(
|
||||
root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> E40PerceptionProductGate:
|
||||
del signature
|
||||
return read_e40_perception_product_gate(Path(root_text))
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
root = provider()
|
||||
if root is None:
|
||||
return None
|
||||
resolved = root.resolve()
|
||||
return resolved if resolved.is_dir() else None
|
||||
|
||||
|
||||
def _candidate(root: Path, result_id: str) -> Path:
|
||||
if _RESULT_ID.fullmatch(result_id) is None:
|
||||
raise HTTPException(status_code=404, detail="E40 result не найден")
|
||||
candidate = root / result_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise HTTPException(status_code=404, detail="E40 result не найден")
|
||||
return candidate
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise E40PerceptionProductGateError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _nonnegative_integer(value: object, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise E40PerceptionProductGateError(
|
||||
f"{label} must be a nonnegative integer"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_number(value: object, label: str) -> float:
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (int, float))
|
||||
or not 0 <= float(value) <= 1
|
||||
):
|
||||
raise E40PerceptionProductGateError(f"{label} must be within [0, 1]")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _dimension_state(value: object, label: str) -> dict[str, str]:
|
||||
source = _object(value, label)
|
||||
if set(source) != set(_DIMENSIONS):
|
||||
raise E40PerceptionProductGateError(
|
||||
f"{label} has incompatible dimensions"
|
||||
)
|
||||
state: dict[str, str] = {}
|
||||
for dimension in _DIMENSIONS:
|
||||
item = source.get(dimension)
|
||||
if not isinstance(item, str) or item not in _VALUES[dimension]:
|
||||
raise E40PerceptionProductGateError(
|
||||
f"{label}.{dimension} is invalid"
|
||||
)
|
||||
state[dimension] = item
|
||||
return state
|
||||
|
||||
|
||||
def _read_predictions(result: E40PerceptionProductGate) -> tuple[dict[str, Any], ...]:
|
||||
path = result.result_root / E40_PREDICTIONS_NAME
|
||||
if (
|
||||
not path.is_file()
|
||||
or path.is_symlink()
|
||||
or path.stat().st_size > _MAX_PREDICTIONS_BYTES
|
||||
):
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 sealed predictions are unavailable"
|
||||
)
|
||||
rows: list[dict[str, Any]] = []
|
||||
sequences: set[int] = set()
|
||||
item_ids: set[str] = set()
|
||||
try:
|
||||
with path.open("r", encoding="utf-8-sig") as stream:
|
||||
for line in stream:
|
||||
row = _object(json.loads(line), E40_PREDICTIONS_NAME)
|
||||
sequence = _nonnegative_integer(
|
||||
row.get("sequence"),
|
||||
"E40 prediction.sequence",
|
||||
)
|
||||
item_id = row.get("item_id")
|
||||
review_key = row.get("review_key")
|
||||
source_frame_index = _nonnegative_integer(
|
||||
row.get("source_frame_index"),
|
||||
"E40 prediction.source_frame_index",
|
||||
)
|
||||
stratum = row.get("source_stratum")
|
||||
severity = row.get("severity")
|
||||
split = row.get("split")
|
||||
if (
|
||||
row.get("schema_version") != E40_PREDICTION_SCHEMA
|
||||
or not isinstance(item_id, str)
|
||||
or _ITEM_ID.fullmatch(item_id) is None
|
||||
or not isinstance(review_key, str)
|
||||
or not review_key
|
||||
or stratum not in _STRATA
|
||||
or severity not in _SEVERITIES
|
||||
or split not in _SPLITS
|
||||
or row.get("scored") is not (split == "validation")
|
||||
or row.get("authority") != _AUTHORITY
|
||||
or sequence in sequences
|
||||
or item_id in item_ids
|
||||
):
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 prediction identity is invalid"
|
||||
)
|
||||
prediction = _dimension_state(
|
||||
row.get("prediction"),
|
||||
"E40 prediction.prediction",
|
||||
)
|
||||
reference = _dimension_state(
|
||||
row.get("reference"),
|
||||
"E40 prediction.reference",
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"sequence": sequence,
|
||||
"item_id": item_id,
|
||||
"review_key": review_key,
|
||||
"source_frame_index": source_frame_index,
|
||||
"source_stratum": stratum,
|
||||
"severity": severity,
|
||||
"split": split,
|
||||
"prediction": prediction,
|
||||
"reference": reference,
|
||||
"presence_confidence": _bounded_number(
|
||||
row.get("presence_confidence"),
|
||||
"E40 prediction.presence_confidence",
|
||||
),
|
||||
}
|
||||
)
|
||||
sequences.add(sequence)
|
||||
item_ids.add(item_id)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 sealed predictions are invalid"
|
||||
) from exc
|
||||
validation_total = _object(
|
||||
result.report.get("metrics"),
|
||||
"E40 metrics",
|
||||
).get("validation_items")
|
||||
if (
|
||||
not rows
|
||||
or _nonnegative_integer(validation_total, "E40 validation_items")
|
||||
!= sum(row["split"] == "validation" for row in rows)
|
||||
):
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 sealed validation denominator changed"
|
||||
)
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def _materialization_id(result: E40PerceptionProductGate) -> str:
|
||||
identity = _object(result.manifest.get("identity"), "E40 identity")
|
||||
source = _object(identity.get("source"), "E40 source")
|
||||
value = source.get("materialization_id")
|
||||
if not isinstance(value, str) or _MATERIALIZATION_ID.fullmatch(value) is None:
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 materialization binding is invalid"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _case(row: dict[str, Any]) -> dict[str, object]:
|
||||
reference = _object(row["reference"], "E40 reference")
|
||||
prediction = _object(row["prediction"], "E40 prediction")
|
||||
source_stratum = str(row["source_stratum"])
|
||||
mismatched = [
|
||||
dimension
|
||||
for dimension in _DIMENSIONS
|
||||
if reference[dimension] != prediction[dimension]
|
||||
]
|
||||
return {
|
||||
"item_id": row["item_id"],
|
||||
"sequence": row["sequence"],
|
||||
"source_frame_index": row["source_frame_index"],
|
||||
"source_stratum": source_stratum,
|
||||
"severity": row["severity"],
|
||||
"presence_confidence": row["presence_confidence"],
|
||||
"prediction_basis": (
|
||||
"camera-only-softmax"
|
||||
if source_stratum == "camera-only"
|
||||
else "fixed-stratum-policy"
|
||||
),
|
||||
"reference": dict(reference),
|
||||
"prediction": dict(prediction),
|
||||
"mismatched_dimensions": mismatched,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _load_catalog(
|
||||
root_provider: RootProvider,
|
||||
result_id: str,
|
||||
) -> tuple[dict[str, object], E40OperatorReviewSubstrate]:
|
||||
root = _configured_root(root_provider)
|
||||
if root is None:
|
||||
raise HTTPException(status_code=404, detail="E40 result не найден")
|
||||
candidate = _candidate(root, result_id)
|
||||
result = _read_result_cached(
|
||||
str(candidate.resolve()),
|
||||
_result_signature(candidate),
|
||||
)
|
||||
rows = _read_predictions(result)
|
||||
errors = [
|
||||
row
|
||||
for row in rows
|
||||
if row["split"] == "validation"
|
||||
and any(
|
||||
row["reference"][dimension] != row["prediction"][dimension]
|
||||
for dimension in _DIMENSIONS
|
||||
)
|
||||
]
|
||||
errors.sort(
|
||||
key=lambda row: (
|
||||
_SEVERITY_ORDER[str(row["severity"])],
|
||||
int(row["sequence"]),
|
||||
)
|
||||
)
|
||||
if not errors or len(errors) > 64:
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 error catalog is outside the review boundary"
|
||||
)
|
||||
materialization_id = _materialization_id(result)
|
||||
cases = [_case(row) for row in errors]
|
||||
case_catalog_sha256 = hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
"result_id": result.result_id,
|
||||
"materialization_id": materialization_id,
|
||||
"items": cases,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
catalog: dict[str, object] = {
|
||||
"schema_version": LABORATORY_E40_CASE_CATALOG_SCHEMA,
|
||||
"result_id": result.result_id,
|
||||
"materialization_id": materialization_id,
|
||||
"items": cases,
|
||||
"total": len(errors),
|
||||
"truncated": False,
|
||||
"access": "read-only",
|
||||
}
|
||||
substrate = E40OperatorReviewSubstrate(
|
||||
result_id=result.result_id,
|
||||
materialization_id=materialization_id,
|
||||
case_catalog_sha256=case_catalog_sha256,
|
||||
subjects=tuple(
|
||||
E40OperatorReviewSubject(
|
||||
item_id=str(row["item_id"]),
|
||||
sequence=int(row["sequence"]),
|
||||
)
|
||||
for row in errors
|
||||
),
|
||||
)
|
||||
return catalog, substrate
|
||||
|
||||
|
||||
def build_e40_case_review_router(
|
||||
*,
|
||||
e40_root_provider: RootProvider = lambda: None,
|
||||
operator_review_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/e40", tags=["laboratory"])
|
||||
|
||||
def catalog_and_source(
|
||||
result_id: str,
|
||||
) -> tuple[dict[str, object], E40OperatorReviewSubstrate]:
|
||||
try:
|
||||
return _load_catalog(e40_root_provider, result_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except (
|
||||
E40PerceptionProductGateError,
|
||||
E40OperatorReviewValidationError,
|
||||
KeyError,
|
||||
OSError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E40 case-review не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
def store() -> E40OperatorReviewStore:
|
||||
root = operator_review_root_provider()
|
||||
if root is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="E40 operator review не настроен",
|
||||
)
|
||||
try:
|
||||
return E40OperatorReviewStore(root=root)
|
||||
except (E40OperatorReviewIntegrityError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="E40 operator review storage недоступен",
|
||||
) from exc
|
||||
|
||||
def invoke(
|
||||
operation: Callable[[], dict[str, object]],
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return operation()
|
||||
except E40OperatorReviewValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except E40OperatorReviewConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (E40OperatorReviewIntegrityError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E40 operator review не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.get("/results/{result_id}/cases")
|
||||
def list_e40_error_cases(
|
||||
result_id: str,
|
||||
limit: int = Query(default=48, ge=1, le=64),
|
||||
) -> dict[str, object]:
|
||||
catalog, _ = catalog_and_source(result_id)
|
||||
items = list(catalog["items"]) # type: ignore[arg-type]
|
||||
return {
|
||||
**catalog,
|
||||
"items": items[:limit],
|
||||
"truncated": len(items) > limit,
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/operator-review")
|
||||
def get_operator_review(
|
||||
result_id: str,
|
||||
reviewer_id: str = Query(
|
||||
default="DC",
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
_, substrate = catalog_and_source(result_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.get(
|
||||
substrate=substrate,
|
||||
reviewer_id=reviewer_id,
|
||||
)
|
||||
)
|
||||
|
||||
@router.put(
|
||||
"/results/{result_id}/operator-review/decisions/{item_id}"
|
||||
)
|
||||
def record_operator_verdict(
|
||||
result_id: str,
|
||||
item_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-review-item-[a-f0-9]{64}$"),
|
||||
],
|
||||
request: E40OperatorVerdictRequest,
|
||||
) -> dict[str, object]:
|
||||
_, substrate = catalog_and_source(result_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.record_verdict(
|
||||
substrate=substrate,
|
||||
reviewer_id=request.reviewer_id,
|
||||
item_id=item_id,
|
||||
expected_revision=request.expected_revision,
|
||||
idempotency_key=request.idempotency_key,
|
||||
verdict=request.verdict,
|
||||
)
|
||||
)
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user