refactor(lab): canonize selected evidence reports
This commit is contained in:
@@ -6,10 +6,32 @@ from k1link.laboratory.evidence_registry import (
|
||||
LaboratoryEvidenceRegistry,
|
||||
LaboratoryRegistryError,
|
||||
)
|
||||
from k1link.laboratory.evidence_report import (
|
||||
LABORATORY_EVIDENCE_REPORT_SCHEMA,
|
||||
LaboratoryEvidenceReportError,
|
||||
LaboratoryEvidenceReportNotFound,
|
||||
LaboratoryEvidenceReportService,
|
||||
)
|
||||
from k1link.laboratory.value_review_registry import (
|
||||
LABORATORY_VALUE_REVIEW_INDEX_SCHEMA,
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA,
|
||||
LaboratoryValueReviewEntry,
|
||||
LaboratoryValueReviewRegistry,
|
||||
LaboratoryValueReviewRegistryError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LABORATORY_EVIDENCE_DEFINITION_SCHEMA",
|
||||
"LABORATORY_EVIDENCE_REPORT_SCHEMA",
|
||||
"LaboratoryEvidenceDefinition",
|
||||
"LaboratoryEvidenceRegistry",
|
||||
"LaboratoryEvidenceReportError",
|
||||
"LaboratoryEvidenceReportNotFound",
|
||||
"LaboratoryEvidenceReportService",
|
||||
"LaboratoryRegistryError",
|
||||
"LABORATORY_VALUE_REVIEW_INDEX_SCHEMA",
|
||||
"LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA",
|
||||
"LaboratoryValueReviewEntry",
|
||||
"LaboratoryValueReviewRegistry",
|
||||
"LaboratoryValueReviewRegistryError",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final
|
||||
|
||||
from k1link.laboratory.evidence_registry import (
|
||||
LaboratoryEvidenceDefinition,
|
||||
LaboratoryEvidenceRegistry,
|
||||
)
|
||||
|
||||
LABORATORY_EVIDENCE_REPORT_SCHEMA: Final = "missioncore.laboratory-evidence-report/v1"
|
||||
_DOCUMENT_MAX_BYTES: Final = 1024 * 1024
|
||||
_ARTIFACT_LIMIT: Final = 256
|
||||
_HASH_CHUNK_BYTES: Final = 1024 * 1024
|
||||
|
||||
RuntimeRootProvider = Callable[[], Path | None]
|
||||
|
||||
|
||||
class LaboratoryEvidenceReportError(ValueError):
|
||||
"""Raised when immutable LAB evidence cannot be verified or projected."""
|
||||
|
||||
|
||||
class LaboratoryEvidenceReportNotFound(LaboratoryEvidenceReportError):
|
||||
"""Raised when the requested evidence identity is not available."""
|
||||
|
||||
|
||||
class LaboratoryEvidenceReportService:
|
||||
def __init__(
|
||||
self,
|
||||
registry: LaboratoryEvidenceRegistry,
|
||||
runtime_root_provider: RuntimeRootProvider,
|
||||
) -> None:
|
||||
self._definitions = {
|
||||
definition.work_id: definition for definition in registry.definitions
|
||||
}
|
||||
self._runtime_root_provider = runtime_root_provider
|
||||
|
||||
def read(self, work_id: str, result_id: str) -> dict[str, object]:
|
||||
definition = self._definitions.get(work_id)
|
||||
if definition is None or definition.result_id_pattern.fullmatch(result_id) is None:
|
||||
raise LaboratoryEvidenceReportNotFound("LAB evidence identity is unknown")
|
||||
result_root = self._result_root(definition, result_id)
|
||||
document_path = _safe_file(result_root, definition.document_name)
|
||||
document_bytes = _read_bounded(document_path, _DOCUMENT_MAX_BYTES, "LAB document")
|
||||
document = _json_object(document_bytes, "LAB document")
|
||||
_validate_document(document, definition, result_id)
|
||||
|
||||
identity = _object_or_none(document.get("identity"))
|
||||
identity_sha256 = document.get("identity_sha256")
|
||||
if identity is None or not isinstance(identity_sha256, str):
|
||||
raise LaboratoryEvidenceReportError("LAB identity proof is missing")
|
||||
actual_identity_sha256 = _canonical_sha256(identity)
|
||||
if actual_identity_sha256 != identity_sha256 or not result_id.endswith(identity_sha256):
|
||||
raise LaboratoryEvidenceReportError("LAB identity proof is invalid")
|
||||
|
||||
artifacts = _verified_artifacts(result_root, document.get("artifacts"))
|
||||
report_descriptor = _report_descriptor(artifacts)
|
||||
report = (
|
||||
_read_json_artifact(result_root, report_descriptor, "LAB report")
|
||||
if report_descriptor is not None
|
||||
else document
|
||||
)
|
||||
runtime_descriptor = _runtime_descriptor(artifacts)
|
||||
runtime = (
|
||||
_read_json_artifact(result_root, runtime_descriptor, "LAB runtime")
|
||||
if runtime_descriptor is not None
|
||||
else None
|
||||
)
|
||||
|
||||
source = _first_object(
|
||||
report.get("source"),
|
||||
identity.get("source"),
|
||||
_nested(identity, "profile", "source"),
|
||||
) or _source_projection(report, identity)
|
||||
configuration = _configuration_projection(report, identity)
|
||||
method = _first_object(
|
||||
report.get("method"),
|
||||
identity.get("method"),
|
||||
identity.get("profile"),
|
||||
)
|
||||
execution = _first_object(
|
||||
report.get("execution"),
|
||||
runtime,
|
||||
identity.get("execution"),
|
||||
identity.get("worker"),
|
||||
report.get("worker"),
|
||||
)
|
||||
metrics = _first_object(report.get("metrics"), _nested(runtime, "metrics"))
|
||||
resources = _first_object(
|
||||
_nested(report, "metrics", "resources"),
|
||||
_nested(runtime, "metrics", "resources"),
|
||||
_nested(runtime, "metrics", "gpu"),
|
||||
)
|
||||
gates = _first_object(
|
||||
report.get("acceptance"),
|
||||
report.get("quality_gate"),
|
||||
report.get("acceptance_requirements"),
|
||||
_nested(runtime, "acceptance"),
|
||||
)
|
||||
decision = _json_value_or_none(report.get("decision"))
|
||||
limitations = _json_value_or_none(report.get("limitations"))
|
||||
authority = _first_object(
|
||||
report.get("authority"),
|
||||
identity.get("authority"),
|
||||
document.get("authority"),
|
||||
)
|
||||
visual_review = _first_object(
|
||||
report.get("visual_review"),
|
||||
report.get("visual_evidence"),
|
||||
)
|
||||
visual_artifacts = [
|
||||
artifact
|
||||
for artifact in artifacts
|
||||
if _is_visual_artifact(artifact)
|
||||
]
|
||||
completeness_values: dict[str, object | None] = {
|
||||
"identity": identity,
|
||||
"source": source,
|
||||
"configuration": configuration,
|
||||
"method": method,
|
||||
"execution": execution,
|
||||
"resources": resources,
|
||||
"metrics": metrics,
|
||||
"gates": gates,
|
||||
"decision": decision,
|
||||
"limitations": limitations,
|
||||
"authority": authority,
|
||||
"artifacts": artifacts or None,
|
||||
"visual_evidence": visual_review or (visual_artifacts or None),
|
||||
}
|
||||
return {
|
||||
"schema_version": LABORATORY_EVIDENCE_REPORT_SCHEMA,
|
||||
"work_id": work_id,
|
||||
"result_id": result_id,
|
||||
"created_at_utc": _optional_text(document.get("created_at_utc")),
|
||||
"access": "read-only",
|
||||
"proof": {
|
||||
"document_schema_version": document.get("schema_version"),
|
||||
"document_sha256": hashlib.sha256(document_bytes).hexdigest(),
|
||||
"identity_sha256": identity_sha256,
|
||||
"report_schema_version": report.get("schema_version"),
|
||||
"report_sha256": (
|
||||
report_descriptor["sha256"] if report_descriptor is not None else None
|
||||
),
|
||||
"artifact_count": len(artifacts),
|
||||
"verified_artifact_count": len(artifacts),
|
||||
},
|
||||
"completeness": {
|
||||
key: "recorded" if value is not None else "not-recorded"
|
||||
for key, value in completeness_values.items()
|
||||
},
|
||||
"identity": identity,
|
||||
"source": source,
|
||||
"configuration": configuration,
|
||||
"method": method,
|
||||
"execution": execution,
|
||||
"resources": resources,
|
||||
"metrics": metrics,
|
||||
"gates": gates,
|
||||
"decision": decision,
|
||||
"limitations": limitations,
|
||||
"authority": authority,
|
||||
"artifacts": artifacts,
|
||||
"visual_evidence": {
|
||||
"review": visual_review,
|
||||
"artifacts": visual_artifacts,
|
||||
},
|
||||
"raw_report": report,
|
||||
}
|
||||
|
||||
def _result_root(
|
||||
self,
|
||||
definition: LaboratoryEvidenceDefinition,
|
||||
result_id: str,
|
||||
) -> Path:
|
||||
configured = self._runtime_root_provider()
|
||||
if configured is None:
|
||||
raise LaboratoryEvidenceReportNotFound("LAB runtime root is unavailable")
|
||||
runtime_root = configured.expanduser().absolute()
|
||||
if runtime_root.is_symlink():
|
||||
raise LaboratoryEvidenceReportError("LAB runtime root must not be a symlink")
|
||||
try:
|
||||
runtime_root = runtime_root.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise LaboratoryEvidenceReportNotFound("LAB runtime root is unavailable") from exc
|
||||
candidate = definition.result_root(runtime_root) / result_id
|
||||
if candidate.is_symlink():
|
||||
raise LaboratoryEvidenceReportError("LAB result must not be a symlink")
|
||||
try:
|
||||
result_root = candidate.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise LaboratoryEvidenceReportNotFound("LAB evidence result is unavailable") from exc
|
||||
if not result_root.is_dir() or not result_root.is_relative_to(runtime_root):
|
||||
raise LaboratoryEvidenceReportError("LAB evidence result path is invalid")
|
||||
return result_root
|
||||
|
||||
|
||||
def _validate_document(
|
||||
document: dict[str, Any],
|
||||
definition: LaboratoryEvidenceDefinition,
|
||||
result_id: str,
|
||||
) -> None:
|
||||
if document.get("schema_version") != definition.result_schema_version:
|
||||
raise LaboratoryEvidenceReportError("LAB document schema is invalid")
|
||||
if document.get("result_id") != result_id:
|
||||
raise LaboratoryEvidenceReportError("LAB result identity is invalid")
|
||||
|
||||
|
||||
def _verified_artifacts(result_root: Path, value: object) -> list[dict[str, object]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or len(value) > _ARTIFACT_LIMIT:
|
||||
raise LaboratoryEvidenceReportError("LAB artifact manifest is invalid")
|
||||
verified: list[dict[str, object]] = []
|
||||
for index, item in enumerate(value):
|
||||
descriptor = _object_or_none(item)
|
||||
if descriptor is None:
|
||||
raise LaboratoryEvidenceReportError(f"LAB artifact {index} is invalid")
|
||||
path_value = descriptor.get("path")
|
||||
byte_length = descriptor.get("byte_length")
|
||||
sha256 = descriptor.get("sha256")
|
||||
if (
|
||||
not isinstance(path_value, str)
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or byte_length < 0
|
||||
or not isinstance(sha256, str)
|
||||
or len(sha256) != 64
|
||||
):
|
||||
raise LaboratoryEvidenceReportError(f"LAB artifact {index} proof is invalid")
|
||||
path = _safe_file(result_root, path_value)
|
||||
if path.stat().st_size != byte_length or _file_sha256(path) != sha256:
|
||||
raise LaboratoryEvidenceReportError(f"LAB artifact {index} proof does not match")
|
||||
verified.append(
|
||||
{
|
||||
"kind": _optional_text(descriptor.get("role") or descriptor.get("kind")),
|
||||
"path": path_value,
|
||||
"byte_length": byte_length,
|
||||
"sha256": sha256,
|
||||
"schema_version": _optional_text(descriptor.get("schema_version")),
|
||||
"media_type": _optional_text(descriptor.get("media_type")),
|
||||
"verified": True,
|
||||
}
|
||||
)
|
||||
return verified
|
||||
|
||||
|
||||
def _safe_file(root: Path, relative: str) -> Path:
|
||||
if not isinstance(relative, str) or "\\" in relative:
|
||||
raise LaboratoryEvidenceReportError("LAB artifact path is invalid")
|
||||
posix = PurePosixPath(relative)
|
||||
if (
|
||||
posix.is_absolute()
|
||||
or not posix.parts
|
||||
or str(posix) != relative
|
||||
or any(part in {"", ".", ".."} for part in posix.parts)
|
||||
):
|
||||
raise LaboratoryEvidenceReportError("LAB artifact path is invalid")
|
||||
candidate = root.joinpath(*posix.parts)
|
||||
current = root
|
||||
for part in posix.parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise LaboratoryEvidenceReportError("LAB artifact must not use symlinks")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise LaboratoryEvidenceReportError("LAB artifact is missing") from exc
|
||||
if not resolved.is_file() or not resolved.is_relative_to(root):
|
||||
raise LaboratoryEvidenceReportError("LAB artifact path escaped its result")
|
||||
return resolved
|
||||
|
||||
|
||||
def _read_bounded(path: Path, maximum: int, label: str) -> bytes:
|
||||
if path.stat().st_size > maximum:
|
||||
raise LaboratoryEvidenceReportError(f"{label} is too large")
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise LaboratoryEvidenceReportError(f"{label} is unreadable") from exc
|
||||
|
||||
|
||||
def _json_object(value: bytes, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
document = json.loads(value)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise LaboratoryEvidenceReportError(f"{label} is invalid JSON") from exc
|
||||
if not isinstance(document, dict) or not all(isinstance(key, str) for key in document):
|
||||
raise LaboratoryEvidenceReportError(f"{label} must be an object")
|
||||
return document
|
||||
|
||||
|
||||
def _read_json_artifact(
|
||||
root: Path,
|
||||
descriptor: dict[str, object],
|
||||
label: str,
|
||||
) -> dict[str, Any]:
|
||||
path_value = descriptor["path"]
|
||||
if not isinstance(path_value, str):
|
||||
raise LaboratoryEvidenceReportError(f"{label} path is invalid")
|
||||
encoded = _read_bounded(
|
||||
_safe_file(root, path_value),
|
||||
_DOCUMENT_MAX_BYTES,
|
||||
label,
|
||||
)
|
||||
return _json_object(encoded, label)
|
||||
|
||||
|
||||
def _report_descriptor(
|
||||
artifacts: list[dict[str, object]],
|
||||
) -> dict[str, object] | None:
|
||||
return next(
|
||||
(
|
||||
artifact
|
||||
for artifact in artifacts
|
||||
if "report" in str(artifact.get("kind") or "").lower()
|
||||
and str(artifact.get("path") or "").lower().endswith(".json")
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_descriptor(
|
||||
artifacts: list[dict[str, object]],
|
||||
) -> dict[str, object] | None:
|
||||
return next(
|
||||
(
|
||||
artifact
|
||||
for artifact in artifacts
|
||||
if "runtime" in str(artifact.get("kind") or "").lower()
|
||||
and str(artifact.get("path") or "").lower().endswith(".json")
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _is_visual_artifact(artifact: dict[str, object]) -> bool:
|
||||
kind = str(artifact.get("kind") or "").lower()
|
||||
media_type = str(artifact.get("media_type") or "").lower()
|
||||
suffix = Path(str(artifact.get("path") or "")).suffix.lower()
|
||||
return (
|
||||
any(token in kind for token in ("visual", "video", "overlay", "contact-sheet", "image"))
|
||||
or media_type.startswith(("image/", "video/"))
|
||||
or suffix in {".png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm"}
|
||||
)
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(_HASH_CHUNK_BYTES), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _object_or_none(value: object) -> dict[str, Any] | None:
|
||||
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _first_object(*values: object) -> dict[str, Any] | None:
|
||||
for value in values:
|
||||
document = _object_or_none(value)
|
||||
if document is not None:
|
||||
return document
|
||||
return None
|
||||
|
||||
|
||||
def _nested(value: object, *keys: str) -> object:
|
||||
current = value
|
||||
for key in keys:
|
||||
document = _object_or_none(current)
|
||||
if document is None:
|
||||
return None
|
||||
current = document.get(key)
|
||||
return current
|
||||
|
||||
|
||||
def _json_value_or_none(value: object) -> object | None:
|
||||
return value if value is not None else None
|
||||
|
||||
|
||||
def _source_projection(*documents: dict[str, Any]) -> dict[str, Any] | None:
|
||||
keys = {
|
||||
"camera_source_id",
|
||||
"frame_count",
|
||||
"route_frame_count",
|
||||
"session_id",
|
||||
"source_display_name",
|
||||
"source_id",
|
||||
"source_result_id",
|
||||
"source_session_id",
|
||||
"source_sha256",
|
||||
"stream_sha256",
|
||||
"upstream",
|
||||
}
|
||||
result: dict[str, Any] = {}
|
||||
for document in documents:
|
||||
for key, value in document.items():
|
||||
if key in keys or key.endswith("_source"):
|
||||
result.setdefault(key, value)
|
||||
return result or None
|
||||
|
||||
|
||||
def _configuration_projection(
|
||||
report: dict[str, Any],
|
||||
identity: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
keys = {
|
||||
"analysis_profile",
|
||||
"configuration",
|
||||
"detection",
|
||||
"detector",
|
||||
"parameters",
|
||||
"preprocessing",
|
||||
"profile",
|
||||
"valid_fov",
|
||||
}
|
||||
result: dict[str, Any] = {}
|
||||
for document in (report, identity):
|
||||
for key, value in document.items():
|
||||
if key in keys:
|
||||
result.setdefault(key, value)
|
||||
return result or None
|
||||
|
||||
|
||||
def _optional_text(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value.strip() else None
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final, Literal, cast
|
||||
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA: Final = (
|
||||
"missioncore.laboratory-value-review-registry/v1"
|
||||
)
|
||||
LABORATORY_VALUE_REVIEW_INDEX_SCHEMA: Final = "missioncore.laboratory-value-review-index/v1"
|
||||
_REGISTRY_MAX_BYTES: Final = 128 * 1024
|
||||
_CATALOG_ID = re.compile(r"^(?:[a-z][a-z0-9-]{2,95}|session:[A-Za-z0-9._:-]{3,128})$")
|
||||
_EVIDENCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,191}$")
|
||||
_ROOT_KEYS: Final = frozenset({"schema_version", "reviewed_at_utc", "entries"})
|
||||
_ENTRY_KEYS: Final = frozenset(
|
||||
{"catalog_id", "evidence_id", "signal", "lifecycle", "visual_evidence"}
|
||||
)
|
||||
|
||||
LaboratoryValueSignal = Literal["progress", "retained", "failed"]
|
||||
LaboratoryValueLifecycle = Literal["current", "legacy"]
|
||||
LaboratoryVisualEvidence = Literal["available", "partial", "missing"]
|
||||
|
||||
|
||||
class LaboratoryValueReviewRegistryError(ValueError):
|
||||
"""Raised when reviewed LAB value metadata is unsafe or ambiguous."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LaboratoryValueReviewEntry:
|
||||
catalog_id: str
|
||||
evidence_id: str
|
||||
signal: LaboratoryValueSignal
|
||||
lifecycle: LaboratoryValueLifecycle
|
||||
visual_evidence: LaboratoryVisualEvidence
|
||||
|
||||
def as_payload(self) -> dict[str, str]:
|
||||
return {
|
||||
"catalog_id": self.catalog_id,
|
||||
"evidence_id": self.evidence_id,
|
||||
"signal": self.signal,
|
||||
"lifecycle": self.lifecycle,
|
||||
"visual_evidence": self.visual_evidence,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LaboratoryValueReviewRegistry:
|
||||
reviewed_at_utc: str
|
||||
entries: tuple[LaboratoryValueReviewEntry, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.reviewed_at_utc, "reviewed_at_utc", maximum=64)
|
||||
if not isinstance(self.entries, tuple) or not all(
|
||||
isinstance(entry, LaboratoryValueReviewEntry) for entry in self.entries
|
||||
):
|
||||
raise LaboratoryValueReviewRegistryError(
|
||||
"LAB value-review entries must be an immutable tuple"
|
||||
)
|
||||
catalog_ids = [entry.catalog_id for entry in self.entries]
|
||||
if len(catalog_ids) != len(set(catalog_ids)):
|
||||
raise LaboratoryValueReviewRegistryError("duplicate LAB value-review catalog_id")
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> LaboratoryValueReviewRegistry:
|
||||
candidate = path.expanduser().absolute()
|
||||
if candidate.is_symlink() or not candidate.is_file():
|
||||
raise LaboratoryValueReviewRegistryError(
|
||||
"LAB value-review registry must be a regular file"
|
||||
)
|
||||
if candidate.stat().st_size > _REGISTRY_MAX_BYTES:
|
||||
raise LaboratoryValueReviewRegistryError("LAB value-review registry is too large")
|
||||
try:
|
||||
payload: object = json.loads(candidate.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
raise LaboratoryValueReviewRegistryError(
|
||||
"LAB value-review registry is unreadable"
|
||||
) from exc
|
||||
document = _object(payload, "LAB value-review registry")
|
||||
_exact_keys(document, _ROOT_KEYS, "LAB value-review registry")
|
||||
if document["schema_version"] != LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA:
|
||||
raise LaboratoryValueReviewRegistryError(
|
||||
"LAB value-review registry schema is invalid"
|
||||
)
|
||||
entries = document["entries"]
|
||||
if not isinstance(entries, list) or len(entries) > 128:
|
||||
raise LaboratoryValueReviewRegistryError("LAB value-review entries are invalid")
|
||||
return cls(
|
||||
reviewed_at_utc=_text(document["reviewed_at_utc"], "reviewed_at_utc", maximum=64),
|
||||
entries=tuple(_entry(item, index) for index, item in enumerate(entries)),
|
||||
)
|
||||
|
||||
def as_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": LABORATORY_VALUE_REVIEW_INDEX_SCHEMA,
|
||||
"reviewed_at_utc": self.reviewed_at_utc,
|
||||
"items": [entry.as_payload() for entry in self.entries],
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _entry(value: object, index: int) -> LaboratoryValueReviewEntry:
|
||||
document = _object(value, f"LAB value-review entry {index}")
|
||||
_exact_keys(document, _ENTRY_KEYS, f"LAB value-review entry {index}")
|
||||
catalog_id = _text(document["catalog_id"], "catalog_id", maximum=160)
|
||||
evidence_id = _text(document["evidence_id"], "evidence_id", maximum=192)
|
||||
if _CATALOG_ID.fullmatch(catalog_id) is None:
|
||||
raise LaboratoryValueReviewRegistryError("LAB value-review catalog_id is invalid")
|
||||
if _EVIDENCE_ID.fullmatch(evidence_id) is None:
|
||||
raise LaboratoryValueReviewRegistryError("LAB value-review evidence_id is invalid")
|
||||
signal = document["signal"]
|
||||
lifecycle = document["lifecycle"]
|
||||
visual_evidence = document["visual_evidence"]
|
||||
if signal not in {"progress", "retained", "failed"}:
|
||||
raise LaboratoryValueReviewRegistryError("LAB value-review signal is invalid")
|
||||
if lifecycle not in {"current", "legacy"}:
|
||||
raise LaboratoryValueReviewRegistryError("LAB value-review lifecycle is invalid")
|
||||
if visual_evidence not in {"available", "partial", "missing"}:
|
||||
raise LaboratoryValueReviewRegistryError("LAB value-review visual_evidence is invalid")
|
||||
return LaboratoryValueReviewEntry(
|
||||
catalog_id=catalog_id,
|
||||
evidence_id=evidence_id,
|
||||
signal=cast(LaboratoryValueSignal, signal),
|
||||
lifecycle=cast(LaboratoryValueLifecycle, lifecycle),
|
||||
visual_evidence=cast(LaboratoryVisualEvidence, visual_evidence),
|
||||
)
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise LaboratoryValueReviewRegistryError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_keys(document: dict[str, object], expected: frozenset[str], label: str) -> None:
|
||||
actual = frozenset(document)
|
||||
if actual != expected:
|
||||
raise LaboratoryValueReviewRegistryError(
|
||||
f"{label} keys are invalid; missing={sorted(expected - actual)}, "
|
||||
f"unexpected={sorted(actual - expected)}"
|
||||
)
|
||||
|
||||
|
||||
def _text(value: object, label: str, *, maximum: int = 768) -> str:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value.strip()
|
||||
or value != value.strip()
|
||||
or len(value) > maximum
|
||||
):
|
||||
raise LaboratoryValueReviewRegistryError(
|
||||
f"{label} must be a bounded trimmed string"
|
||||
)
|
||||
return value
|
||||
@@ -57,6 +57,7 @@ from k1link.compute.e40_perception_product_gate import (
|
||||
)
|
||||
from k1link.laboratory import LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry
|
||||
from k1link.web.l3_pointpillars_visual_api import latest_l3_visual_identity
|
||||
from k1link.web.l31_pointpillars_ravnoves_api import latest_l31_identity
|
||||
from k1link.web.l32_pointpillars_camera_review_api import latest_l32_identity
|
||||
from k1link.web.l33_camera_first_detector_review_api import latest_l33_identity
|
||||
|
||||
@@ -917,6 +918,15 @@ def build_advanced_laboratory_router(
|
||||
"access": "read-only",
|
||||
}
|
||||
)
|
||||
l31_identity = latest_l31_identity(l31_ravnoves_root_provider)
|
||||
if l31_identity is not None:
|
||||
items.append(
|
||||
{
|
||||
"work_id": "l31-pointpillars-ravnoves",
|
||||
**l31_identity,
|
||||
"access": "read-only",
|
||||
}
|
||||
)
|
||||
l32_identity = latest_l32_identity(l32_camera_review_root_provider)
|
||||
if l32_identity is not None:
|
||||
items.append(
|
||||
|
||||
+19
-1
@@ -23,7 +23,11 @@ from k1link.compute import (
|
||||
RecordedPerceptionOverlayMux,
|
||||
RecordedPerceptionOverlayStore,
|
||||
)
|
||||
from k1link.laboratory import LaboratoryEvidenceRegistry
|
||||
from k1link.laboratory import (
|
||||
LaboratoryEvidenceRegistry,
|
||||
LaboratoryEvidenceReportService,
|
||||
LaboratoryValueReviewRegistry,
|
||||
)
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedMediaInspector,
|
||||
@@ -104,6 +108,7 @@ from k1link.web.l34e_self_review_diagnostic_api import (
|
||||
)
|
||||
from k1link.web.l34f_adjudication_api import build_l34f_adjudication_router
|
||||
from k1link.web.laboratory_api import build_laboratory_router
|
||||
from k1link.web.laboratory_report_api import build_laboratory_report_router
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService
|
||||
from k1link.web.map_api import (
|
||||
@@ -137,6 +142,13 @@ INVALID_REQUEST_DETAIL = "Некорректные параметры запро
|
||||
LABORATORY_EVIDENCE_REGISTRY = LaboratoryEvidenceRegistry.from_directory(
|
||||
REPOSITORY_ROOT / "config" / "laboratories"
|
||||
)
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file(
|
||||
REPOSITORY_ROOT / "config" / "laboratory-value-review.json"
|
||||
)
|
||||
LABORATORY_EVIDENCE_REPORTS = LaboratoryEvidenceReportService(
|
||||
LABORATORY_EVIDENCE_REGISTRY,
|
||||
lambda: REPOSITORY_ROOT / ".runtime" / "compute-experiments",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_media_tool(name: str) -> Path | None:
|
||||
@@ -559,6 +571,12 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_laboratory_report_router(
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY,
|
||||
LABORATORY_EVIDENCE_REPORTS,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_advanced_laboratory_router(
|
||||
evidence_registry=LABORATORY_EVIDENCE_REGISTRY,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from k1link.laboratory import (
|
||||
LaboratoryEvidenceReportError,
|
||||
LaboratoryEvidenceReportNotFound,
|
||||
LaboratoryEvidenceReportService,
|
||||
LaboratoryValueReviewRegistry,
|
||||
)
|
||||
|
||||
|
||||
def build_laboratory_report_router(
|
||||
registry: LaboratoryValueReviewRegistry,
|
||||
evidence_reports: LaboratoryEvidenceReportService | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"])
|
||||
|
||||
@router.get("/value-review-index")
|
||||
def get_value_review_index() -> dict[str, object]:
|
||||
return registry.as_payload()
|
||||
|
||||
@router.get("/evidence-reports/{work_id}/{result_id}")
|
||||
def get_evidence_report(work_id: str, result_id: str) -> dict[str, object]:
|
||||
if evidence_reports is None:
|
||||
raise HTTPException(status_code=404, detail="LAB evidence report is unavailable")
|
||||
try:
|
||||
return evidence_reports.read(work_id, result_id)
|
||||
except LaboratoryEvidenceReportNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except LaboratoryEvidenceReportError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user