From fdcaf57ae7b8c8c15bc506c546e74a8a1e78aeed Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 30 Jul 2026 21:53:04 +0300 Subject: [PATCH] feat(lab): add lazy E40 evidence review --- .../src/core/laboratory/advancedIndex.ts | 243 ++++++++ .../src/core/laboratory/advancedResults.ts | 16 +- .../src/core/laboratory/e30Review.ts | 10 + .../src/core/laboratory/e40CaseReview.ts | 538 +++++++++++++++++ apps/control-station/src/styles.css | 1 + .../src/styles/e40-case-review.css | 297 ++++++++++ .../src/workspaces/E30EvidencePointCloud.tsx | 2 +- .../src/workspaces/E30EvidenceProjection.tsx | 2 +- .../laboratory/AdvancedLaboratoryResult.tsx | 105 +--- .../workspaces/laboratory/E40CaseReview.tsx | 550 ++++++++++++++++++ .../src/workspaces/laboratory/E40Result.tsx | 53 +- .../laboratory/LaboratoryArchiveWorkspace.tsx | 64 +- .../useAdvancedLaboratoryCatalog.ts | 118 ++++ .../test/advancedLaboratoryResults.test.mjs | 53 ++ apps/control-station/test/e30Review.test.mjs | 2 + .../test/e40CaseReview.test.mjs | 174 ++++++ .../test/laboratoryProductUi.test.mjs | 27 +- src/k1link/compute/e40_operator_review.py | 436 ++++++++++++++ src/k1link/web/advanced_laboratory_api.py | 159 +++++ src/k1link/web/app.py | 19 + src/k1link/web/e40_case_review_api.py | 479 +++++++++++++++ tests/test_advanced_laboratory_api.py | 88 +++ tests/test_e40_case_review_api.py | 177 ++++++ tests/test_e40_operator_review.py | 122 ++++ 24 files changed, 3570 insertions(+), 165 deletions(-) create mode 100644 apps/control-station/src/core/laboratory/advancedIndex.ts create mode 100644 apps/control-station/src/core/laboratory/e40CaseReview.ts create mode 100644 apps/control-station/src/styles/e40-case-review.css create mode 100644 apps/control-station/src/workspaces/laboratory/E40CaseReview.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts create mode 100644 apps/control-station/test/e40CaseReview.test.mjs create mode 100644 src/k1link/compute/e40_operator_review.py create mode 100644 src/k1link/web/e40_case_review_api.py create mode 100644 tests/test_e40_case_review_api.py create mode 100644 tests/test_e40_operator_review.py diff --git a/apps/control-station/src/core/laboratory/advancedIndex.ts b/apps/control-station/src/core/laboratory/advancedIndex.ts new file mode 100644 index 0000000..14f2de6 --- /dev/null +++ b/apps/control-station/src/core/laboratory/advancedIndex.ts @@ -0,0 +1,243 @@ +import { + AdvancedLaboratoryContractError, + fetchOne, + parseE31, + parseE32, + parseE33, + parseE37, + parseE38, + parseE39, + type AdvancedLaboratoryResults, + type LaboratoryFetch, +} from "./advancedResults"; +import { fetchE34TemporalLayerResult } from "./e34TemporalLayer"; +import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery"; +import { fetchE40ProductGateResult } from "./e40ProductGate"; + +export type AdvancedLaboratoryWorkId = + | "e31-source-binding" + | "e32-track-geometry" + | "e33-worker-shadow" + | "e34-temporal-layer" + | "e35-degradation-recovery" + | "e37-ravnoves-acceptance" + | "e38-perception-baseline" + | "e39-perception-refinement" + | "e40-perception-product-gate"; + +export interface AdvancedLaboratoryIndexItem { + workId: AdvancedLaboratoryWorkId; + resultId: string; + createdAtUtc: string; +} + +const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [ + "e31-source-binding", + "e32-track-geometry", + "e33-worker-shadow", + "e34-temporal-layer", + "e35-degradation-recovery", + "e37-ravnoves-acceptance", + "e38-perception-baseline", + "e39-perception-refinement", + "e40-perception-product-gate", +]; + +const RESULT_PREFIX: Readonly> = { + "e31-source-binding": "e31-source-qualification", + "e32-track-geometry": "e32-track-geometry", + "e33-worker-shadow": "e33-worker-shadow", + "e34-temporal-layer": "e34-temporal-occupied", + "e35-degradation-recovery": "e35-degradation-recovery", + "e37-ravnoves-acceptance": "e37-ravnoves-acceptance", + "e38-perception-baseline": "e38-perception-baseline", + "e39-perception-refinement": "e39-perception-refinement", + "e40-perception-product-gate": "e40-perception-product-gate", +}; + +export function isAdvancedLaboratoryWorkId( + value: string, +): value is AdvancedLaboratoryWorkId { + return WORK_IDS.includes(value as AdvancedLaboratoryWorkId); +} + +export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults { + return { + e31: null, + e32: null, + e33: null, + e34: null, + e35: null, + e37: null, + e38: null, + e39: null, + e40: null, + }; +} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function exactString(value: unknown, expected: string, label: string): string { + if (value !== expected) { + throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`); + } + return expected; +} + +function parseIndexItem(value: unknown): AdvancedLaboratoryIndexItem { + const item = objectValue(value, "Индекс LAB.item"); + if ( + typeof item.work_id !== "string" + || !isAdvancedLaboratoryWorkId(item.work_id) + ) { + throw new AdvancedLaboratoryContractError( + "Индекс LAB.work_id: неизвестная лабораторная работа.", + ); + } + const prefix = RESULT_PREFIX[item.work_id]; + if ( + typeof item.result_id !== "string" + || !new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(item.result_id) + ) { + throw new AdvancedLaboratoryContractError( + "Индекс LAB.result_id: нарушена идентичность результата.", + ); + } + if (typeof item.created_at_utc !== "string" || !item.created_at_utc.trim()) { + throw new AdvancedLaboratoryContractError( + "Индекс LAB.created_at_utc: ожидалась строка.", + ); + } + exactString(item.access, "read-only", "Индекс LAB.item.access"); + return { + workId: item.work_id, + resultId: item.result_id, + createdAtUtc: item.created_at_utc, + }; +} + +export async function fetchAdvancedLaboratoryIndex({ + fetcher = fetch, + signal, +}: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; +} = {}): Promise { + const response = await fetcher("/api/v1/laboratory/advanced-index", { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }); + if (!response.ok) { + throw new AdvancedLaboratoryContractError( + `Индекс LAB недоступен: HTTP ${response.status}.`, + ); + } + const payload = objectValue(await response.json(), "Индекс LAB"); + exactString( + payload.schema_version, + "missioncore.laboratory-advanced-index/v1", + "Индекс LAB.schema_version", + ); + exactString(payload.access, "read-only", "Индекс LAB.access"); + if (!Array.isArray(payload.items) || payload.items.length > WORK_IDS.length) { + throw new AdvancedLaboratoryContractError( + "Индекс LAB.items: нарушен размер каталога.", + ); + } + const items = payload.items.map(parseIndexItem); + if (new Set(items.map((item) => item.workId)).size !== items.length) { + throw new AdvancedLaboratoryContractError( + "Индекс LAB.items: лабораторная работа продублирована.", + ); + } + return items; +} + +export function advancedLaboratoryResultAvailable( + workId: AdvancedLaboratoryWorkId, + results: AdvancedLaboratoryResults, +): boolean { + return workId === "e31-source-binding" ? results.e31 !== null + : workId === "e32-track-geometry" ? results.e32 !== null + : workId === "e33-worker-shadow" ? results.e33 !== null + : workId === "e34-temporal-layer" ? results.e34 !== null + : workId === "e35-degradation-recovery" ? results.e35 !== null + : workId === "e37-ravnoves-acceptance" ? results.e37 !== null + : workId === "e38-perception-baseline" ? results.e38 !== null + : workId === "e39-perception-refinement" ? results.e39 !== null + : results.e40 !== null; +} + +export async function fetchAdvancedLaboratoryResult( + workId: AdvancedLaboratoryWorkId, + { + fetcher = fetch, + signal, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + } = {}, +): Promise { + const results = emptyAdvancedLaboratoryResults(); + if (workId === "e31-source-binding") { + results.e31 = await fetchOne( + "/api/v1/laboratory/e31/results?limit=1", + parseE31, + fetcher, + signal, + ); + } else if (workId === "e32-track-geometry") { + results.e32 = await fetchOne( + "/api/v1/laboratory/e32/results?limit=1", + parseE32, + fetcher, + signal, + ); + } else if (workId === "e33-worker-shadow") { + results.e33 = await fetchOne( + "/api/v1/laboratory/e33/results?limit=1", + parseE33, + fetcher, + signal, + ); + } else if (workId === "e34-temporal-layer") { + results.e34 = await fetchE34TemporalLayerResult({ fetcher, signal }); + } else if (workId === "e35-degradation-recovery") { + results.e35 = await fetchE35DegradationRecoveryResult({ fetcher, signal }); + } else if (workId === "e37-ravnoves-acceptance") { + results.e37 = await fetchOne( + "/api/v1/laboratory/e37/results?limit=1", + parseE37, + fetcher, + signal, + ); + } else if (workId === "e38-perception-baseline") { + results.e38 = await fetchOne( + "/api/v1/laboratory/e38/results?limit=1", + parseE38, + fetcher, + signal, + ); + } else if (workId === "e39-perception-refinement") { + results.e39 = await fetchOne( + "/api/v1/laboratory/e39/results?limit=1", + parseE39, + fetcher, + signal, + ); + } else { + results.e40 = await fetchE40ProductGateResult({ fetcher, signal }); + } + if (!advancedLaboratoryResultAvailable(workId, results)) { + throw new AdvancedLaboratoryContractError( + "Выбранная лабораторная работа не прошла серверную проверку.", + ); + } + return results; +} diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 23c06bc..84d60b7 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -256,7 +256,7 @@ export class AdvancedLaboratoryContractError extends Error { } } -type LaboratoryFetch = ( +export type LaboratoryFetch = ( input: RequestInfo | URL, init?: RequestInit, ) => Promise; @@ -402,7 +402,7 @@ function parseCatalog( return value.items.length ? parseItem(value.items[0]) : null; } -function parseE31(value: unknown): E31LaboratoryResult { +export function parseE31(value: unknown): E31LaboratoryResult { const item = record(value, "E31"); const metrics = record(item.metrics, "E31.metrics"); diagnosticAuthority(item.authority, "E31.authority"); @@ -437,7 +437,7 @@ function parseE31(value: unknown): E31LaboratoryResult { }; } -function parseE32(value: unknown): E32LaboratoryResult { +export function parseE32(value: unknown): E32LaboratoryResult { const item = record(value, "E32"); const metrics = record(item.metrics, "E32.metrics"); diagnosticAuthority(item.authority, "E32.authority"); @@ -470,7 +470,7 @@ function parseE32(value: unknown): E32LaboratoryResult { }; } -function parseE33(value: unknown): E33LaboratoryResult { +export function parseE33(value: unknown): E33LaboratoryResult { const item = record(value, "E33"); const worker = record(item.worker, "E33.worker"); const metrics = record(item.metrics, "E33.metrics"); @@ -521,7 +521,7 @@ function parseE33(value: unknown): E33LaboratoryResult { }; } -function parseE37(value: unknown): E37AcceptanceContractResult { +export function parseE37(value: unknown): E37AcceptanceContractResult { const item = record(value, "E37"); const metrics = record(item.metrics, "E37.metrics"); const distributions = record( @@ -718,7 +718,7 @@ function parseE38Dimension( }; } -function parseE38(value: unknown): E38PerceptionBaselineResult { +export function parseE38(value: unknown): E38PerceptionBaselineResult { const item = record(value, "E38"); const metrics = record(item.metrics, "E38.metrics"); const dimensions = record(metrics.dimensions, "E38.metrics.dimensions"); @@ -810,7 +810,7 @@ function parseDevelopmentDimension( }; } -function parseE39(value: unknown): E39PerceptionRefinementResult { +export function parseE39(value: unknown): E39PerceptionRefinementResult { const item = record(value, "E39"); const metrics = record(item.metrics, "E39.metrics"); const dimensions = record(metrics.dimensions, "E39.metrics.dimensions"); @@ -944,7 +944,7 @@ function parseE39(value: unknown): E39PerceptionRefinementResult { }; } -async function fetchOne( +export async function fetchOne( path: string, parser: (value: unknown) => T, fetcher: LaboratoryFetch, diff --git a/apps/control-station/src/core/laboratory/e30Review.ts b/apps/control-station/src/core/laboratory/e30Review.ts index 4c694c3..ffde687 100644 --- a/apps/control-station/src/core/laboratory/e30Review.ts +++ b/apps/control-station/src/core/laboratory/e30Review.ts @@ -59,6 +59,8 @@ export interface E30ReviewItem { materialization: { framePointCount: number; projectedPointCount: number; + projectionWidth: number; + projectionHeight: number; candidatePointCount: number; selectedPointCount: number; rejectedCandidatePointCount: number; @@ -428,6 +430,14 @@ function parseItem(value: unknown): E30ReviewItem { materialized.projected_point_count, "E30 materialization.projected_point_count", ), + projectionWidth: integerValue( + materialized.projection_width, + "E30 materialization.projection_width", + ), + projectionHeight: integerValue( + materialized.projection_height, + "E30 materialization.projection_height", + ), candidatePointCount: integerValue( materialized.candidate_point_count, "E30 materialization.candidate_point_count", diff --git a/apps/control-station/src/core/laboratory/e40CaseReview.ts b/apps/control-station/src/core/laboratory/e40CaseReview.ts new file mode 100644 index 0000000..9889b6a --- /dev/null +++ b/apps/control-station/src/core/laboratory/e40CaseReview.ts @@ -0,0 +1,538 @@ +export const E40_CASE_DIMENSIONS = [ + "presence", + "geometry_association", + "freshness", +] as const; + +export type E40CaseDimension = (typeof E40_CASE_DIMENSIONS)[number]; +export type E40CaseSeverity = "high" | "medium" | "standard"; +export type E40CaseStratum = + | "agree" + | "camera-only" + | "conflict" + | "geometry-only" + | "unknown"; +export type E40PredictionBasis = + | "camera-only-softmax" + | "fixed-stratum-policy"; + +export interface E40CaseState { + presence: "background-or-noise" | "object-present" | "occupied-environment"; + geometryAssociation: + | "independent-occupied" + | "insufficient-support" + | "object-associated" + | "rejected-nonobject" + | "unknown"; + freshness: "current" | "stale" | "unavailable"; +} + +export interface E40ErrorCase { + itemId: string; + sequence: number; + sourceFrameIndex: number; + sourceStratum: E40CaseStratum; + severity: E40CaseSeverity; + presenceConfidence: number; + predictionBasis: E40PredictionBasis; + reference: E40CaseState; + prediction: E40CaseState; + mismatchedDimensions: readonly E40CaseDimension[]; + access: "read-only"; +} + +export interface E40CaseCatalog { + resultId: string; + materializationId: string; + items: readonly E40ErrorCase[]; + total: number; + truncated: boolean; + access: "read-only"; +} + +export type E40OperatorVerdict = "confirmed-error" | "rejected-error"; + +export interface E40OperatorDecision { + itemId: string; + verdict: E40OperatorVerdict; + revision: number; + idempotencyKey: string; + decidedAtUtc: string; + eventId: string; +} + +export interface E40OperatorReview { + reviewId: string; + resultId: string; + materializationId: string; + caseCatalogSha256: string; + itemSetSha256: string; + itemCount: number; + reviewerId: string; + revision: number; + decisions: readonly E40OperatorDecision[]; + reviewedItemCount: number; + remainingItemCount: number; + updatedAtUtc: string | null; + access: "review-write"; +} + +export class E40CaseReviewContractError extends Error { + constructor(message: string) { + super(message); + this.name = "E40CaseReviewContractError"; + } +} + +type LaboratoryFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new E40CaseReviewContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new E40CaseReviewContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function contentId(value: unknown, prefix: string, label: string): string { + const parsed = stringValue(value, label); + if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) { + throw new E40CaseReviewContractError(`${label}: неверный content id.`); + } + return parsed; +} + +function sha256Value(value: unknown, label: string): string { + const parsed = stringValue(value, label); + if (!/^[a-f0-9]{64}$/.test(parsed)) { + throw new E40CaseReviewContractError(`${label}: неверный SHA-256.`); + } + return parsed; +} + +function integerValue(value: unknown, label: string): number { + if ( + typeof value !== "number" + || !Number.isSafeInteger(value) + || value < 0 + ) { + throw new E40CaseReviewContractError(`${label}: ожидалось целое число.`); + } + return value; +} + +function nullableString(value: unknown, label: string): string | null { + if (value === null) return null; + return stringValue(value, label); +} + +function probability(value: unknown, label: string): number { + if ( + typeof value !== "number" + || !Number.isFinite(value) + || value < 0 + || value > 1 + ) { + throw new E40CaseReviewContractError(`${label}: ожидалась вероятность.`); + } + return value; +} + +function exactString( + value: unknown, + expected: T, + label: string, +): T { + if (value !== expected) { + throw new E40CaseReviewContractError(`${label}: неверное значение.`); + } + return expected; +} + +function enumValue( + value: unknown, + allowed: readonly T[], + label: string, +): T { + if (typeof value !== "string" || !allowed.includes(value as T)) { + throw new E40CaseReviewContractError(`${label}: значение не поддерживается.`); + } + return value as T; +} + +function parseState(value: unknown, label: string): E40CaseState { + const source = record(value, label); + return { + presence: enumValue( + source.presence, + [ + "background-or-noise", + "object-present", + "occupied-environment", + ], + `${label}.presence`, + ), + geometryAssociation: enumValue( + source.geometry_association, + [ + "independent-occupied", + "insufficient-support", + "object-associated", + "rejected-nonobject", + "unknown", + ], + `${label}.geometry_association`, + ), + freshness: enumValue( + source.freshness, + ["current", "stale", "unavailable"], + `${label}.freshness`, + ), + }; +} + +function parseCase(value: unknown, index: number): E40ErrorCase { + const label = `E40 case[${index}]`; + const source = record(value, label); + if (!Array.isArray(source.mismatched_dimensions)) { + throw new E40CaseReviewContractError( + `${label}.mismatched_dimensions: ожидался массив.`, + ); + } + const mismatchedDimensions = source.mismatched_dimensions.map( + (dimension, dimensionIndex) => enumValue( + dimension, + E40_CASE_DIMENSIONS, + `${label}.mismatched_dimensions[${dimensionIndex}]`, + ), + ); + if (!mismatchedDimensions.length) { + throw new E40CaseReviewContractError(`${label}: кейс не содержит ошибки.`); + } + return { + itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`), + sequence: integerValue(source.sequence, `${label}.sequence`), + sourceFrameIndex: integerValue( + source.source_frame_index, + `${label}.source_frame_index`, + ), + sourceStratum: enumValue( + source.source_stratum, + ["agree", "camera-only", "conflict", "geometry-only", "unknown"], + `${label}.source_stratum`, + ), + severity: enumValue( + source.severity, + ["high", "medium", "standard"], + `${label}.severity`, + ), + presenceConfidence: probability( + source.presence_confidence, + `${label}.presence_confidence`, + ), + predictionBasis: enumValue( + source.prediction_basis, + ["camera-only-softmax", "fixed-stratum-policy"], + `${label}.prediction_basis`, + ), + reference: parseState(source.reference, `${label}.reference`), + prediction: parseState(source.prediction, `${label}.prediction`), + mismatchedDimensions, + access: exactString(source.access, "read-only", `${label}.access`), + }; +} + +export function parseE40CaseCatalog(payload: unknown): E40CaseCatalog { + const source = record(payload, "E40 case catalog"); + exactString( + source.schema_version, + "missioncore.laboratory-e40-case-catalog/v1", + "E40 case catalog.schema_version", + ); + if (!Array.isArray(source.items) || source.items.length > 64) { + throw new E40CaseReviewContractError( + "E40 case catalog.items: нарушена граница.", + ); + } + return { + resultId: contentId( + source.result_id, + "e40-perception-product-gate", + "E40 case catalog.result_id", + ), + materializationId: contentId( + source.materialization_id, + "e30-materialization", + "E40 case catalog.materialization_id", + ), + items: source.items.map(parseCase), + total: integerValue(source.total, "E40 case catalog.total"), + truncated: typeof source.truncated === "boolean" + ? source.truncated + : (() => { + throw new E40CaseReviewContractError( + "E40 case catalog.truncated: ожидался boolean.", + ); + })(), + access: exactString( + source.access, + "read-only", + "E40 case catalog.access", + ), + }; +} + +function parseOperatorDecision( + value: unknown, + index: number, +): E40OperatorDecision { + const label = `E40 operator decision[${index}]`; + const source = record(value, label); + return { + itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`), + verdict: enumValue( + source.verdict, + ["confirmed-error", "rejected-error"], + `${label}.verdict`, + ), + revision: integerValue(source.revision, `${label}.revision`), + idempotencyKey: stringValue( + source.idempotency_key, + `${label}.idempotency_key`, + ), + decidedAtUtc: stringValue( + source.decided_at_utc, + `${label}.decided_at_utc`, + ), + eventId: contentId( + source.event_id, + "e40-operator-verdict", + `${label}.event_id`, + ), + }; +} + +export function parseE40OperatorReview( + payload: unknown, +): E40OperatorReview { + const source = record(payload, "E40 operator review"); + exactString( + source.schema_version, + "missioncore.e40-operator-review/v1", + "E40 operator review.schema_version", + ); + exactString( + source.protocol, + "sealed-error-adjudication/v1", + "E40 operator review.protocol", + ); + const binding = record(source.source, "E40 operator review.source"); + if (!Array.isArray(source.decisions) || source.decisions.length > 64) { + throw new E40CaseReviewContractError( + "E40 operator review.decisions: нарушена граница.", + ); + } + const decisions = source.decisions.map(parseOperatorDecision); + const reviewedItemCount = integerValue( + source.reviewed_item_count, + "E40 operator review.reviewed_item_count", + ); + const remainingItemCount = integerValue( + source.remaining_item_count, + "E40 operator review.remaining_item_count", + ); + const itemCount = integerValue( + binding.item_count, + "E40 operator review.source.item_count", + ); + if ( + reviewedItemCount !== decisions.length + || reviewedItemCount + remainingItemCount !== itemCount + || new Set(decisions.map((decision) => decision.itemId)).size + !== decisions.length + ) { + throw new E40CaseReviewContractError( + "E40 operator review: счётчики решений несовместимы.", + ); + } + return { + reviewId: contentId( + source.review_id, + "e40-operator-review", + "E40 operator review.review_id", + ), + resultId: contentId( + binding.result_id, + "e40-perception-product-gate", + "E40 operator review.source.result_id", + ), + materializationId: contentId( + binding.materialization_id, + "e30-materialization", + "E40 operator review.source.materialization_id", + ), + caseCatalogSha256: sha256Value( + binding.case_catalog_sha256, + "E40 operator review.source.case_catalog_sha256", + ), + itemSetSha256: sha256Value( + binding.item_set_sha256, + "E40 operator review.source.item_set_sha256", + ), + itemCount, + reviewerId: stringValue( + source.reviewer_id, + "E40 operator review.reviewer_id", + ), + revision: integerValue( + source.revision, + "E40 operator review.revision", + ), + decisions, + reviewedItemCount, + remainingItemCount, + updatedAtUtc: nullableString( + source.updated_at_utc, + "E40 operator review.updated_at_utc", + ), + access: exactString( + source.access, + "review-write", + "E40 operator review.access", + ), + }; +} + +export async function fetchE40CaseCatalog( + resultId: string, + { + fetcher = fetch, + signal, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + } = {}, +): Promise { + const response = await fetcher( + `/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}/cases?limit=48`, + { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }, + ); + if (!response.ok) { + throw new E40CaseReviewContractError( + `Case-review E40 недоступен: HTTP ${response.status}.`, + ); + } + const catalog = parseE40CaseCatalog(await response.json()); + if (catalog.resultId !== resultId) { + throw new E40CaseReviewContractError( + "Case-review E40 вернул другой результат.", + ); + } + return catalog; +} + +export async function fetchE40OperatorReview( + resultId: string, + { + reviewerId = "DC", + fetcher = fetch, + signal, + }: { + reviewerId?: string; + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + } = {}, +): Promise { + const response = await fetcher( + `/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}` + + `/operator-review?reviewer_id=${encodeURIComponent(reviewerId)}`, + { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }, + ); + if (!response.ok) { + throw new E40CaseReviewContractError( + `Операторская проверка E40 недоступна: HTTP ${response.status}.`, + ); + } + const review = parseE40OperatorReview(await response.json()); + if (review.resultId !== resultId || review.reviewerId !== reviewerId) { + throw new E40CaseReviewContractError( + "Операторская проверка E40 вернула другой источник.", + ); + } + return review; +} + +export async function saveE40OperatorVerdict( + resultId: string, + itemId: string, + { + reviewerId = "DC", + expectedRevision, + idempotencyKey, + verdict, + fetcher = fetch, + signal, + }: { + reviewerId?: string; + expectedRevision: number; + idempotencyKey: string; + verdict: E40OperatorVerdict; + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + }, +): Promise { + const response = await fetcher( + `/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}` + + `/operator-review/decisions/${encodeURIComponent(itemId)}`, + { + method: "PUT", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + reviewer_id: reviewerId, + expected_revision: expectedRevision, + idempotency_key: idempotencyKey, + verdict, + }), + signal, + }, + ); + if (!response.ok) { + throw new E40CaseReviewContractError( + `Решение E40 не сохранено: HTTP ${response.status}.`, + ); + } + const review = parseE40OperatorReview(await response.json()); + if ( + review.resultId !== resultId + || review.reviewerId !== reviewerId + || !review.decisions.some( + (decision) => decision.itemId === itemId + && decision.verdict === verdict, + ) + ) { + throw new E40CaseReviewContractError( + "Решение E40 не подтверждено сервером.", + ); + } + return review; +} diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index e218f4a..829da76 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -3,6 +3,7 @@ @import "./styles/shell.css"; @import "./styles/workspaces.css"; @import "./styles/laboratory.css"; +@import "./styles/e40-case-review.css"; @import "./styles/laboratory-reporting.css"; @import "./styles/e34-temporal-layer.css"; @import "./styles/e35-degradation-recovery.css"; diff --git a/apps/control-station/src/styles/e40-case-review.css b/apps/control-station/src/styles/e40-case-review.css new file mode 100644 index 0000000..008191c --- /dev/null +++ b/apps/control-station/src/styles/e40-case-review.css @@ -0,0 +1,297 @@ +.e40-case-review { + display: grid; + height: clamp(38rem, 66vh, 52rem); + min-height: 38rem; + grid-template-rows: auto minmax(0, 1fr); + gap: 0.65rem; + border-radius: var(--nodedc-radius-option); + background: var(--nodedc-glass-panel-bg-soft); + padding: 0.75rem; +} + +.e40-case-review__header { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.e40-case-review__header > div { + display: grid; + min-width: 0; + gap: 0.18rem; +} + +.e40-case-review__header strong { + color: var(--nodedc-text-primary); + font-size: 0.78rem; +} + +.e40-case-review__header small { + color: var(--nodedc-text-muted); + font-size: 0.56rem; + line-height: 1.45; +} + +.e40-case-review__viewer { + display: grid; + min-width: 0; + min-height: 0; +} + +.e40-case-review__viewer > .laboratory-evidence-viewer { + min-height: 0; +} + +.e40-case-review__state { + display: flex; + min-height: 16rem; + align-items: center; + justify-content: center; + gap: 0.55rem; + border-radius: var(--nodedc-radius-option); + background: var(--nodedc-canvas); + color: var(--nodedc-text-muted); + font-size: 0.62rem; +} + +.e40-case-review__controls { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: 0.45rem; +} + +.e40-case-review__viewer + .laboratory-evidence-viewer__controls:has(.e40-case-review__controls) { + right: 0.6rem; + left: 0.6rem; +} + +.e40-case-review__pagination { + display: flex; + flex: none; + gap: 0.35rem; +} + +.e40-case-review__glass-button { + background: var(--nodedc-floating-surface); + backdrop-filter: blur(var(--nodedc-blur-control)); +} + +.e40-case-review__controls .nodedc-select-anchor, +.e40-case-review__controls .nodedc-select { + width: clamp(15rem, 30vw, 26rem); +} + +.e40-case-review__select .nodedc-select__value, +.e40-case-review__select .nodedc-select__toggle, +.e40-case-review__select-menu { + background: var(--nodedc-floating-surface); + backdrop-filter: blur(var(--nodedc-blur-control)); +} + +.e40-case-review__layer-button { + margin-left: auto; + --nodedc-button-bg: var(--nodedc-floating-surface); + --nodedc-button-color: var(--nodedc-text-secondary); + backdrop-filter: blur(var(--nodedc-blur-control)); +} + +.e40-case-review__layer-button[data-active="true"] { + --nodedc-button-bg: var(--nodedc-glass-control-active); + --nodedc-button-color: var(--nodedc-glass-control-active-text); +} + +.e40-case-review__telemetry { + position: absolute; + z-index: 3; + left: 0.6rem; + bottom: 0.6rem; + display: grid; + width: min(46rem, calc(100% - 1.2rem)); + grid-template-columns: minmax(0, 1fr) minmax(14rem, 18rem); + align-items: end; + gap: 0.75rem; + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-floating-surface); + padding: 0.55rem 0.65rem; + color: var(--nodedc-text-secondary); + backdrop-filter: blur(var(--nodedc-blur-control)); + pointer-events: none; +} + +.e40-case-review__telemetry-content { + display: grid; + min-width: 0; + gap: 0.45rem; +} + +.e40-case-review__question { + display: grid; + gap: 0.15rem; + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-glass-control-bg); + padding: 0.45rem 0.55rem; +} + +.e40-case-review__question > span { + color: var(--nodedc-text-muted); + font-size: 0.46rem; + font-weight: 700; + letter-spacing: 0.12em; +} + +.e40-case-review__question > strong { + color: var(--nodedc-text-primary); + font-size: 0.62rem; + line-height: 1.35; +} + +.e40-case-review__question > small { + color: var(--nodedc-text-muted); + font-size: 0.49rem; + line-height: 1.35; +} + +.e40-case-review__telemetry-content > span, +.e40-case-review__telemetry dt { + color: var(--nodedc-text-muted); + font-size: 0.49rem; +} + +.e40-case-review__telemetry dl { + display: grid; + gap: 0.35rem; + margin: 0; +} + +.e40-case-review__telemetry dl > div { + display: grid; + grid-template-columns: 6.2rem minmax(0, 1fr); + align-items: start; + gap: 0.45rem; +} + +.e40-case-review__telemetry dt, +.e40-case-review__telemetry dd { + margin: 0; +} + +.e40-case-review__telemetry dd { + display: grid; + min-width: 0; + gap: 0.12rem; + font-size: 0.55rem; +} + +.e40-case-review__telemetry dd span, +.e40-case-review__telemetry dd strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.e40-case-review__telemetry dd strong { + color: var(--nodedc-text-primary); +} + +.e40-case-review__verdict { + display: grid; + justify-items: end; + gap: 0.28rem; + pointer-events: auto; +} + +.e40-case-review__verdict > span { + color: var(--nodedc-text-muted); + font-size: 0.49rem; + white-space: nowrap; +} + +.e40-case-review__verdict-error { + max-width: 12rem; + color: rgb(var(--nodedc-danger-rgb)); + font-size: 0.48rem; + line-height: 1.35; + text-align: right; +} + +.e40-case-review__verdict-buttons { + display: flex; + gap: 0.2rem; + border-radius: 999px; + background: var(--nodedc-glass-control-bg); + padding: 0.18rem; + backdrop-filter: blur(var(--nodedc-blur-control)); +} + +.e40-case-review__verdict-guide { + display: grid; + justify-self: stretch; + gap: 0.26rem; +} + +.e40-case-review__verdict-guide > span { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 0.28rem; + color: var(--nodedc-text-muted); + font-size: 0.49rem; + line-height: 1.35; +} + +.e40-case-review__verdict-guide > span[data-verdict="confirmed"] > svg { + color: rgb(var(--nodedc-success-rgb)); +} + +.e40-case-review__verdict-guide > span[data-verdict="rejected"] > svg { + color: rgb(var(--nodedc-danger-rgb)); +} + +.e40-case-review__commit-note { + max-width: 18rem; + color: var(--nodedc-text-muted); + font-size: 0.46rem; + line-height: 1.3; + text-align: right; +} + +.e40-case-review__verdict-button { + width: 2rem; + height: 2rem; + min-width: 2rem; + background: transparent; +} + +.e40-case-review__verdict-button[data-active="true"][data-verdict="confirmed"] { + background: rgb(var(--nodedc-success-rgb) / 0.12); + color: rgb(var(--nodedc-success-rgb)); +} + +.e40-case-review__verdict-button[data-active="true"][data-verdict="rejected"] { + background: rgb(var(--nodedc-danger-rgb) / 0.12); + color: rgb(var(--nodedc-danger-rgb)); +} + +@media (max-width: 900px) { + .e40-case-review { + height: 42rem; + } + + .e40-case-review__controls .nodedc-select-anchor, + .e40-case-review__controls .nodedc-select { + width: clamp(10rem, 42vw, 18rem); + } + + .e40-case-review__telemetry { + grid-template-columns: minmax(0, 1fr); + } + + .e40-case-review__verdict { + justify-items: start; + } +} diff --git a/apps/control-station/src/workspaces/E30EvidencePointCloud.tsx b/apps/control-station/src/workspaces/E30EvidencePointCloud.tsx index da8a61b..a4b3864 100644 --- a/apps/control-station/src/workspaces/E30EvidencePointCloud.tsx +++ b/apps/control-station/src/workspaces/E30EvidencePointCloud.tsx @@ -460,7 +460,7 @@ export function E30EvidencePointCloud({ detail }: E30EvidencePointCloudProps) { Отклонено · {rejectedCount} - Выбрано E29 · {detail.selected.pointsMapXyzM.length} + Проверяемый кластер E29 · {detail.selected.pointsMapXyzM.length} diff --git a/apps/control-station/src/workspaces/E30EvidenceProjection.tsx b/apps/control-station/src/workspaces/E30EvidenceProjection.tsx index 91e1256..785fddc 100644 --- a/apps/control-station/src/workspaces/E30EvidenceProjection.tsx +++ b/apps/control-station/src/workspaces/E30EvidenceProjection.tsx @@ -206,7 +206,7 @@ export function E30EvidenceProjection({ Кандидаты · {rejectedCount} - Выбрано E29 · {detail.selected.pointsMapXyzM.length} + Проверяемый кластер E29 · {detail.selected.pointsMapXyzM.length} ) : null} diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index a7eeedd..db94f79 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -1,6 +1,11 @@ import type { ComponentType } from "react"; import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation"; +import { + isAdvancedLaboratoryWorkId, + type AdvancedLaboratoryIndexItem, + type AdvancedLaboratoryWorkId, +} from "../../core/laboratory/advancedIndex"; import type { AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults"; import type { ObservationSessionSummary } from "../../core/observation/sessionArchive"; import type { WorkspaceRendererProps } from "../contracts"; @@ -15,97 +20,29 @@ import { E39Result } from "./E39Result"; import { E40Result } from "./E40Result"; import { RecordedReplayEvidence } from "./RecordedReplayEvidence"; -export type AdvancedLaboratoryWorkId = - | "e31-source-binding" - | "e32-track-geometry" - | "e33-worker-shadow" - | "e34-temporal-layer" - | "e35-degradation-recovery" - | "e37-ravnoves-acceptance" - | "e38-perception-baseline" - | "e39-perception-refinement" - | "e40-perception-product-gate"; +export { isAdvancedLaboratoryWorkId }; +export type { AdvancedLaboratoryWorkId }; type LaboratoryWorkspaceProps = WorkspaceRendererProps & { SpatialView: ComponentType; }; -export function isAdvancedLaboratoryWorkId( - value: string, -): value is AdvancedLaboratoryWorkId { - return ( - value === "e31-source-binding" - || value === "e32-track-geometry" - || value === "e33-worker-shadow" - || value === "e34-temporal-layer" - || value === "e35-degradation-recovery" - || value === "e37-ravnoves-acceptance" - || value === "e38-perception-baseline" - || value === "e39-perception-refinement" - || value === "e40-perception-product-gate" - ); -} - export function advancedLaboratoryWorkOptions( - results: AdvancedLaboratoryResults, - sourceSessions: ReadonlyMap, + index: readonly AdvancedLaboratoryIndexItem[], ): readonly LaboratoryOption[] { - const options: LaboratoryOption[] = []; - if (results.e31 && sourceSessions.has(results.e31.sourceSessionId)) { - options.push({ - id: "e31-source-binding", - label: "LAB E31 · source binding", - }); - } - if (results.e32 && sourceSessions.has(results.e32.sourceSessionId)) { - options.push({ - id: "e32-track-geometry", - label: "LAB E32 · TrackGeometry v1", - }); - } - if (results.e33 && sourceSessions.has(results.e33.sourceSessionId)) { - options.push({ - id: "e33-worker-shadow", - label: "LAB E33 · worker shadow 1×", - }); - } - if (results.e34) { - options.push({ - id: "e34-temporal-layer", - label: "LAB E34 · temporal occupied/unknown", - }); - } - if (results.e35) { - options.push({ - id: "e35-degradation-recovery", - label: "LAB E35 · degradation recovery", - }); - } - if (results.e37) { - options.push({ - id: "e37-ravnoves-acceptance", - label: "LAB E37 · RAVNOVES00 acceptance R0", - }); - } - if (results.e38) { - options.push({ - id: "e38-perception-baseline", - label: "LAB E38 · perception quality R1", - }); - } - if (results.e39) { - options.push({ - id: "e39-perception-refinement", - label: "LAB E39 · perception refinement R1", - }); - } - if (results.e40) { - options.push({ - id: "e40-perception-product-gate", - label: "LAB E40 · leakage-resistant product gate", - }); - } - return options; + const available = new Set(index.map(({ workId }) => workId)); + const options: readonly LaboratoryOption[] = [ + { id: "e31-source-binding", label: "LAB E31 · source binding" }, + { id: "e32-track-geometry", label: "LAB E32 · TrackGeometry v1" }, + { id: "e33-worker-shadow", label: "LAB E33 · worker shadow 1×" }, + { id: "e34-temporal-layer", label: "LAB E34 · temporal occupied/unknown" }, + { id: "e35-degradation-recovery", label: "LAB E35 · degradation recovery" }, + { id: "e37-ravnoves-acceptance", label: "LAB E37 · RAVNOVES00 acceptance R0" }, + { id: "e38-perception-baseline", label: "LAB E38 · perception quality R1" }, + { id: "e39-perception-refinement", label: "LAB E39 · perception refinement R1" }, + { id: "e40-perception-product-gate", label: "LAB E40 · leakage-resistant product gate" }, + ]; + return options.filter(({ id }) => available.has(id)); } export function advancedLaboratorySourceSession( diff --git a/apps/control-station/src/workspaces/laboratory/E40CaseReview.tsx b/apps/control-station/src/workspaces/laboratory/E40CaseReview.tsx new file mode 100644 index 0000000..8995fdd --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/E40CaseReview.tsx @@ -0,0 +1,550 @@ +import { useEffect, useState } from "react"; +import { + Button, + Icon, + IconButton, + Select, + StatusBadge, +} from "@nodedc/ui-react"; + +import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; +import { + fetchE30ReviewItemDetail, + type E30ReviewItemDetail, +} from "../../core/laboratory/e30Review"; +import { + fetchE40CaseCatalog, + fetchE40OperatorReview, + saveE40OperatorVerdict, + type E40CaseCatalog, + type E40CaseDimension, + type E40CaseSeverity, + type E40CaseState, + type E40CaseStratum, + type E40ErrorCase, + type E40OperatorReview, + type E40OperatorVerdict, +} from "../../core/laboratory/e40CaseReview"; +import { E30EvidencePointCloud } from "../E30EvidencePointCloud"; +import { E30EvidenceProjection } from "../E30EvidenceProjection"; + +type EvidenceMode = "camera" | "3d"; + +const SEVERITY_LABELS: Record = { + high: "Высокий приоритет", + medium: "Средний приоритет", + standard: "Стандартный приоритет", +}; + +const STRATUM_LABELS: Record = { + agree: "согласованные источники", + "camera-only": "только камера", + conflict: "конфликт источников", + "geometry-only": "только геометрия", + unknown: "неопределённый источник", +}; + +const DIMENSION_LABELS: Record = { + presence: "Присутствие", + geometry_association: "Связь с геометрией", + freshness: "Актуальность", +}; + +const VALUE_LABELS: Readonly> = { + "background-or-noise": "Фон или шум", + "object-present": "Объект присутствует", + "occupied-environment": "Занятая среда", + "independent-occupied": "Независимая геометрия", + "insufficient-support": "Недостаточно опоры", + "object-associated": "Связано с объектом", + "rejected-nonobject": "Не объект", + unknown: "Не определено", + current: "Актуально", + stale: "Устарело", + unavailable: "Недоступно", +}; + +function stateValue( + state: E40CaseState, + dimension: E40CaseDimension, +): string { + if (dimension === "presence") return state.presence; + if (dimension === "geometry_association") { + return state.geometryAssociation; + } + return state.freshness; +} + +function caseLabel(item: E40ErrorCase): string { + const dimensions = item.mismatchedDimensions + .map((dimension) => DIMENSION_LABELS[dimension]) + .join(" + "); + return `${SEVERITY_LABELS[item.severity]} · кадр ${item.sourceFrameIndex} · ${dimensions}`; +} + +function reviewPrompt( + item: E40ErrorCase, + detail: E30ReviewItemDetail, +): { + question: string; + evidence: string; + confirm: string; + reject: string; +} { + const hasSelectedPoints = detail.selected.pointsMapXyzM.length > 0; + const subject = hasSelectedPoints + ? "Белый LiDAR-кластер" + : "Выделенная область"; + const evidence = hasSelectedPoints + ? detail.locatorKind === "geometry-only-cluster" + ? ( + "Белые точки — самостоятельный кластер занятой геометрии E29 без " + + "класса. Это проверяемая LiDAR-опора, а не отметки ошибки." + ) + : ( + "Белые точки — LiDAR-опора, которую E29 связал с наблюдением. " + + "Это проверяемые исходные точки, а не отметки ошибки." + ) + : ( + "Белого LiDAR-кластера в этом кейсе нет: решение принимается по " + + "кадру и рамке наблюдения." + ); + + if (item.reference.presence === "object-present") { + return { + question: `${subject} относится к отдельному видимому объекту?`, + evidence, + confirm: ( + "Да — объект виден; эталон верен, расхождение E40 подтверждается." + ), + reject: ( + "Нет — это фон или часть среды; предполагаемая ошибка отклоняется." + ), + }; + } + if (item.reference.presence === "background-or-noise") { + return { + question: `${subject} является фоном или шумом, а не объектом?`, + evidence, + confirm: ( + "Да — это фон или шум; эталон верен, расхождение E40 подтверждается." + ), + reject: ( + "Нет — отдельный объект есть; предполагаемая ошибка отклоняется." + ), + }; + } + return { + question: "Проверяемый эталон точнее описывает кадр, чем решение E40?", + evidence, + confirm: "Да — эталон верен, расхождение E40 подтверждается.", + reject: "Нет — эталон спорен, предполагаемая ошибка отклоняется.", + }; +} + +export function E40CaseReview({ resultId }: { resultId: string }) { + const [catalog, setCatalog] = useState(null); + const [selectedItemId, setSelectedItemId] = useState(null); + const [detail, setDetail] = useState(null); + const [review, setReview] = useState(null); + const [catalogLoading, setCatalogLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [verdictPending, setVerdictPending] = useState(false); + const [error, setError] = useState(null); + const [reviewError, setReviewError] = useState(null); + const [mode, setMode] = useState("camera"); + const [pointLayerVisible, setPointLayerVisible] = useState(true); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + setCatalogLoading(true); + setCatalog(null); + setDetail(null); + setReview(null); + setSelectedItemId(null); + setError(null); + setReviewError(null); + void Promise.all([ + fetchE40CaseCatalog(resultId, { signal: controller.signal }), + fetchE40OperatorReview(resultId, { signal: controller.signal }), + ]).then(([nextCatalog, nextReview]) => { + if ( + nextReview.materializationId !== nextCatalog.materializationId + || nextReview.reviewedItemCount > nextCatalog.total + ) { + throw new Error( + "Операторская проверка E40 не совпадает с каталогом ошибок.", + ); + } + setCatalog(nextCatalog); + setReview(nextReview); + setSelectedItemId(nextCatalog.items[0]?.itemId ?? null); + }).catch((caught: unknown) => { + if (controller.signal.aborted) return; + setError( + caught instanceof Error + ? caught.message + : "Case-review E40 недоступен.", + ); + }).finally(() => { + if (!controller.signal.aborted) setCatalogLoading(false); + }); + return () => controller.abort(); + }, [resultId]); + + useEffect(() => { + if (!catalog || !selectedItemId) { + setDetail(null); + return; + } + const controller = new AbortController(); + setDetailLoading(true); + setDetail(null); + setError(null); + void fetchE30ReviewItemDetail( + catalog.materializationId, + selectedItemId, + { signal: controller.signal }, + ).then((next) => { + setDetail(next); + setMode(next.cameraFrame ? "camera" : "3d"); + }).catch((caught: unknown) => { + if (controller.signal.aborted) return; + setError( + caught instanceof Error + ? caught.message + : "Визуальное доказательство кейса недоступно.", + ); + }).finally(() => { + if (!controller.signal.aborted) setDetailLoading(false); + }); + return () => controller.abort(); + }, [catalog, selectedItemId]); + + const selectedCase = catalog?.items.find( + (item) => item.itemId === selectedItemId, + ) ?? null; + const selectedIndex = selectedCase && catalog + ? catalog.items.findIndex((item) => item.itemId === selectedCase.itemId) + : -1; + const selectedDecision = selectedCase + ? review?.decisions.find( + (decision) => decision.itemId === selectedCase.itemId, + ) ?? null + : null; + const prompt = selectedCase && detail + ? reviewPrompt(selectedCase, detail) + : null; + + const navigate = (offset: -1 | 1) => { + if (!catalog?.items.length || selectedIndex < 0) return; + const index = ( + selectedIndex + offset + catalog.items.length + ) % catalog.items.length; + setSelectedItemId(catalog.items[index].itemId); + }; + + const saveVerdict = async (verdict: E40OperatorVerdict) => { + if (!review || !selectedCase || verdictPending) return; + setVerdictPending(true); + setReviewError(null); + try { + setReview(await saveE40OperatorVerdict( + resultId, + selectedCase.itemId, + { + expectedRevision: review.revision, + idempotencyKey: `ui-${crypto.randomUUID()}`, + verdict, + }, + )); + } catch (caught) { + setReviewError( + caught instanceof Error + ? caught.message + : "Решение оператора не сохранено.", + ); + } finally { + setVerdictPending(false); + } + }; + + if (catalogLoading) { + return ( +
+
+ ); + } + + if (error && !selectedCase) { + return ( +
+ + {error} +
+ ); + } + + if (!catalog?.items.length) { + return ( +
+ + Расхождений исторической оценки E40 не опубликовано. +
+ ); + } + + return ( +
+
+
+ + CASE REVIEW · HISTORICAL VISIBLE EVALUATION + + + Визуальная проверка {catalog.items.length} из {catalog.total}{" "} + расхождений + + + Загружается только выбранный camera/LiDAR-кейс. Проверено + оператором: {review?.reviewedItemCount ?? 0} из {catalog.total}. + +
+ {selectedCase ? ( + + {SEVERITY_LABELS[selectedCase.severity]} + + ) : null} +
+ +
+ {detailLoading ? ( +
+
+ ) : error || !detail || !selectedCase ? ( +
+ + {error ?? "Выберите ошибку для проверки."} +
+ ) : ( + +
+ navigate(-1)} + > + + + navigate(1)} + > + + +
+