feat(lab): add lazy E40 evidence review
This commit is contained in:
@@ -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<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"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<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<readonly AdvancedLaboratoryIndexItem[]> {
|
||||
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<AdvancedLaboratoryResults> {
|
||||
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;
|
||||
}
|
||||
@@ -256,7 +256,7 @@ export class AdvancedLaboratoryContractError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
export type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
@@ -402,7 +402,7 @@ function parseCatalog<T>(
|
||||
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<T>(
|
||||
export async function fetchOne<T>(
|
||||
path: string,
|
||||
parser: (value: unknown) => T,
|
||||
fetcher: LaboratoryFetch,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E40CaseReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E40CaseReviewContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(
|
||||
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<E40CaseCatalog> {
|
||||
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<E40OperatorReview> {
|
||||
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<E40OperatorReview> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user