feat(lab): complete E30 evidence review gate
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
import {
|
||||
parseE30ReviewItem,
|
||||
type E30ReviewItem,
|
||||
type E30Stratum,
|
||||
} from "./e30Review";
|
||||
|
||||
export type E30EngineeringVerdict =
|
||||
| "confirmed"
|
||||
| "corrected"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export type E30DetectorAssessment =
|
||||
| "valid"
|
||||
| "class-mismatch"
|
||||
| "false-positive"
|
||||
| "missed-object"
|
||||
| "not-applicable"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export type E30ProjectionAssessment =
|
||||
| "aligned"
|
||||
| "misaligned"
|
||||
| "not-assessable";
|
||||
|
||||
export type E30PointOwnership =
|
||||
| "object"
|
||||
| "surface-or-background"
|
||||
| "static-environment"
|
||||
| "self"
|
||||
| "insufficient-support"
|
||||
| "not-applicable"
|
||||
| "mixed"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export interface E30ExceptionReviewPrompt {
|
||||
question: string;
|
||||
focus: string;
|
||||
effects: Readonly<{
|
||||
"object-present": string;
|
||||
"background-or-noise": string;
|
||||
"insufficient-evidence": string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface E30EngineeringGeneration {
|
||||
generationId: string;
|
||||
createdAtUtc: string;
|
||||
materializationId: string;
|
||||
producer: {
|
||||
producerId: string;
|
||||
methodId: string;
|
||||
reviewSheetIdentitySha256: string;
|
||||
claimsHumanGroundTruth: false;
|
||||
};
|
||||
summary: {
|
||||
itemCount: number;
|
||||
reviewedItemCount: number;
|
||||
verdictDistribution: Readonly<Record<string, number>>;
|
||||
detectorDistribution: Readonly<Record<string, number>>;
|
||||
projectionDistribution: Readonly<Record<string, number>>;
|
||||
pointOwnershipDistribution: Readonly<Record<string, number>>;
|
||||
humanExceptionCount: number;
|
||||
meanConfidence: number;
|
||||
};
|
||||
causeDistribution: readonly {
|
||||
reasonCode: string;
|
||||
count: number;
|
||||
}[];
|
||||
humanExceptions: readonly {
|
||||
itemId: string;
|
||||
reviewKey: string;
|
||||
sourceStratum: E30Stratum;
|
||||
confidence: number;
|
||||
reviewPrompt: E30ExceptionReviewPrompt | null;
|
||||
}[];
|
||||
aiReviewComplete: true;
|
||||
humanExceptionComplete: boolean;
|
||||
humanReviewComplete: false;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30EngineeringCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E30EngineeringGeneration[];
|
||||
}
|
||||
|
||||
export interface E30EngineeringDecision {
|
||||
sequence: number;
|
||||
itemId: string;
|
||||
reviewKey: string;
|
||||
sourceStratum: E30Stratum;
|
||||
verdict: E30EngineeringVerdict;
|
||||
effectiveStratum: E30Stratum | null;
|
||||
detectorAssessment: E30DetectorAssessment;
|
||||
projectionAssessment: E30ProjectionAssessment;
|
||||
pointOwnership: E30PointOwnership;
|
||||
causeCode: string | null;
|
||||
confidence: number;
|
||||
humanExceptionRequired: boolean;
|
||||
exceptionReason: "ambiguity" | "high-impact" | null;
|
||||
evidenceNote: string;
|
||||
reviewSheet: {
|
||||
path: string;
|
||||
sha256: string;
|
||||
ordinal: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E30EngineeringExceptions {
|
||||
resultId: string;
|
||||
generationId: string;
|
||||
items: readonly E30ReviewItem[];
|
||||
total: number;
|
||||
nextCursor: number | null;
|
||||
}
|
||||
|
||||
export class E30EngineeringContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E30EngineeringContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
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 E30EngineeringContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E30EngineeringContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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 E30EngineeringContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readOnly(value: unknown, label: string): "read-only" {
|
||||
if (value !== "read-only") {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось read-only.`);
|
||||
}
|
||||
return "read-only";
|
||||
}
|
||||
|
||||
function authority(value: unknown, label: string): void {
|
||||
const parsed = record(value, label);
|
||||
falseValue(parsed.commands_enabled, `${label}.commands_enabled`);
|
||||
falseValue(
|
||||
parsed.navigation_or_safety_accepted,
|
||||
`${label}.navigation_or_safety_accepted`,
|
||||
);
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const parsed = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).map(([key, count]) => [
|
||||
key,
|
||||
integerValue(count, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function stratum(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
value !== "conflict"
|
||||
&& value !== "agree"
|
||||
&& value !== "camera-only"
|
||||
&& value !== "unknown"
|
||||
&& value !== "geometry-only"
|
||||
) {
|
||||
throw new E30EngineeringContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableStratum(value: unknown, label: string): E30Stratum | null {
|
||||
return value === null ? null : stratum(value, label);
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(
|
||||
value: unknown,
|
||||
values: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !values.includes(value as T)) {
|
||||
throw new E30EngineeringContractError(`${label}: неизвестное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function exceptionReviewPrompt(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E30ExceptionReviewPrompt | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const source = record(value, label);
|
||||
const effects = record(source.effects, `${label}.effects`);
|
||||
return {
|
||||
question: stringValue(source.question, `${label}.question`),
|
||||
focus: stringValue(source.focus, `${label}.focus`),
|
||||
effects: {
|
||||
"object-present": stringValue(
|
||||
effects["object-present"],
|
||||
`${label}.effects.object-present`,
|
||||
),
|
||||
"background-or-noise": stringValue(
|
||||
effects["background-or-noise"],
|
||||
`${label}.effects.background-or-noise`,
|
||||
),
|
||||
"insufficient-evidence": stringValue(
|
||||
effects["insufficient-evidence"],
|
||||
`${label}.effects.insufficient-evidence`,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeneration(value: unknown): E30EngineeringGeneration {
|
||||
const source = record(value, "A3 generation");
|
||||
const producer = record(source.producer, "A3 producer");
|
||||
const summary = record(source.summary, "A3 summary");
|
||||
const causes = record(source.cause_distribution, "A3 causes");
|
||||
const humanExceptions = source.human_exceptions;
|
||||
if (
|
||||
producer.kind !== "ai-assisted-engineering-review"
|
||||
|| !Array.isArray(causes.reasons)
|
||||
|| !Array.isArray(humanExceptions)
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 provenance не поддерживается.");
|
||||
}
|
||||
falseValue(
|
||||
producer.claims_human_ground_truth,
|
||||
"A3 producer.claims_human_ground_truth",
|
||||
);
|
||||
authority(source.authority, "A3 authority");
|
||||
return {
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 generation_id",
|
||||
),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "A3 created_at_utc"),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"A3 materialization_id",
|
||||
),
|
||||
producer: {
|
||||
producerId: stringValue(producer.producer_id, "A3 producer_id"),
|
||||
methodId: stringValue(producer.method_id, "A3 method_id"),
|
||||
reviewSheetIdentitySha256: stringValue(
|
||||
producer.review_sheet_identity_sha256,
|
||||
"A3 review_sheet_identity_sha256",
|
||||
),
|
||||
claimsHumanGroundTruth: false,
|
||||
},
|
||||
summary: {
|
||||
itemCount: integerValue(summary.item_count, "A3 item_count"),
|
||||
reviewedItemCount: integerValue(
|
||||
summary.reviewed_item_count,
|
||||
"A3 reviewed_item_count",
|
||||
),
|
||||
verdictDistribution: distribution(
|
||||
summary.verdict_distribution,
|
||||
"A3 verdict_distribution",
|
||||
),
|
||||
detectorDistribution: distribution(
|
||||
summary.detector_distribution,
|
||||
"A3 detector_distribution",
|
||||
),
|
||||
projectionDistribution: distribution(
|
||||
summary.projection_distribution,
|
||||
"A3 projection_distribution",
|
||||
),
|
||||
pointOwnershipDistribution: distribution(
|
||||
summary.point_ownership_distribution,
|
||||
"A3 point_ownership_distribution",
|
||||
),
|
||||
humanExceptionCount: integerValue(
|
||||
summary.human_exception_count,
|
||||
"A3 human_exception_count",
|
||||
),
|
||||
meanConfidence: numberValue(
|
||||
summary.mean_confidence,
|
||||
"A3 mean_confidence",
|
||||
),
|
||||
},
|
||||
causeDistribution: causes.reasons.map((value, index) => {
|
||||
const reason = record(value, `A3 causes[${index}]`);
|
||||
return {
|
||||
reasonCode: stringValue(
|
||||
reason.reason_code,
|
||||
`A3 causes[${index}].reason_code`,
|
||||
),
|
||||
count: integerValue(reason.count, `A3 causes[${index}].count`),
|
||||
};
|
||||
}),
|
||||
humanExceptions: humanExceptions.map((value, index) => {
|
||||
const exception = record(value, `A3 exceptions[${index}]`);
|
||||
const confidence = numberValue(
|
||||
exception.confidence,
|
||||
`A3 exceptions[${index}].confidence`,
|
||||
);
|
||||
if (confidence < 0 || confidence > 1) {
|
||||
throw new E30EngineeringContractError(
|
||||
`A3 exceptions[${index}].confidence вне диапазона.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
itemId: contentId(
|
||||
exception.item_id,
|
||||
"e30-review-item",
|
||||
`A3 exceptions[${index}].item_id`,
|
||||
),
|
||||
reviewKey: stringValue(
|
||||
exception.review_key,
|
||||
`A3 exceptions[${index}].review_key`,
|
||||
),
|
||||
sourceStratum: stratum(
|
||||
exception.source_stratum,
|
||||
`A3 exceptions[${index}].source_stratum`,
|
||||
),
|
||||
confidence,
|
||||
reviewPrompt: exceptionReviewPrompt(
|
||||
exception.review_prompt,
|
||||
`A3 exceptions[${index}].review_prompt`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
aiReviewComplete: trueValue(
|
||||
source.ai_review_complete,
|
||||
"A3 ai_review_complete",
|
||||
),
|
||||
humanExceptionComplete: booleanValue(
|
||||
source.human_exception_complete,
|
||||
"A3 human_exception_complete",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
source.human_review_complete,
|
||||
"A3 human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "A3 lab_published"),
|
||||
access: readOnly(source.access, "A3 access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringCatalog(
|
||||
value: unknown,
|
||||
): E30EngineeringCatalog {
|
||||
const source = record(value, "A3 catalog");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-generations/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 catalog schema не поддерживается.");
|
||||
}
|
||||
readOnly(source.access, "A3 catalog access");
|
||||
return {
|
||||
configured: Boolean(source.configured),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"A3 candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(source.invalid_total, "A3 invalid_total"),
|
||||
items: source.items.map(parseGeneration),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringDecision(
|
||||
value: unknown,
|
||||
): E30EngineeringDecision {
|
||||
const response = record(value, "A3 decision response");
|
||||
if (
|
||||
response.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-decision/v1"
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 decision schema не поддерживается.");
|
||||
}
|
||||
readOnly(response.access, "A3 decision access");
|
||||
contentId(
|
||||
response.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 decision generation_id",
|
||||
);
|
||||
const source = record(response.decision, "A3 decision");
|
||||
const sheet = record(source.review_sheet, "A3 review_sheet");
|
||||
const confidence = numberValue(source.confidence, "A3 confidence");
|
||||
if (confidence < 0 || confidence > 1) {
|
||||
throw new E30EngineeringContractError("A3 confidence вне диапазона.");
|
||||
}
|
||||
const exceptionReason = source.exception_reason === null
|
||||
? null
|
||||
: enumValue(
|
||||
source.exception_reason,
|
||||
["ambiguity", "high-impact"] as const,
|
||||
"A3 exception_reason",
|
||||
);
|
||||
return {
|
||||
sequence: integerValue(source.sequence, "A3 sequence"),
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"A3 decision item_id",
|
||||
),
|
||||
reviewKey: stringValue(source.review_key, "A3 review_key"),
|
||||
sourceStratum: stratum(source.source_stratum, "A3 source_stratum"),
|
||||
verdict: enumValue(
|
||||
source.verdict,
|
||||
["confirmed", "corrected", "insufficient-evidence"] as const,
|
||||
"A3 verdict",
|
||||
),
|
||||
effectiveStratum: nullableStratum(
|
||||
source.effective_stratum,
|
||||
"A3 effective_stratum",
|
||||
),
|
||||
detectorAssessment: enumValue(
|
||||
source.detector_assessment,
|
||||
[
|
||||
"valid",
|
||||
"class-mismatch",
|
||||
"false-positive",
|
||||
"missed-object",
|
||||
"not-applicable",
|
||||
"insufficient-evidence",
|
||||
] as const,
|
||||
"A3 detector_assessment",
|
||||
),
|
||||
projectionAssessment: enumValue(
|
||||
source.projection_assessment,
|
||||
["aligned", "misaligned", "not-assessable"] as const,
|
||||
"A3 projection_assessment",
|
||||
),
|
||||
pointOwnership: enumValue(
|
||||
source.point_ownership,
|
||||
[
|
||||
"object",
|
||||
"surface-or-background",
|
||||
"static-environment",
|
||||
"self",
|
||||
"insufficient-support",
|
||||
"not-applicable",
|
||||
"mixed",
|
||||
"insufficient-evidence",
|
||||
] as const,
|
||||
"A3 point_ownership",
|
||||
),
|
||||
causeCode: source.cause_code === null
|
||||
? null
|
||||
: stringValue(source.cause_code, "A3 cause_code"),
|
||||
confidence,
|
||||
humanExceptionRequired: booleanValue(
|
||||
source.human_exception_required,
|
||||
"A3 human_exception_required",
|
||||
),
|
||||
exceptionReason,
|
||||
evidenceNote: stringValue(source.evidence_note, "A3 evidence_note"),
|
||||
reviewSheet: {
|
||||
path: stringValue(sheet.path, "A3 review_sheet.path"),
|
||||
sha256: stringValue(sheet.sha256, "A3 review_sheet.sha256"),
|
||||
ordinal: integerValue(sheet.ordinal, "A3 review_sheet.ordinal"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringExceptions(
|
||||
value: unknown,
|
||||
): E30EngineeringExceptions {
|
||||
const source = record(value, "A3 exception queue");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-exceptions/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30EngineeringContractError(
|
||||
"A3 exception queue schema не поддерживается.",
|
||||
);
|
||||
}
|
||||
readOnly(source.access, "A3 exception queue access");
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e30-materialization",
|
||||
"A3 exception queue result_id",
|
||||
),
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 exception queue generation_id",
|
||||
),
|
||||
items: source.items.map(parseE30ReviewItem),
|
||||
total: integerValue(source.total, "A3 exception queue total"),
|
||||
nextCursor: source.next_cursor === null
|
||||
? null
|
||||
: integerValue(
|
||||
source.next_cursor,
|
||||
"A3 exception queue next_cursor",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30EngineeringContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validMaterializationId(value: string): boolean {
|
||||
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validGenerationId(value: string): boolean {
|
||||
return /^e30-engineering-generation-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validItemId(value: string): boolean {
|
||||
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringCatalog(
|
||||
resultId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringCatalog> {
|
||||
if (!validMaterializationId(resultId)) {
|
||||
throw new E30EngineeringContractError("Некорректный E30 materialization id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations?limit=1`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringCatalog(
|
||||
await responseJson(response, "Не удалось получить A3 generation."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringDecision(
|
||||
resultId: string,
|
||||
generationId: string,
|
||||
itemId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringDecision> {
|
||||
if (
|
||||
!validMaterializationId(resultId)
|
||||
|| !validGenerationId(generationId)
|
||||
|| !validItemId(itemId)
|
||||
) {
|
||||
throw new E30EngineeringContractError("Некорректный A3 decision id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/items/${itemId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringDecision(
|
||||
await responseJson(response, "Не удалось получить A3 decision."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringExceptions(
|
||||
resultId: string,
|
||||
generationId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringExceptions> {
|
||||
if (!validMaterializationId(resultId) || !validGenerationId(generationId)) {
|
||||
throw new E30EngineeringContractError("Некорректная A3 exception queue.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/exceptions?limit=128`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringExceptions(
|
||||
await responseJson(response, "Не удалось получить A3 exception queue."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import {
|
||||
E30ReviewContractError,
|
||||
type E30Stratum,
|
||||
} from "./e30Review";
|
||||
|
||||
export const E30_EXCEPTION_DISPOSITIONS = [
|
||||
"object-present",
|
||||
"background-or-noise",
|
||||
"insufficient-evidence",
|
||||
] as const;
|
||||
|
||||
export type E30ExceptionDisposition =
|
||||
(typeof E30_EXCEPTION_DISPOSITIONS)[number];
|
||||
export type E30HumanReviewState = "active" | "finalized";
|
||||
|
||||
export interface E30HumanReviewDecision {
|
||||
itemId: string;
|
||||
sourceStratum: E30Stratum;
|
||||
disposition: E30ExceptionDisposition;
|
||||
notes: string | null;
|
||||
eventId: string;
|
||||
decidedAtUtc: string;
|
||||
}
|
||||
|
||||
export interface E30HumanReviewDraft {
|
||||
draftId: string;
|
||||
materializationId: string;
|
||||
engineeringGenerationId: string;
|
||||
reviewerId: string;
|
||||
createdAtUtc: string;
|
||||
state: E30HumanReviewState;
|
||||
revision: number;
|
||||
itemCount: number;
|
||||
reviewedItemCount: number;
|
||||
remainingItemCount: number;
|
||||
dispositionDistribution: Readonly<Record<string, number>>;
|
||||
generationId: string | null;
|
||||
decisions: readonly E30HumanReviewDecision[];
|
||||
labPublished: false;
|
||||
access: "review-write" | "read-only";
|
||||
}
|
||||
|
||||
export interface E30HumanReviewGeneration {
|
||||
generationId: string;
|
||||
materializationId: string;
|
||||
engineeringGenerationId: string;
|
||||
reviewerId: string;
|
||||
createdAtUtc: string;
|
||||
itemCount: number;
|
||||
dispositionDistribution: Readonly<Record<string, number>>;
|
||||
coverage: {
|
||||
expectedItemCount: number;
|
||||
reviewedItemCount: number;
|
||||
complete: true;
|
||||
};
|
||||
humanReviewComplete: true;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30HumanReviewFinalized {
|
||||
draft: E30HumanReviewDraft;
|
||||
generation: E30HumanReviewGeneration;
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
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 E30ReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E30ReviewContractError(`${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 E30ReviewContractError(`${label}: некорректный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const source = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).map(([key, count]) => [
|
||||
key,
|
||||
integerValue(count, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function disposition(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E30ExceptionDisposition {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !E30_EXCEPTION_DISPOSITIONS.includes(
|
||||
value as E30ExceptionDisposition,
|
||||
)
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестное решение.`);
|
||||
}
|
||||
return value as E30ExceptionDisposition;
|
||||
}
|
||||
|
||||
function stratum(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
value !== "conflict"
|
||||
&& value !== "agree"
|
||||
&& value !== "camera-only"
|
||||
&& value !== "unknown"
|
||||
&& value !== "geometry-only"
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseDecision(value: unknown): E30HumanReviewDecision {
|
||||
const source = record(value, "Решение по исключению");
|
||||
return {
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"Решение.item_id",
|
||||
),
|
||||
sourceStratum: stratum(source.source_stratum, "Решение.source_stratum"),
|
||||
disposition: disposition(source.disposition, "Решение.disposition"),
|
||||
notes: nullableString(source.notes, "Решение.notes"),
|
||||
eventId: contentId(
|
||||
source.event_id,
|
||||
"e30-review-event",
|
||||
"Решение.event_id",
|
||||
),
|
||||
decidedAtUtc: stringValue(
|
||||
source.decided_at_utc,
|
||||
"Решение.decided_at_utc",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30HumanReviewDraft(
|
||||
value: unknown,
|
||||
): E30HumanReviewDraft {
|
||||
const source = record(value, "Проверка исключений");
|
||||
if (
|
||||
source.schema_version !== "missioncore.e30-human-review-draft/v2"
|
||||
|| !Array.isArray(source.decisions)
|
||||
) {
|
||||
throw new E30ReviewContractError(
|
||||
"Схема проверки исключений не поддерживается.",
|
||||
);
|
||||
}
|
||||
const state = stringValue(source.state, "Проверка.state");
|
||||
const access = stringValue(source.access, "Проверка.access");
|
||||
if (
|
||||
(state !== "active" && state !== "finalized")
|
||||
|| (state === "active" && access !== "review-write")
|
||||
|| (state === "finalized" && access !== "read-only")
|
||||
) {
|
||||
throw new E30ReviewContractError("Состояние проверки некорректно.");
|
||||
}
|
||||
const itemCount = integerValue(source.item_count, "Проверка.item_count");
|
||||
const reviewedItemCount = integerValue(
|
||||
source.reviewed_item_count,
|
||||
"Проверка.reviewed_item_count",
|
||||
);
|
||||
const remainingItemCount = integerValue(
|
||||
source.remaining_item_count,
|
||||
"Проверка.remaining_item_count",
|
||||
);
|
||||
const decisions = source.decisions.map(parseDecision);
|
||||
if (
|
||||
reviewedItemCount + remainingItemCount !== itemCount
|
||||
|| decisions.length !== reviewedItemCount
|
||||
) {
|
||||
throw new E30ReviewContractError("Покрытие проверки расходится.");
|
||||
}
|
||||
return {
|
||||
draftId: contentId(
|
||||
source.draft_id,
|
||||
"e30-human-draft",
|
||||
"Проверка.draft_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"Проверка.materialization_id",
|
||||
),
|
||||
engineeringGenerationId: contentId(
|
||||
source.engineering_generation_id,
|
||||
"e30-engineering-generation",
|
||||
"Проверка.engineering_generation_id",
|
||||
),
|
||||
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
|
||||
state,
|
||||
revision: integerValue(source.revision, "Проверка.revision"),
|
||||
itemCount,
|
||||
reviewedItemCount,
|
||||
remainingItemCount,
|
||||
dispositionDistribution: distribution(
|
||||
source.disposition_distribution,
|
||||
"Проверка.disposition_distribution",
|
||||
),
|
||||
generationId: source.generation_id === null
|
||||
? null
|
||||
: contentId(
|
||||
source.generation_id,
|
||||
"e30-review-generation",
|
||||
"Проверка.generation_id",
|
||||
),
|
||||
decisions,
|
||||
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
|
||||
access: access as "review-write" | "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeneration(value: unknown): E30HumanReviewGeneration {
|
||||
const source = record(value, "Зафиксированная проверка");
|
||||
const coverage = record(source.coverage, "Проверка.coverage");
|
||||
if (
|
||||
source.schema_version !== "missioncore.e30-human-review-generation/v2"
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new E30ReviewContractError(
|
||||
"Схема зафиксированной проверки не поддерживается.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-review-generation",
|
||||
"Проверка.generation_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"Проверка.materialization_id",
|
||||
),
|
||||
engineeringGenerationId: contentId(
|
||||
source.engineering_generation_id,
|
||||
"e30-engineering-generation",
|
||||
"Проверка.engineering_generation_id",
|
||||
),
|
||||
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
|
||||
itemCount: integerValue(source.item_count, "Проверка.item_count"),
|
||||
dispositionDistribution: distribution(
|
||||
source.disposition_distribution,
|
||||
"Проверка.disposition_distribution",
|
||||
),
|
||||
coverage: {
|
||||
expectedItemCount: integerValue(
|
||||
coverage.expected_item_count,
|
||||
"Проверка.coverage.expected",
|
||||
),
|
||||
reviewedItemCount: integerValue(
|
||||
coverage.reviewed_item_count,
|
||||
"Проверка.coverage.reviewed",
|
||||
),
|
||||
complete: trueValue(coverage.complete, "Проверка.coverage.complete"),
|
||||
},
|
||||
humanReviewComplete: trueValue(
|
||||
source.human_review_complete,
|
||||
"Проверка.human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30HumanReviewFinalized(
|
||||
value: unknown,
|
||||
): E30HumanReviewFinalized {
|
||||
const source = record(value, "Фиксация проверки");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-human-review-finalized/v2"
|
||||
) {
|
||||
throw new E30ReviewContractError("Схема фиксации не поддерживается.");
|
||||
}
|
||||
const draft = parseE30HumanReviewDraft(source.draft);
|
||||
const generation = parseGeneration(source.generation);
|
||||
if (
|
||||
draft.state !== "finalized"
|
||||
|| draft.generationId !== generation.generationId
|
||||
|| draft.engineeringGenerationId !== generation.engineeringGenerationId
|
||||
) {
|
||||
throw new E30ReviewContractError("Связь фиксации расходится.");
|
||||
}
|
||||
return { draft, generation };
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30ReviewContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validId(value: string, prefix: string): boolean {
|
||||
return new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(value);
|
||||
}
|
||||
|
||||
function generationQuery(engineeringGenerationId: string): string {
|
||||
return `?engineering_generation_id=${encodeURIComponent(
|
||||
engineeringGenerationId,
|
||||
)}`;
|
||||
}
|
||||
|
||||
export async function createOrResumeE30HumanReview(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
reviewerId = "DC",
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewDraft> {
|
||||
if (
|
||||
!validId(resultId, "e30-materialization")
|
||||
|| !validId(engineeringGenerationId, "e30-engineering-generation")
|
||||
) {
|
||||
throw new E30ReviewContractError("Некорректная очередь проверки.");
|
||||
}
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reviewer_id: reviewerId,
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewDraft(
|
||||
await responseJson(response, "Не удалось открыть проверку."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveE30HumanReviewDecision(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
draftId: string,
|
||||
itemId: string,
|
||||
request: {
|
||||
expectedRevision: number;
|
||||
idempotencyKey: string;
|
||||
disposition: E30ExceptionDisposition;
|
||||
notes: string | null;
|
||||
},
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewDraft> {
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
|
||||
+ `/decisions/${itemId}${generationQuery(engineeringGenerationId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_revision: request.expectedRevision,
|
||||
idempotency_key: request.idempotencyKey,
|
||||
disposition: request.disposition,
|
||||
notes: request.notes,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewDraft(
|
||||
await responseJson(response, "Не удалось сохранить решение."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function finalizeE30HumanReview(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
draftId: string,
|
||||
expectedRevision: number,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewFinalized> {
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
|
||||
+ `/finalize${generationQuery(engineeringGenerationId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_revision: expectedRevision,
|
||||
confirm_generation: true,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewFinalized(
|
||||
await responseJson(response, "Не удалось зафиксировать проверку."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
export const E30_STRATA = [
|
||||
"conflict",
|
||||
"agree",
|
||||
"camera-only",
|
||||
"unknown",
|
||||
"geometry-only",
|
||||
] as const;
|
||||
|
||||
export type E30Stratum = (typeof E30_STRATA)[number];
|
||||
|
||||
export interface E30ReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
reviewPackId: string;
|
||||
e29ResultId: string;
|
||||
sourceSessionId: string;
|
||||
itemCount: number;
|
||||
stratumCounts: Record<E30Stratum, number>;
|
||||
reasonTaxonomy: readonly string[];
|
||||
projection: {
|
||||
width: number;
|
||||
height: number;
|
||||
sourceId: string;
|
||||
calibrationSlot: string;
|
||||
};
|
||||
cameraEvidenceAvailable: boolean;
|
||||
humanReviewComplete: false;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30ReviewCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E30ReviewResult[];
|
||||
}
|
||||
|
||||
export interface E30ReviewSnapshot {
|
||||
label: string | null;
|
||||
geometryStatus: string;
|
||||
geometryReason: string | null;
|
||||
rangeM: number | null;
|
||||
nearestRangeM: number | null;
|
||||
bboxXyxy: readonly [number, number, number, number] | null;
|
||||
}
|
||||
|
||||
export interface E30ReviewItem {
|
||||
itemId: string;
|
||||
sequence: number;
|
||||
reviewKey: string;
|
||||
stratum: E30Stratum;
|
||||
rangeBucket: string;
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
locatorKind: "semantic-observation" | "geometry-only-cluster";
|
||||
snapshot: E30ReviewSnapshot;
|
||||
materialization: {
|
||||
framePointCount: number;
|
||||
projectedPointCount: number;
|
||||
candidatePointCount: number;
|
||||
selectedPointCount: number;
|
||||
rejectedCandidatePointCount: number;
|
||||
selectedAndCandidateLossless: true;
|
||||
freeSpaceValid: false;
|
||||
humanReviewComplete: false;
|
||||
detectorScore: number | null;
|
||||
};
|
||||
cameraFrameAvailable: boolean;
|
||||
engineeringTriage: E30EngineeringTriage;
|
||||
review: {
|
||||
state: "unreviewed";
|
||||
reasonCode: null;
|
||||
notes: null;
|
||||
};
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30EngineeringTriage {
|
||||
provenance: "deterministic-evidence-readiness/v1";
|
||||
state: "ready-for-ai-review" | "blocked-camera-frame-unavailable";
|
||||
attention: "standard" | "elevated";
|
||||
signals: readonly string[];
|
||||
semanticVerdict: null;
|
||||
humanExceptionRequired: null;
|
||||
}
|
||||
|
||||
export interface E30ReviewItems {
|
||||
resultId: string;
|
||||
stratum: E30Stratum;
|
||||
items: readonly E30ReviewItem[];
|
||||
total: number;
|
||||
nextCursor: number | null;
|
||||
reasonTaxonomy: readonly string[];
|
||||
}
|
||||
|
||||
export interface E30ReviewItemDetail extends E30ReviewItem {
|
||||
cameraFrame: {
|
||||
available: true;
|
||||
url: string;
|
||||
sha256: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceFrameIndex: number;
|
||||
exactSourceFrame: true;
|
||||
} | null;
|
||||
selected: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
};
|
||||
candidate: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
};
|
||||
projection: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
pixelsXy: readonly (readonly [number, number])[];
|
||||
depthM: readonly number[];
|
||||
pointClass: readonly number[];
|
||||
pointHeightM: readonly number[];
|
||||
candidateMask: readonly number[];
|
||||
selectedMask: readonly number[];
|
||||
};
|
||||
pose: {
|
||||
positionMapXyzM: readonly [number, number, number];
|
||||
orientationMapFromLidarXyzw: readonly [number, number, number, number];
|
||||
};
|
||||
}
|
||||
|
||||
export class E30ReviewContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E30ReviewContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
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 E30ReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось конечное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E30ReviewContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullValue(value: unknown, label: string): null {
|
||||
if (value !== null) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось null.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readOnly(value: unknown, label: string): "read-only" {
|
||||
if (value !== "read-only") {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось read-only.`);
|
||||
}
|
||||
return "read-only";
|
||||
}
|
||||
|
||||
function stratumValue(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !E30_STRATA.includes(value as E30Stratum)
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value as E30Stratum;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown, label: string): number | null {
|
||||
return value === null || value === undefined ? null : numberValue(value, label);
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null || value === undefined ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberTuple<const N extends number>(
|
||||
value: unknown,
|
||||
length: N,
|
||||
label: string,
|
||||
): readonly number[] {
|
||||
if (!Array.isArray(value) || value.length !== length) {
|
||||
throw new E30ReviewContractError(`${label}: неверная размерность.`);
|
||||
}
|
||||
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function numberArray(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function integerArray(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => integerValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function points(
|
||||
value: unknown,
|
||||
dimensions: 2 | 3,
|
||||
label: string,
|
||||
): readonly (readonly number[])[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив точек.`);
|
||||
}
|
||||
return value.map((item, index) => (
|
||||
numberTuple(item, dimensions, `${label}[${index}]`)
|
||||
));
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив строк.`);
|
||||
}
|
||||
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
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 E30ReviewContractError(`${label}: некорректный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E30ReviewResult {
|
||||
const source = record(value, "E30 result");
|
||||
const counts = record(source.stratum_counts, "E30 stratum_counts");
|
||||
const projection = record(source.projection, "E30 projection");
|
||||
const authority = record(source.authority, "E30 authority");
|
||||
falseValue(authority.commands_enabled, "E30 authority.commands_enabled");
|
||||
falseValue(
|
||||
authority.navigation_or_safety_accepted,
|
||||
"E30 authority.navigation_or_safety_accepted",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e30-materialization",
|
||||
"E30 result_id",
|
||||
),
|
||||
createdAtUtc: source.created_at_utc === null
|
||||
? null
|
||||
: stringValue(source.created_at_utc, "E30 created_at_utc"),
|
||||
reviewPackId: contentId(
|
||||
source.review_pack_id,
|
||||
"e30-review-pack",
|
||||
"E30 review_pack_id",
|
||||
),
|
||||
e29ResultId: contentId(
|
||||
source.e29_result_id,
|
||||
"e29-camera-geometry",
|
||||
"E30 e29_result_id",
|
||||
),
|
||||
sourceSessionId: stringValue(
|
||||
source.source_session_id,
|
||||
"E30 source_session_id",
|
||||
),
|
||||
itemCount: integerValue(source.item_count, "E30 item_count"),
|
||||
stratumCounts: Object.fromEntries(E30_STRATA.map((stratum) => [
|
||||
stratum,
|
||||
integerValue(counts[stratum], `E30 stratum_counts.${stratum}`),
|
||||
])) as Record<E30Stratum, number>,
|
||||
reasonTaxonomy: strings(source.reason_taxonomy, "E30 reason_taxonomy"),
|
||||
projection: {
|
||||
width: integerValue(projection.width, "E30 projection.width"),
|
||||
height: integerValue(projection.height, "E30 projection.height"),
|
||||
sourceId: stringValue(projection.source_id, "E30 projection.source_id"),
|
||||
calibrationSlot: stringValue(
|
||||
projection.calibration_slot,
|
||||
"E30 projection.calibration_slot",
|
||||
),
|
||||
},
|
||||
cameraEvidenceAvailable: booleanValue(
|
||||
source.camera_evidence_available,
|
||||
"E30 camera_evidence_available",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
source.human_review_complete,
|
||||
"E30 human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "E30 lab_published"),
|
||||
access: readOnly(source.access, "E30 access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnapshot(value: unknown): E30ReviewSnapshot {
|
||||
const source = record(value, "E30 snapshot");
|
||||
const rawBbox = source.bbox_xyxy;
|
||||
return {
|
||||
label: nullableString(source.label ?? source.semantic_class, "E30 snapshot.label"),
|
||||
geometryStatus: stringValue(
|
||||
source.geometry_status,
|
||||
"E30 snapshot.geometry_status",
|
||||
),
|
||||
geometryReason: nullableString(
|
||||
source.geometry_reason,
|
||||
"E30 snapshot.geometry_reason",
|
||||
),
|
||||
rangeM: nullableNumber(source.range_m, "E30 snapshot.range_m"),
|
||||
nearestRangeM: nullableNumber(
|
||||
source.nearest_range_m,
|
||||
"E30 snapshot.nearest_range_m",
|
||||
),
|
||||
bboxXyxy: rawBbox === null || rawBbox === undefined
|
||||
? null
|
||||
: numberTuple(rawBbox, 4, "E30 snapshot.bbox_xyxy") as
|
||||
readonly [number, number, number, number],
|
||||
};
|
||||
}
|
||||
|
||||
function parseItem(value: unknown): E30ReviewItem {
|
||||
const source = record(value, "E30 item");
|
||||
const locator = record(source.locator, "E30 item.locator");
|
||||
const materialized = record(
|
||||
source.materialization,
|
||||
"E30 item.materialization",
|
||||
);
|
||||
const review = record(source.review, "E30 item.review");
|
||||
const triage = record(
|
||||
source.engineering_triage,
|
||||
"E30 item.engineering_triage",
|
||||
);
|
||||
const locatorKind = stringValue(locator.kind, "E30 item.locator.kind");
|
||||
if (
|
||||
locatorKind !== "semantic-observation"
|
||||
&& locatorKind !== "geometry-only-cluster"
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 item.locator.kind не поддерживается.");
|
||||
}
|
||||
if (review.state !== "unreviewed") {
|
||||
throw new E30ReviewContractError("E30 item уже изменён вне review gate.");
|
||||
}
|
||||
if (
|
||||
triage.provenance !== "deterministic-evidence-readiness/v1"
|
||||
|| (
|
||||
triage.state !== "ready-for-ai-review"
|
||||
&& triage.state !== "blocked-camera-frame-unavailable"
|
||||
)
|
||||
|| (triage.attention !== "standard" && triage.attention !== "elevated")
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 engineering triage не поддерживается.");
|
||||
}
|
||||
return {
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"E30 item.item_id",
|
||||
),
|
||||
sequence: integerValue(source.sequence, "E30 item.sequence"),
|
||||
reviewKey: stringValue(source.review_key, "E30 item.review_key"),
|
||||
stratum: stratumValue(source.stratum, "E30 item.stratum"),
|
||||
rangeBucket: stringValue(source.range_bucket, "E30 item.range_bucket"),
|
||||
frameIndex: integerValue(source.frame_index, "E30 item.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
source.source_frame_index,
|
||||
"E30 item.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
source.session_seconds,
|
||||
"E30 item.session_seconds",
|
||||
),
|
||||
locatorKind,
|
||||
snapshot: parseSnapshot(source.snapshot),
|
||||
materialization: {
|
||||
framePointCount: integerValue(
|
||||
materialized.frame_point_count,
|
||||
"E30 materialization.frame_point_count",
|
||||
),
|
||||
projectedPointCount: integerValue(
|
||||
materialized.projected_point_count,
|
||||
"E30 materialization.projected_point_count",
|
||||
),
|
||||
candidatePointCount: integerValue(
|
||||
materialized.candidate_point_count,
|
||||
"E30 materialization.candidate_point_count",
|
||||
),
|
||||
selectedPointCount: integerValue(
|
||||
materialized.selected_point_count,
|
||||
"E30 materialization.selected_point_count",
|
||||
),
|
||||
rejectedCandidatePointCount: integerValue(
|
||||
materialized.rejected_candidate_point_count,
|
||||
"E30 materialization.rejected_candidate_point_count",
|
||||
),
|
||||
selectedAndCandidateLossless: trueValue(
|
||||
materialized.selected_and_candidate_lossless,
|
||||
"E30 materialization.selected_and_candidate_lossless",
|
||||
),
|
||||
freeSpaceValid: falseValue(
|
||||
materialized.free_space_valid,
|
||||
"E30 materialization.free_space_valid",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
materialized.human_review_complete,
|
||||
"E30 materialization.human_review_complete",
|
||||
),
|
||||
detectorScore: nullableNumber(
|
||||
materialized.detector_score,
|
||||
"E30 materialization.detector_score",
|
||||
),
|
||||
},
|
||||
cameraFrameAvailable: booleanValue(
|
||||
source.camera_frame_available,
|
||||
"E30 item.camera_frame_available",
|
||||
),
|
||||
engineeringTriage: {
|
||||
provenance: "deterministic-evidence-readiness/v1",
|
||||
state: triage.state,
|
||||
attention: triage.attention,
|
||||
signals: strings(triage.signals, "E30 engineering_triage.signals"),
|
||||
semanticVerdict: nullValue(
|
||||
triage.semantic_verdict,
|
||||
"E30 engineering_triage.semantic_verdict",
|
||||
),
|
||||
humanExceptionRequired: nullValue(
|
||||
triage.human_exception_required,
|
||||
"E30 engineering_triage.human_exception_required",
|
||||
),
|
||||
},
|
||||
review: {
|
||||
state: "unreviewed",
|
||||
reasonCode: nullValue(review.reason_code, "E30 review.reason_code"),
|
||||
notes: nullValue(review.notes, "E30 review.notes"),
|
||||
},
|
||||
access: readOnly(source.access, "E30 item.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItem(value: unknown): E30ReviewItem {
|
||||
return parseItem(value);
|
||||
}
|
||||
|
||||
export function parseE30ReviewCatalog(value: unknown): E30ReviewCatalog {
|
||||
const source = record(value, "E30 catalog");
|
||||
if (source.schema_version !== "missioncore.laboratory-e30-catalog/v1") {
|
||||
throw new E30ReviewContractError("E30 catalog schema не поддерживается.");
|
||||
}
|
||||
if (!Array.isArray(source.items)) {
|
||||
throw new E30ReviewContractError("E30 catalog.items имеет неверный формат.");
|
||||
}
|
||||
readOnly(source.access, "E30 catalog.access");
|
||||
return {
|
||||
configured: booleanValue(source.configured, "E30 catalog.configured"),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"E30 catalog.candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(source.invalid_total, "E30 catalog.invalid_total"),
|
||||
items: source.items.map(parseResult),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItems(value: unknown): E30ReviewItems {
|
||||
const source = record(value, "E30 items");
|
||||
if (
|
||||
source.schema_version !== "missioncore.laboratory-e30-items/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 items schema не поддерживается.");
|
||||
}
|
||||
readOnly(source.access, "E30 items.access");
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E30 items.result_id"),
|
||||
stratum: stratumValue(source.stratum, "E30 items.stratum"),
|
||||
items: source.items.map(parseItem),
|
||||
total: integerValue(source.total, "E30 items.total"),
|
||||
nextCursor: source.next_cursor === null
|
||||
? null
|
||||
: integerValue(source.next_cursor, "E30 items.next_cursor"),
|
||||
reasonTaxonomy: strings(source.reason_taxonomy, "E30 items.reason_taxonomy"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItemDetail(value: unknown): E30ReviewItemDetail {
|
||||
const response = record(value, "E30 item detail response");
|
||||
if (response.schema_version !== "missioncore.laboratory-e30-item-detail/v1") {
|
||||
throw new E30ReviewContractError("E30 detail schema не поддерживается.");
|
||||
}
|
||||
readOnly(response.access, "E30 detail access");
|
||||
const source = record(response.item, "E30 item detail");
|
||||
const item = parseItem(source);
|
||||
const selected = record(source.selected, "E30 selected");
|
||||
const candidate = record(source.candidate, "E30 candidate");
|
||||
const projection = record(source.projection, "E30 projection detail");
|
||||
const pose = record(source.pose, "E30 pose");
|
||||
const cameraFrame = source.camera_frame === null
|
||||
? null
|
||||
: record(source.camera_frame, "E30 camera_frame");
|
||||
const projectedLength = integerArray(
|
||||
projection.source_indices,
|
||||
"E30 projection.source_indices",
|
||||
).length;
|
||||
const projectionCollections = [
|
||||
projection.points_map_xyz_m,
|
||||
projection.pixels_xy,
|
||||
projection.depth_m,
|
||||
projection.point_class,
|
||||
projection.point_height_m,
|
||||
projection.candidate_mask,
|
||||
projection.selected_mask,
|
||||
];
|
||||
if (
|
||||
projectionCollections.some(
|
||||
(collection) => !Array.isArray(collection) || collection.length !== projectedLength,
|
||||
)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 projection arrays имеют разную длину.");
|
||||
}
|
||||
const candidateMask = integerArray(
|
||||
projection.candidate_mask,
|
||||
"E30 projection.candidate_mask",
|
||||
);
|
||||
const selectedMask = integerArray(
|
||||
projection.selected_mask,
|
||||
"E30 projection.selected_mask",
|
||||
);
|
||||
if (
|
||||
candidateMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
|
||||
|| selectedMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 projection mask должна быть бинарной.");
|
||||
}
|
||||
const selectedSourceIndices = integerArray(
|
||||
selected.source_indices,
|
||||
"E30 selected.source_indices",
|
||||
);
|
||||
const selectedPoints = points(
|
||||
selected.points_map_xyz_m,
|
||||
3,
|
||||
"E30 selected.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[];
|
||||
const candidateSourceIndices = integerArray(
|
||||
candidate.source_indices,
|
||||
"E30 candidate.source_indices",
|
||||
);
|
||||
const candidatePoints = points(
|
||||
candidate.points_map_xyz_m,
|
||||
3,
|
||||
"E30 candidate.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[];
|
||||
if (
|
||||
selectedSourceIndices.length !== selectedPoints.length
|
||||
|| candidateSourceIndices.length !== candidatePoints.length
|
||||
|| selectedSourceIndices.length !== item.materialization.selectedPointCount
|
||||
|| candidateSourceIndices.length !== item.materialization.candidatePointCount
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 selected/candidate arrays расходятся.");
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
cameraFrame: cameraFrame === null
|
||||
? null
|
||||
: {
|
||||
available: trueValue(
|
||||
cameraFrame.available,
|
||||
"E30 camera_frame.available",
|
||||
),
|
||||
url: stringValue(cameraFrame.url, "E30 camera_frame.url"),
|
||||
sha256: stringValue(cameraFrame.sha256, "E30 camera_frame.sha256"),
|
||||
width: integerValue(cameraFrame.width, "E30 camera_frame.width"),
|
||||
height: integerValue(cameraFrame.height, "E30 camera_frame.height"),
|
||||
sourceFrameIndex: integerValue(
|
||||
cameraFrame.source_frame_index,
|
||||
"E30 camera_frame.source_frame_index",
|
||||
),
|
||||
exactSourceFrame: trueValue(
|
||||
cameraFrame.exact_source_frame,
|
||||
"E30 camera_frame.exact_source_frame",
|
||||
),
|
||||
},
|
||||
selected: {
|
||||
sourceIndices: selectedSourceIndices,
|
||||
pointsMapXyzM: selectedPoints,
|
||||
},
|
||||
candidate: {
|
||||
sourceIndices: candidateSourceIndices,
|
||||
pointsMapXyzM: candidatePoints,
|
||||
},
|
||||
projection: {
|
||||
sourceIndices: integerArray(
|
||||
projection.source_indices,
|
||||
"E30 projection.source_indices",
|
||||
),
|
||||
pointsMapXyzM: points(
|
||||
projection.points_map_xyz_m,
|
||||
3,
|
||||
"E30 projection.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[],
|
||||
pixelsXy: points(
|
||||
projection.pixels_xy,
|
||||
2,
|
||||
"E30 projection.pixels_xy",
|
||||
) as readonly (readonly [number, number])[],
|
||||
depthM: numberArray(projection.depth_m, "E30 projection.depth_m"),
|
||||
pointClass: integerArray(
|
||||
projection.point_class,
|
||||
"E30 projection.point_class",
|
||||
),
|
||||
pointHeightM: numberArray(
|
||||
projection.point_height_m,
|
||||
"E30 projection.point_height_m",
|
||||
),
|
||||
candidateMask,
|
||||
selectedMask,
|
||||
},
|
||||
pose: {
|
||||
positionMapXyzM: numberTuple(
|
||||
pose.position_map_xyz_m,
|
||||
3,
|
||||
"E30 pose.position_map_xyz_m",
|
||||
) as readonly [number, number, number],
|
||||
orientationMapFromLidarXyzw: numberTuple(
|
||||
pose.orientation_map_from_lidar_xyzw,
|
||||
4,
|
||||
"E30 pose.orientation_map_from_lidar_xyzw",
|
||||
) as readonly [number, number, number, number],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30ReviewContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validResultId(value: string): boolean {
|
||||
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validItemId(value: string): boolean {
|
||||
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/laboratory/e30/reviews?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseE30ReviewCatalog(
|
||||
await responseJson(response, "Не удалось получить LAB E30."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewItems(
|
||||
resultId: string,
|
||||
stratum: E30Stratum,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewItems> {
|
||||
if (!validResultId(resultId) || !E30_STRATA.includes(stratum)) {
|
||||
throw new E30ReviewContractError("Некорректный E30 review query.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/items?stratum=${stratum}&limit=128&cursor=0`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30ReviewItems(
|
||||
await responseJson(response, "Не удалось получить выборку LAB E30."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewItemDetail(
|
||||
resultId: string,
|
||||
itemId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewItemDetail> {
|
||||
if (!validResultId(resultId) || !validItemId(itemId)) {
|
||||
throw new E30ReviewContractError("Некорректный E30 review item id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/items/${itemId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30ReviewItemDetail(
|
||||
await responseJson(response, "Не удалось получить доказательство LAB E30."),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user