feat(lab): complete E30 evidence review gate
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { E30EngineeringGeneration } from "../../core/laboratory/e30Engineering";
|
||||
|
||||
export function E30EngineeringGenerationSummary({
|
||||
generation,
|
||||
}: {
|
||||
generation: E30EngineeringGeneration;
|
||||
}) {
|
||||
const summary = generation.summary;
|
||||
const confirmed = summary.verdictDistribution.confirmed ?? 0;
|
||||
const corrected = summary.verdictDistribution.corrected ?? 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="e30-engineering-generation"
|
||||
aria-label="Результат A3 engineering generation"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">A3 · IMMUTABLE ENGINEERING GENERATION</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={summary.humanExceptionCount ? "warning" : "accent"}
|
||||
>
|
||||
{summary.reviewedItemCount} / {summary.itemCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Подтверждено</dt>
|
||||
<dd>{confirmed.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исправлено</dt>
|
||||
<dd>{corrected.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исключения</dt>
|
||||
<dd>{summary.humanExceptionCount.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Средняя уверенность</dt>
|
||||
<dd>
|
||||
{(summary.meanConfidence * 100).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
%
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { E30ReviewItemDetail } from "../../core/laboratory/e30Review";
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function evidenceRange(item: E30ReviewItemDetail): string {
|
||||
const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM;
|
||||
return value === null ? "недоступна" : `${formatNumber(value)} м`;
|
||||
}
|
||||
|
||||
export function E30EvidenceTelemetry({
|
||||
detail,
|
||||
mode,
|
||||
}: {
|
||||
detail: E30ReviewItemDetail;
|
||||
mode: "camera" | "3d";
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
className="e30-evidence-telemetry"
|
||||
aria-label="Параметры доказательства"
|
||||
>
|
||||
<span>
|
||||
{mode === "camera"
|
||||
? "Точный camera frame · LiDAR projection"
|
||||
: "Map frame · Z вверх"}
|
||||
</span>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Кадр / время</dt>
|
||||
<dd>
|
||||
{formatNumber(detail.sourceFrameIndex)}
|
||||
{" · "}
|
||||
{formatNumber(detail.sessionSeconds)} с
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>LiDAR</dt>
|
||||
<dd>{formatNumber(detail.materialization.projectedPointCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Кандидаты</dt>
|
||||
<dd>{formatNumber(detail.materialization.candidatePointCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Выбрано / отклонено</dt>
|
||||
<dd>
|
||||
{formatNumber(detail.materialization.selectedPointCount)}
|
||||
{" / "}
|
||||
{formatNumber(detail.materialization.rejectedCandidatePointCount)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Класс E29</dt>
|
||||
<dd>{detail.snapshot.geometryStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Дальность</dt>
|
||||
<dd>{evidenceRange(detail)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<span>Точные индексы LiDAR</span>
|
||||
<code>
|
||||
{detail.selected.sourceIndices.length
|
||||
? detail.selected.sourceIndices.slice(0, 18).join(", ")
|
||||
: "нет выбранных точек"}
|
||||
{detail.selected.sourceIndices.length > 18 ? " …" : ""}
|
||||
</code>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryEvidenceViewerMode<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function LaboratoryEvidenceViewer<T extends string>({
|
||||
label,
|
||||
mode,
|
||||
modes,
|
||||
expanded,
|
||||
onModeChange,
|
||||
onExpandedChange,
|
||||
actions,
|
||||
overlay,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
mode: T;
|
||||
modes: readonly LaboratoryEvidenceViewerMode<T>[];
|
||||
expanded: boolean;
|
||||
onModeChange: (mode: T) => void;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
actions?: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
onExpandedChange(false);
|
||||
window.requestAnimationFrame(() => expandButtonRef.current?.focus());
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
const viewer = (
|
||||
<section
|
||||
className="laboratory-evidence-viewer"
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return expanded ? createPortal(viewer, document.body) : viewer;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Select,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryOption<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type LaboratoryExecutionClass =
|
||||
| "deterministic"
|
||||
| "ai-inference"
|
||||
| "hybrid";
|
||||
export type LaboratoryMethodCompleteness = "complete" | "legacy-partial";
|
||||
export type LaboratoryEvidenceKind = "recorded-replay" | "diagnostic-model";
|
||||
|
||||
export interface LaboratoryMethodComponent {
|
||||
kind: "source" | "tool" | "model" | "algorithm" | "runtime";
|
||||
name: string;
|
||||
version: string;
|
||||
role: string;
|
||||
identitySha256: string | null;
|
||||
}
|
||||
|
||||
export interface LaboratoryMethod {
|
||||
completeness: LaboratoryMethodCompleteness;
|
||||
executionClass: LaboratoryExecutionClass;
|
||||
pipelineId: string;
|
||||
components: readonly LaboratoryMethodComponent[];
|
||||
}
|
||||
|
||||
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
|
||||
deterministic: "Детерминированный",
|
||||
"ai-inference": "AI inference",
|
||||
hybrid: "Гибридный",
|
||||
};
|
||||
|
||||
const COMPONENT_LABELS: Record<LaboratoryMethodComponent["kind"], string> = {
|
||||
source: "Источник",
|
||||
tool: "Инструмент",
|
||||
model: "Модель",
|
||||
algorithm: "Алгоритм",
|
||||
runtime: "Runtime",
|
||||
};
|
||||
|
||||
export function LaboratorySelector<T extends string>({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly LaboratoryOption<T>[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="laboratory-selector">
|
||||
<div>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="laboratory-selector__control">
|
||||
<span>{label}</span>
|
||||
<Select
|
||||
label={`Выбрать: ${label}`}
|
||||
value={value}
|
||||
options={options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange(next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratorySummary({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
statusTone = "neutral",
|
||||
facts,
|
||||
method,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
|
||||
facts: readonly { label: string; value: string }[];
|
||||
method?: LaboratoryMethod | null;
|
||||
}) {
|
||||
const methodComplete = method?.completeness === "complete";
|
||||
return (
|
||||
<section className="laboratory-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛАБОРАТОРНАЯ РАБОТА</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
</header>
|
||||
|
||||
<dl className="laboratory-summary__facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{method ? (
|
||||
<div className="laboratory-summary__method">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">МЕТОД</span>
|
||||
<strong>{method.pipelineId}</strong>
|
||||
</div>
|
||||
<small>
|
||||
{EXECUTION_LABELS[method.executionClass]}
|
||||
{" · "}
|
||||
{methodComplete ? "полная идентичность" : "legacy · частично"}
|
||||
</small>
|
||||
</header>
|
||||
<dl className="laboratory-summary__components">
|
||||
{method.components.map((component, index) => (
|
||||
<div key={`${component.kind}:${component.name}:${index}`}>
|
||||
<dt>{COMPONENT_LABELS[component.kind]}</dt>
|
||||
<dd>
|
||||
<strong>{component.name}</strong>
|
||||
<small>
|
||||
{component.role}
|
||||
{" · "}
|
||||
{component.version}
|
||||
{component.identitySha256
|
||||
? ` · ${component.identitySha256.slice(0, 12)}`
|
||||
: ""}
|
||||
</small>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryEvidence({
|
||||
eyebrow,
|
||||
title,
|
||||
kind,
|
||||
resizable = false,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
kind: LaboratoryEvidenceKind;
|
||||
resizable?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="lab-result-surface"
|
||||
data-evidence-kind={kind}
|
||||
data-resizable={resizable ? "true" : undefined}
|
||||
>
|
||||
<header>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<strong>{title}</strong>
|
||||
</header>
|
||||
<div className="laboratory-evidence-frame">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryWorkTemplate({
|
||||
summary,
|
||||
evidence,
|
||||
result = null,
|
||||
details = null,
|
||||
}: {
|
||||
summary: ReactNode;
|
||||
evidence: ReactNode;
|
||||
result?: ReactNode;
|
||||
details?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="laboratory-work-template">
|
||||
{summary}
|
||||
{evidence}
|
||||
{result}
|
||||
{details}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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."),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/shell.css";
|
||||
@import "./styles/workspaces.css";
|
||||
@import "./styles/laboratory.css";
|
||||
@import "./styles/e30-human-review.css";
|
||||
@import "./styles/spatial.css";
|
||||
@import "./styles/device.css";
|
||||
@import "./styles/responsive.css";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
.e30-human-review {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.e30-human-review__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.e30-human-review h3,
|
||||
.e30-human-review p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-human-review h3 {
|
||||
margin-top: 0.28rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.e30-human-review p {
|
||||
max-width: 58rem;
|
||||
margin-top: 0.28rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.e30-human-review__field > span,
|
||||
.e30-human-review__actions > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-human-review__form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 0.55fr) minmax(18rem, 1fr);
|
||||
align-items: end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.e30-human-review__field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.e30-human-review__field .nodedc-select-anchor,
|
||||
.e30-human-review__field .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.e30-human-review__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.e30-human-review__impact {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.e30-human-review__impact span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-human-review__impact strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.e30-human-review__actions > span {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.e30-human-review__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.e30-human-review__form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.e30-engineering-generation {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dl,
|
||||
.e30-engineering-generation dt,
|
||||
.e30-engineering-generation dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dl > div {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
padding: 0.5rem 0.55rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dd {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.e30-engineering-generation dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
.lab-archive-workspace,
|
||||
.lab-result-surface,
|
||||
.laboratory-work-template {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.recordings-workspace {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.lab-result-surface > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.lab-result-surface > header strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.lab-archive-workspace {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr);
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector h2,
|
||||
.laboratory-selector p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-selector h2 {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector p {
|
||||
max-width: 60rem;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.laboratory-selector__control {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.laboratory-selector__control > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-selector__control .nodedc-select-anchor,
|
||||
.laboratory-selector__control .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.laboratory-work-output {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-summary,
|
||||
.laboratory-result-summary {
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-summary > header,
|
||||
.laboratory-result-summary > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.laboratory-summary h2,
|
||||
.laboratory-summary p,
|
||||
.laboratory-summary dl,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-result-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-summary h2,
|
||||
.laboratory-result-summary h2 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.laboratory-summary > header p,
|
||||
.laboratory-result-summary > p {
|
||||
max-width: 66rem;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts > div,
|
||||
.laboratory-result-metrics > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts dt,
|
||||
.laboratory-summary__facts dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts dt,
|
||||
.laboratory-result-metrics span,
|
||||
.laboratory-result-metrics small,
|
||||
.laboratory-summary__method small,
|
||||
.laboratory-summary__components dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts dd,
|
||||
.laboratory-result-metrics strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 660;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-summary__method {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__method > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__method > header > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__method > header strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.66rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-summary__components {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__components > div {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.58rem 0.65rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__components dt {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-summary__components dd {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__components strong,
|
||||
.laboratory-summary__components small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-summary__components strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.63rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.lab-result-surface[data-resizable="true"] .laboratory-evidence-frame {
|
||||
height: clamp(42rem, 68vh, 58rem);
|
||||
min-height: 42rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame > .spatial-workspace {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame > .lidar-quality-workspace {
|
||||
min-height: 42rem;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-selector,
|
||||
.lab-archive-workspace[data-viewer-focused="true"]
|
||||
.laboratory-work-template > :not(.lab-result-surface),
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface > header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-work-output,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-work-template,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.laboratory-result-pending {
|
||||
display: grid;
|
||||
min-height: 10rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.laboratory-result-pending strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.laboratory-result-pending p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.laboratory-result-summary {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review h2 {
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button:hover,
|
||||
.laboratory-frame-review__picker button.is-active {
|
||||
background: rgb(255 255 255 / 0.09);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker span {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 660;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker small,
|
||||
.laboratory-frame-review__detail small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__state {
|
||||
display: flex;
|
||||
min-height: 6rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__conflicts p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer__stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer__stage > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer__controls {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-expanded="true"] {
|
||||
position: fixed;
|
||||
z-index: var(--nodedc-layer-overlay);
|
||||
inset: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-expanded="true"] .e30-evidence-scene,
|
||||
.laboratory-evidence-viewer[data-expanded="true"] .e30-projection-scene {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.e30-review-workspace {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.e30-review-workspace__header,
|
||||
.e30-review-workspace__case {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace h2,
|
||||
.e30-review-workspace h3,
|
||||
.e30-review-workspace p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-review-workspace h2,
|
||||
.e30-review-workspace h3 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace p {
|
||||
max-width: 62rem;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.e30-review-workspace__strata {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.e30-review-workspace__strata .nodedc-segmented__item {
|
||||
min-width: max-content;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.e30-review-workspace__body {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(10.5rem, 13vw, 12rem) minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
height: clamp(40rem, 68vh, 54rem);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.e30-review-workspace__items,
|
||||
.e30-review-workspace__detail {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
}
|
||||
|
||||
.e30-review-workspace__items {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 0.3rem;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__items > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.2rem 0.5rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__items > header small,
|
||||
.e30-review-workspace__item small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__item-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
gap: 0.3rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.08rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__item.nodedc-button {
|
||||
display: grid;
|
||||
min-height: auto;
|
||||
justify-content: flex-start;
|
||||
gap: 0.18rem;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
padding: 0.62rem 0.7rem;
|
||||
box-shadow: none;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.e30-review-workspace__item.nodedc-button:hover {
|
||||
background: var(--nodedc-panel-item-hover-bg);
|
||||
}
|
||||
|
||||
.e30-review-workspace__item.nodedc-button[data-active="true"] {
|
||||
background: var(--nodedc-panel-item-active-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.e30-review-workspace__item span,
|
||||
.e30-review-workspace__item strong,
|
||||
.e30-review-workspace__item small {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 0.61rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e30-review-workspace__item strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.e30-review-workspace__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__case {
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__case h3 {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 10rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.e30-review-evidence {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.e30-evidence-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.e30-projection-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.e30-projection-scene canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.e30-projection-scene__state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__viewport canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__viewport canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__toolbar {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
left: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__toolbar .nodedc-button {
|
||||
background: var(--nodedc-floating-surface);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.e30-evidence-scene__gestures,
|
||||
.e30-evidence-scene__legend {
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
color: var(--nodedc-text-secondary);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.e30-evidence-scene__gestures {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
padding: 0.43rem 0.55rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__gestures span,
|
||||
.e30-evidence-scene__legend span {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
padding: 0.42rem 0.55rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span::before {
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span[data-point="rejected"]::before {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span[data-point="selected"]::before {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span[data-point="depth"]::before {
|
||||
background: linear-gradient(90deg, #4f72ee, #b95ee8, #ee654f);
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: grid;
|
||||
width: min(11.5rem, calc(100% - 1.2rem));
|
||||
gap: 0.4rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.5rem 0.6rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry > span,
|
||||
.e30-evidence-telemetry dt,
|
||||
.e30-evidence-telemetry > div > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry dl > div,
|
||||
.e30-evidence-telemetry > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry dt,
|
||||
.e30-evidence-telemetry dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry dd {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry code {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.48rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.laboratory-selector {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.laboratory-summary__facts,
|
||||
.laboratory-result-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.e30-review-workspace__strata {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.e30-review-workspace__body {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.e30-review-workspace__items {
|
||||
height: 18rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__detail {
|
||||
min-height: 40rem;
|
||||
}
|
||||
|
||||
.e30-review-evidence {
|
||||
min-height: 30rem;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2384,455 +2384,14 @@
|
||||
}
|
||||
|
||||
.recordings-workspace,
|
||||
.lab-archive-workspace,
|
||||
.lab-result-surface,
|
||||
.laboratory-work-template {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.recordings-workspace {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.lab-result-surface > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.lab-result-surface > header strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.lab-archive-workspace {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr);
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector h2,
|
||||
.laboratory-selector p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-selector h2 {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector p {
|
||||
max-width: 60rem;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.laboratory-selector__control {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.laboratory-selector__control > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-selector__control .nodedc-select-anchor,
|
||||
.laboratory-selector__control .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.laboratory-work-output {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-task,
|
||||
.laboratory-method,
|
||||
.laboratory-result-summary {
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-task > header,
|
||||
.laboratory-method > header,
|
||||
.laboratory-result-summary > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.laboratory-task h2,
|
||||
.laboratory-task p,
|
||||
.laboratory-task dl,
|
||||
.laboratory-method h2,
|
||||
.laboratory-method p,
|
||||
.laboratory-method ul,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-result-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-task h2,
|
||||
.laboratory-method h2,
|
||||
.laboratory-result-summary h2 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.laboratory-task p,
|
||||
.laboratory-method p,
|
||||
.laboratory-result-summary > p {
|
||||
max-width: 66rem;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.laboratory-method__summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-method__summary > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-method__summary span,
|
||||
.laboratory-method li > span,
|
||||
.laboratory-method small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-method__summary strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-method ul {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.55rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.laboratory-method li {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.62rem 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-method li > span {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-method li > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.laboratory-method li strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.66rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-method code {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-task dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-task dl > div,
|
||||
.laboratory-result-metrics > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-task dt,
|
||||
.laboratory-task dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-task dt,
|
||||
.laboratory-result-metrics span,
|
||||
.laboratory-result-metrics small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-task dd,
|
||||
.laboratory-result-metrics strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 660;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.lab-result-surface[data-resizable="true"] .laboratory-evidence-frame {
|
||||
height: clamp(42rem, 68vh, 58rem);
|
||||
min-height: 42rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame > .spatial-workspace {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame > .lidar-quality-workspace {
|
||||
min-height: 42rem;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-selector,
|
||||
.lab-archive-workspace[data-viewer-focused="true"]
|
||||
.laboratory-work-template > :not(.lab-result-surface),
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface > header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-work-output,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-work-template,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.laboratory-result-pending {
|
||||
display: grid;
|
||||
min-height: 10rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.laboratory-result-pending strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.laboratory-result-pending p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.laboratory-result-summary {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review h2 {
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button:hover,
|
||||
.laboratory-frame-review__picker button.is-active {
|
||||
background: rgb(255 255 255 / 0.09);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker span {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 660;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker small,
|
||||
.laboratory-frame-review__detail small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__state {
|
||||
display: flex;
|
||||
min-height: 6rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__conflicts p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.laboratory-selector {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.laboratory-task dl,
|
||||
.laboratory-result-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dataset-entry {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(20rem, 1.2fr) minmax(22rem, 0.8fr);
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
import type { E30ReviewItemDetail } from "../core/laboratory/e30Review";
|
||||
|
||||
interface E30EvidencePointCloudProps {
|
||||
detail: E30ReviewItemDetail;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
): THREE.Color {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
if (value.startsWith("#")) {
|
||||
return new THREE.Color(value);
|
||||
}
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3
|
||||
? channels
|
||||
: fallback;
|
||||
return new THREE.Color(red / 255, green / 255, blue / 255);
|
||||
}
|
||||
|
||||
function createPointTexture(): THREE.CanvasTexture {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context) {
|
||||
const gradient = context.createRadialGradient(32, 32, 2, 32, 32, 30);
|
||||
gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
|
||||
gradient.addColorStop(0.72, "rgba(255, 255, 255, 0.94)");
|
||||
gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, 64, 64);
|
||||
}
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function toMapScenePositions(
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[],
|
||||
positionMapXyzM: readonly [number, number, number],
|
||||
): Float32Array {
|
||||
const positions = new Float32Array(pointsMapXyzM.length * 3);
|
||||
pointsMapXyzM.forEach(([mapX, mapY, mapZ], index) => {
|
||||
const offset = index * 3;
|
||||
positions[offset] = mapX - positionMapXyzM[0];
|
||||
positions[offset + 1] = mapZ - positionMapXyzM[2];
|
||||
positions[offset + 2] = -(mapY - positionMapXyzM[1]);
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
function boundsFromPositions(positions: Float32Array): THREE.Box3 {
|
||||
const bounds = new THREE.Box3();
|
||||
const point = new THREE.Vector3();
|
||||
for (let offset = 0; offset < positions.length; offset += 3) {
|
||||
point.set(positions[offset], positions[offset + 1], positions[offset + 2]);
|
||||
bounds.expandByPoint(point);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function evidenceViewFromPositions(positions: Float32Array): {
|
||||
target: THREE.Vector3;
|
||||
radius: number;
|
||||
} {
|
||||
if (!positions.length) {
|
||||
return { target: new THREE.Vector3(), radius: 0.65 };
|
||||
}
|
||||
const xValues: number[] = [];
|
||||
const yValues: number[] = [];
|
||||
const zValues: number[] = [];
|
||||
for (let offset = 0; offset < positions.length; offset += 3) {
|
||||
xValues.push(positions[offset]);
|
||||
yValues.push(positions[offset + 1]);
|
||||
zValues.push(positions[offset + 2]);
|
||||
}
|
||||
const target = new THREE.Vector3(
|
||||
percentile(xValues, 0.5),
|
||||
percentile(yValues, 0.5),
|
||||
percentile(zValues, 0.5),
|
||||
);
|
||||
const radii = xValues.map((x, index) => Math.hypot(
|
||||
x - target.x,
|
||||
yValues[index] - target.y,
|
||||
zValues[index] - target.z,
|
||||
));
|
||||
return {
|
||||
target,
|
||||
radius: Math.max(percentile(radii, 0.9), 0.65),
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(values: readonly number[], fraction: number): number {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.min(
|
||||
sorted.length - 1,
|
||||
Math.max(0, Math.floor((sorted.length - 1) * fraction)),
|
||||
);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
export function E30EvidencePointCloud({ detail }: E30EvidencePointCloudProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const contextGeometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const rejectedGeometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const selectedGeometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const contextMaterialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const rejectedMaterialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const selectedMaterialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const gridRef = useRef<THREE.GridHelper | null>(null);
|
||||
const viewTargetRef = useRef(new THREE.Vector3());
|
||||
const viewDistanceRef = useRef(4);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setRenderError("Браузер не смог создать WebGL-сцену доказательства E30.");
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(
|
||||
tokenColor(host, "--nodedc-canvas", [5, 5, 6]),
|
||||
1,
|
||||
);
|
||||
renderer.domElement.setAttribute(
|
||||
"aria-label",
|
||||
"Интерактивное 3D-доказательство E30",
|
||||
);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 500);
|
||||
camera.position.set(4, 2.5, 4);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 0.2;
|
||||
controls.maxDistance = 300;
|
||||
controls.minPolarAngle = 0.04;
|
||||
controls.maxPolarAngle = Math.PI - 0.04;
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
controlsRef.current = controls;
|
||||
|
||||
const pointTexture = createPointTexture();
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
const contextMaterial = new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
map: pointTexture,
|
||||
alphaTest: 0.04,
|
||||
size: 2.2,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
});
|
||||
const contextPoints = new THREE.Points(contextGeometry, contextMaterial);
|
||||
contextPoints.renderOrder = 0;
|
||||
scene.add(contextPoints);
|
||||
contextGeometryRef.current = contextGeometry;
|
||||
contextMaterialRef.current = contextMaterial;
|
||||
|
||||
const rejectedGeometry = new THREE.BufferGeometry();
|
||||
const rejectedMaterial = new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]),
|
||||
map: pointTexture,
|
||||
alphaTest: 0.04,
|
||||
size: 5.5,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.98,
|
||||
depthWrite: true,
|
||||
});
|
||||
const rejectedPoints = new THREE.Points(rejectedGeometry, rejectedMaterial);
|
||||
rejectedPoints.renderOrder = 1;
|
||||
scene.add(rejectedPoints);
|
||||
rejectedGeometryRef.current = rejectedGeometry;
|
||||
rejectedMaterialRef.current = rejectedMaterial;
|
||||
|
||||
const selectedGeometry = new THREE.BufferGeometry();
|
||||
const selectedMaterial = new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
map: pointTexture,
|
||||
alphaTest: 0.04,
|
||||
size: 10.5,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const selectedPoints = new THREE.Points(selectedGeometry, selectedMaterial);
|
||||
selectedPoints.renderOrder = 3;
|
||||
scene.add(selectedPoints);
|
||||
selectedGeometryRef.current = selectedGeometry;
|
||||
selectedMaterialRef.current = selectedMaterial;
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
10,
|
||||
20,
|
||||
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
|
||||
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
|
||||
);
|
||||
const gridMaterials = Array.isArray(grid.material)
|
||||
? grid.material
|
||||
: [grid.material];
|
||||
gridMaterials.forEach((material) => {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.16;
|
||||
material.depthWrite = false;
|
||||
});
|
||||
gridRef.current = grid;
|
||||
scene.add(grid);
|
||||
|
||||
const sensorMarkerGeometry = new THREE.RingGeometry(0.08, 0.12, 32);
|
||||
const sensorMarkerMaterial = new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-secondary", [185, 187, 192]),
|
||||
transparent: true,
|
||||
opacity: 0.64,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sensorMarker = new THREE.Mesh(
|
||||
sensorMarkerGeometry,
|
||||
sensorMarkerMaterial,
|
||||
);
|
||||
sensorMarker.rotation.x = -Math.PI / 2;
|
||||
sensorMarker.renderOrder = 3;
|
||||
scene.add(sensorMarker);
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(host);
|
||||
resize();
|
||||
|
||||
let animationFrame = 0;
|
||||
const render = () => {
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
render();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
resizeObserver.disconnect();
|
||||
controls.dispose();
|
||||
contextGeometry.dispose();
|
||||
contextMaterial.dispose();
|
||||
rejectedGeometry.dispose();
|
||||
rejectedMaterial.dispose();
|
||||
selectedGeometry.dispose();
|
||||
selectedMaterial.dispose();
|
||||
grid.geometry.dispose();
|
||||
gridMaterials.forEach((material) => material.dispose());
|
||||
sensorMarkerGeometry.dispose();
|
||||
sensorMarkerMaterial.dispose();
|
||||
pointTexture.dispose();
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
contextGeometryRef.current = null;
|
||||
rejectedGeometryRef.current = null;
|
||||
selectedGeometryRef.current = null;
|
||||
contextMaterialRef.current = null;
|
||||
rejectedMaterialRef.current = null;
|
||||
selectedMaterialRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
gridRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const contextGeometry = contextGeometryRef.current;
|
||||
const rejectedGeometry = rejectedGeometryRef.current;
|
||||
const selectedGeometry = selectedGeometryRef.current;
|
||||
const contextMaterial = contextMaterialRef.current;
|
||||
const rejectedMaterial = rejectedMaterialRef.current;
|
||||
const selectedMaterial = selectedMaterialRef.current;
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
const grid = gridRef.current;
|
||||
if (
|
||||
!host
|
||||
|| !contextGeometry
|
||||
|| !rejectedGeometry
|
||||
|| !selectedGeometry
|
||||
|| !contextMaterial
|
||||
|| !rejectedMaterial
|
||||
|| !selectedMaterial
|
||||
|| !camera
|
||||
|| !controls
|
||||
|| !grid
|
||||
) return;
|
||||
|
||||
const contextPositions = toMapScenePositions(
|
||||
detail.projection.pointsMapXyzM,
|
||||
detail.pose.positionMapXyzM,
|
||||
);
|
||||
const selectedPositions = toMapScenePositions(
|
||||
detail.selected.pointsMapXyzM,
|
||||
detail.pose.positionMapXyzM,
|
||||
);
|
||||
const selectedIndices = new Set(detail.selected.sourceIndices);
|
||||
const rejectedPointsMap = detail.candidate.pointsMapXyzM.filter(
|
||||
(_point, index) => !selectedIndices.has(
|
||||
detail.candidate.sourceIndices[index] ?? -1,
|
||||
),
|
||||
);
|
||||
const rejectedPositions = toMapScenePositions(
|
||||
rejectedPointsMap,
|
||||
detail.pose.positionMapXyzM,
|
||||
);
|
||||
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(contextPositions, 3),
|
||||
);
|
||||
rejectedGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(rejectedPositions, 3),
|
||||
);
|
||||
selectedGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(selectedPositions, 3),
|
||||
);
|
||||
contextGeometry.computeBoundingSphere();
|
||||
rejectedGeometry.computeBoundingSphere();
|
||||
selectedGeometry.computeBoundingSphere();
|
||||
|
||||
rejectedMaterial.color.copy(
|
||||
tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]),
|
||||
);
|
||||
selectedMaterial.color.copy(
|
||||
tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
);
|
||||
|
||||
const evidencePositions = selectedPositions.length || rejectedPositions.length
|
||||
? new Float32Array([...rejectedPositions, ...selectedPositions])
|
||||
: contextPositions;
|
||||
const evidenceBounds = boundsFromPositions(evidencePositions);
|
||||
const contextBounds = boundsFromPositions(contextPositions);
|
||||
const evidenceView = evidenceViewFromPositions(evidencePositions);
|
||||
const target = evidenceView.target;
|
||||
const evidenceRadius = evidenceView.radius;
|
||||
const evidenceSize = evidenceBounds.isEmpty()
|
||||
? new THREE.Vector3(1, 1, 1)
|
||||
: evidenceBounds.getSize(new THREE.Vector3());
|
||||
const contextSize = contextBounds.isEmpty()
|
||||
? evidenceSize
|
||||
: contextBounds.getSize(new THREE.Vector3());
|
||||
const contextRadius = Math.max(contextSize.length() / 2, evidenceRadius);
|
||||
const distance = Math.max(evidenceRadius * 2.45, 2.3);
|
||||
|
||||
viewTargetRef.current.copy(target);
|
||||
viewDistanceRef.current = distance;
|
||||
controls.target.copy(target);
|
||||
camera.position.set(
|
||||
target.x + distance * 0.86,
|
||||
target.y + distance * 0.52,
|
||||
target.z + distance * 0.86,
|
||||
);
|
||||
camera.near = Math.max(distance / 2_000, 0.005);
|
||||
camera.far = Math.max(contextRadius * 12, distance * 40, 120);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.maxDistance = Math.max(contextRadius * 5, distance * 5, 40);
|
||||
controls.update();
|
||||
|
||||
const contextHeights: number[] = [];
|
||||
for (let offset = 1; offset < contextPositions.length; offset += 3) {
|
||||
contextHeights.push(contextPositions[offset]);
|
||||
}
|
||||
const groundHeight = percentile(contextHeights, 0.04);
|
||||
const gridSize = THREE.MathUtils.clamp(evidenceRadius * 7, 8, 48);
|
||||
grid.position.set(target.x, groundHeight, target.z);
|
||||
grid.scale.setScalar(gridSize / 10);
|
||||
}, [detail]);
|
||||
|
||||
const resetCamera = () => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) return;
|
||||
const target = viewTargetRef.current;
|
||||
const distance = viewDistanceRef.current;
|
||||
controls.target.copy(target);
|
||||
camera.position.set(
|
||||
target.x + distance * 0.86,
|
||||
target.y + distance * 0.52,
|
||||
target.z + distance * 0.86,
|
||||
);
|
||||
controls.update();
|
||||
};
|
||||
|
||||
const selectedIndices = new Set(detail.selected.sourceIndices);
|
||||
const rejectedCount = detail.candidate.sourceIndices.filter(
|
||||
(sourceIndex) => !selectedIndices.has(sourceIndex),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="e30-evidence-scene" data-testid="e30-evidence-3d">
|
||||
<div ref={hostRef} className="e30-evidence-scene__viewport">
|
||||
{renderError ? (
|
||||
<p className="e30-evidence-scene__error">{renderError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="e30-evidence-scene__toolbar">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
onClick={resetCamera}
|
||||
>
|
||||
Сбросить ракурс
|
||||
</Button>
|
||||
<div className="e30-evidence-scene__gestures" aria-label="Управление 3D-сценой">
|
||||
<span>ЛКМ · вращение</span>
|
||||
<span>Колесо · масштаб</span>
|
||||
<span>ПКМ · панорама</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="e30-evidence-scene__legend" aria-label="Легенда 3D-доказательства">
|
||||
<span data-point="context">
|
||||
Контекст · {detail.projection.pointsMapXyzM.length}
|
||||
</span>
|
||||
<span data-point="rejected">Отклонено · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { E30ReviewItemDetail } from "../core/laboratory/e30Review";
|
||||
|
||||
function tokenColor(host: HTMLElement, token: string, fallback: string): string {
|
||||
return getComputedStyle(host).getPropertyValue(token).trim() || fallback;
|
||||
}
|
||||
|
||||
function tokenRgb(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgb(${red} ${green} ${blue} / ${alpha})`;
|
||||
}
|
||||
|
||||
function depthColor(
|
||||
depth: number,
|
||||
minimumDepth: number,
|
||||
maximumDepth: number,
|
||||
): string {
|
||||
const span = Math.max(maximumDepth - minimumDepth, 0.001);
|
||||
const position = Math.min(1, Math.max(0, (depth - minimumDepth) / span));
|
||||
const hue = 220 - position * 205;
|
||||
return `hsl(${hue} 88% 62% / 0.78)`;
|
||||
}
|
||||
|
||||
export function E30EvidenceProjection({
|
||||
detail,
|
||||
projectionWidth,
|
||||
projectionHeight,
|
||||
pointLayerVisible,
|
||||
}: {
|
||||
detail: E30ReviewItemDetail;
|
||||
projectionWidth: number;
|
||||
projectionHeight: number;
|
||||
pointLayerVisible: boolean;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [frameState, setFrameState] = useState<
|
||||
"loading" | "ready" | "unavailable"
|
||||
>(detail.cameraFrame ? "loading" : "unavailable");
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
const host = canvas?.parentElement;
|
||||
if (!canvas || !context || !host) return;
|
||||
|
||||
let cancelled = false;
|
||||
const canvasWidth = 1_200;
|
||||
const canvasHeight = Math.round(
|
||||
canvasWidth * projectionHeight / projectionWidth,
|
||||
);
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
const scaleX = canvasWidth / projectionWidth;
|
||||
const scaleY = canvasHeight / projectionHeight;
|
||||
const depths = detail.projection.depthM.filter(Number.isFinite);
|
||||
const minimumDepth = depths.length ? Math.min(...depths) : 0;
|
||||
const maximumDepth = depths.length ? Math.max(...depths) : 1;
|
||||
|
||||
const draw = (image: HTMLImageElement | null) => {
|
||||
if (cancelled) return;
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
if (image) {
|
||||
context.drawImage(image, 0, 0, canvasWidth, canvasHeight);
|
||||
if (pointLayerVisible) {
|
||||
context.fillStyle = "rgb(0 0 0 / 0.08)";
|
||||
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
} else {
|
||||
context.fillStyle = tokenColor(host, "--nodedc-canvas", "#050506");
|
||||
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
if (pointLayerVisible) {
|
||||
detail.projection.pixelsXy.forEach(([sourceX, sourceY], index) => {
|
||||
if (
|
||||
sourceX < 0
|
||||
|| sourceX > projectionWidth
|
||||
|| sourceY < 0
|
||||
|| sourceY > projectionHeight
|
||||
) return;
|
||||
const selected = detail.projection.selectedMask[index] === 1;
|
||||
const candidate = detail.projection.candidateMask[index] === 1;
|
||||
const x = sourceX * scaleX;
|
||||
const y = sourceY * scaleY;
|
||||
context.fillStyle = selected
|
||||
? tokenRgb(host, "--nodedc-accent-rgb", [247, 248, 244])
|
||||
: candidate
|
||||
? tokenRgb(host, "--nodedc-warning-rgb", [255, 209, 102])
|
||||
: depthColor(
|
||||
detail.projection.depthM[index] ?? minimumDepth,
|
||||
minimumDepth,
|
||||
maximumDepth,
|
||||
);
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
x,
|
||||
y,
|
||||
selected ? 6.5 : candidate ? 4.25 : 2.1,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
});
|
||||
}
|
||||
|
||||
const bbox = detail.snapshot.bboxXyxy;
|
||||
if (bbox) {
|
||||
context.strokeStyle = tokenRgb(
|
||||
host,
|
||||
detail.stratum === "conflict"
|
||||
? "--nodedc-danger-rgb"
|
||||
: "--nodedc-accent-rgb",
|
||||
detail.stratum === "conflict"
|
||||
? [255, 98, 92]
|
||||
: [247, 248, 244],
|
||||
);
|
||||
context.lineWidth = 2.5;
|
||||
context.strokeRect(
|
||||
bbox[0] * scaleX,
|
||||
bbox[1] * scaleY,
|
||||
(bbox[2] - bbox[0]) * scaleX,
|
||||
(bbox[3] - bbox[1]) * scaleY,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!detail.cameraFrame) {
|
||||
setFrameState("unavailable");
|
||||
draw(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setFrameState("loading");
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.onload = () => {
|
||||
if (cancelled) return;
|
||||
if (
|
||||
image.naturalWidth !== detail.cameraFrame?.width
|
||||
|| image.naturalHeight !== detail.cameraFrame?.height
|
||||
) {
|
||||
setFrameState("unavailable");
|
||||
draw(null);
|
||||
return;
|
||||
}
|
||||
setFrameState("ready");
|
||||
draw(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setFrameState("unavailable");
|
||||
draw(null);
|
||||
};
|
||||
image.src = detail.cameraFrame.url;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = "";
|
||||
};
|
||||
}, [detail, pointLayerVisible, projectionHeight, projectionWidth]);
|
||||
|
||||
const rejectedCount = detail.projection.candidateMask.reduce(
|
||||
(count, candidate, index) => (
|
||||
count
|
||||
+ Number(
|
||||
candidate === 1
|
||||
&& detail.projection.selectedMask[index] !== 1,
|
||||
)
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="e30-projection-scene" data-testid="e30-evidence-camera">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-label={pointLayerVisible
|
||||
? "Камерный кадр с проекцией LiDAR и выбранным наблюдением E30"
|
||||
: "Исходный камерный кадр с рамкой наблюдения E30"}
|
||||
/>
|
||||
{frameState !== "ready" ? (
|
||||
<div className="e30-projection-scene__state" role="status">
|
||||
{frameState === "loading"
|
||||
? "Проверяем точный кадр камеры…"
|
||||
: "Точный кадр камеры не материализован"}
|
||||
</div>
|
||||
) : null}
|
||||
{pointLayerVisible ? (
|
||||
<div
|
||||
className="e30-evidence-scene__legend"
|
||||
aria-label="Легенда camera-LiDAR доказательства"
|
||||
>
|
||||
<span data-point="depth">
|
||||
LiDAR · глубина · {detail.projection.pointsMapXyzM.length}
|
||||
</span>
|
||||
<span data-point="rejected">Кандидаты · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmationModal,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextAreaField,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createOrResumeE30HumanReview,
|
||||
finalizeE30HumanReview,
|
||||
saveE30HumanReviewDecision,
|
||||
type E30ExceptionDisposition,
|
||||
type E30HumanReviewDraft,
|
||||
} from "../core/laboratory/e30HumanReview";
|
||||
import type { E30EngineeringGeneration } from "../core/laboratory/e30Engineering";
|
||||
import type {
|
||||
E30ReviewItemDetail,
|
||||
E30ReviewResult,
|
||||
} from "../core/laboratory/e30Review";
|
||||
|
||||
const DISPOSITION_OPTIONS: readonly {
|
||||
value: E30ExceptionDisposition;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "object-present", label: "Объект есть" },
|
||||
{ value: "background-or-noise", label: "Фон или шум" },
|
||||
{ value: "insufficient-evidence", label: "Недостаточно данных" },
|
||||
];
|
||||
|
||||
const FALLBACK_REVIEW_PROMPT = {
|
||||
question: "Белый кластер — самостоятельное физическое препятствие?",
|
||||
focus: (
|
||||
"Сопоставьте выбранные белые точки с исходным кадром и решите, "
|
||||
+ "принадлежат ли они занятой геометрии реального объекта."
|
||||
),
|
||||
effects: {
|
||||
"object-present": "Сохранить кластер как занятую геометрию.",
|
||||
"background-or-noise": "Исключить кластер как фон или шум.",
|
||||
"insufficient-evidence": "Оставить кейс неизвестным без настройки порогов.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function decisionFor(
|
||||
review: E30HumanReviewDraft | null,
|
||||
itemId: string | undefined,
|
||||
) {
|
||||
return itemId
|
||||
? review?.decisions.find((decision) => decision.itemId === itemId) ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function E30HumanReviewPanel({
|
||||
result,
|
||||
generation,
|
||||
item,
|
||||
review,
|
||||
onReviewChange,
|
||||
onDecisionSaved,
|
||||
}: {
|
||||
result: E30ReviewResult;
|
||||
generation: E30EngineeringGeneration;
|
||||
item: E30ReviewItemDetail | null;
|
||||
review: E30HumanReviewDraft | null;
|
||||
onReviewChange: (review: E30HumanReviewDraft) => void;
|
||||
onDecisionSaved: (review: E30HumanReviewDraft) => void;
|
||||
}) {
|
||||
const [disposition, setDisposition] =
|
||||
useState<E30ExceptionDisposition>("object-present");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [finalizeOpen, setFinalizeOpen] = useState(false);
|
||||
const currentDecision = decisionFor(review, item?.itemId);
|
||||
const reviewPrompt = generation.humanExceptions.find(
|
||||
(exception) => exception.itemId === item?.itemId,
|
||||
)?.reviewPrompt ?? FALLBACK_REVIEW_PROMPT;
|
||||
|
||||
useEffect(() => {
|
||||
setDisposition(currentDecision?.disposition ?? "object-present");
|
||||
setNotes(currentDecision?.notes ?? "");
|
||||
setError(null);
|
||||
}, [currentDecision, item?.itemId]);
|
||||
|
||||
const begin = async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
onReviewChange(await createOrResumeE30HumanReview(
|
||||
result.resultId,
|
||||
generation.generationId,
|
||||
));
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Проверка недоступна.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!review || !item || review.state !== "active" || pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await saveE30HumanReviewDecision(
|
||||
result.resultId,
|
||||
generation.generationId,
|
||||
review.draftId,
|
||||
item.itemId,
|
||||
{
|
||||
expectedRevision: review.revision,
|
||||
idempotencyKey: `ui-${crypto.randomUUID()}`,
|
||||
disposition,
|
||||
notes: notes.trim() || null,
|
||||
},
|
||||
);
|
||||
onReviewChange(next);
|
||||
onDecisionSaved(next);
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Решение не сохранено.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = async () => {
|
||||
if (!review || review.state !== "active") return;
|
||||
setError(null);
|
||||
try {
|
||||
const finalized = await finalizeE30HumanReview(
|
||||
result.resultId,
|
||||
generation.generationId,
|
||||
review.draftId,
|
||||
review.revision,
|
||||
);
|
||||
onReviewChange(finalized.draft);
|
||||
setFinalizeOpen(false);
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Проверка не зафиксирована.",
|
||||
);
|
||||
throw caught;
|
||||
}
|
||||
};
|
||||
|
||||
if (!review) {
|
||||
return (
|
||||
<section className="e30-human-review" aria-label="Проверка исключений">
|
||||
<header className="e30-human-review__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ТРЕБУЕТСЯ РЕШЕНИЕ</span>
|
||||
<h3>{reviewPrompt.question}</h3>
|
||||
<p>{reviewPrompt.focus}</p>
|
||||
</div>
|
||||
<StatusBadge tone="warning">
|
||||
0 / {generation.summary.humanExceptionCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="e30-human-review__actions">
|
||||
<span>
|
||||
Решение изменит только отдельную A3-коррекцию; A2 останется
|
||||
неизменным.
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={pending}
|
||||
onClick={() => void begin()}
|
||||
>
|
||||
{pending ? "Открываем…" : "Начать проверку"}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="e30-human-review__error" role="alert">{error}</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const finalized = review.state === "finalized";
|
||||
return (
|
||||
<section className="e30-human-review" aria-label="Проверка исключений">
|
||||
<header className="e30-human-review__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПРОВЕРКА ИСКЛЮЧЕНИЙ</span>
|
||||
<h3>
|
||||
{finalized ? "Проверка зафиксирована" : reviewPrompt.question}
|
||||
</h3>
|
||||
{!finalized ? <p>{reviewPrompt.focus}</p> : null}
|
||||
</div>
|
||||
<StatusBadge tone={finalized ? "success" : "accent"}>
|
||||
{review.reviewedItemCount} / {review.itemCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{finalized ? (
|
||||
<p>
|
||||
Все спорные кадры получили отдельное человеческое решение.
|
||||
Исходные доказательства сохранены без изменений.
|
||||
</p>
|
||||
) : item ? (
|
||||
<>
|
||||
<div className="e30-human-review__form">
|
||||
<div className="e30-human-review__field">
|
||||
<span>Решение</span>
|
||||
<Select
|
||||
label="Что видно на выбранном кадре"
|
||||
value={disposition}
|
||||
options={[...DISPOSITION_OPTIONS]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={pending}
|
||||
onChange={setDisposition}
|
||||
/>
|
||||
</div>
|
||||
<TextAreaField
|
||||
label="Комментарий"
|
||||
hint="необязательно"
|
||||
value={notes}
|
||||
rows={2}
|
||||
maxLength={2_000}
|
||||
disabled={pending}
|
||||
onChange={(event) => setNotes(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="e30-human-review__impact">
|
||||
<span>Что изменится после решения</span>
|
||||
<strong>{reviewPrompt.effects[disposition]}</strong>
|
||||
</div>
|
||||
<div className="e30-human-review__actions">
|
||||
<span>
|
||||
{currentDecision
|
||||
? "Этот кадр уже решён — его можно пересмотреть."
|
||||
: `${review.remainingItemCount} решений осталось.`}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={pending}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{pending
|
||||
? "Сохраняем…"
|
||||
: currentDecision
|
||||
? "Обновить кадр"
|
||||
: "Сохранить кадр"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={review.remainingItemCount !== 0 || pending}
|
||||
onClick={() => setFinalizeOpen(true)}
|
||||
>
|
||||
Зафиксировать проверку
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="e30-human-review__error" role="alert">{error}</p>
|
||||
) : null}
|
||||
|
||||
<ConfirmationModal
|
||||
open={finalizeOpen}
|
||||
title="Зафиксировать проверку?"
|
||||
description={(
|
||||
<p>
|
||||
Будет создан неизменяемый набор из {review.itemCount} решений.
|
||||
После фиксации их нельзя будет изменить.
|
||||
</p>
|
||||
)}
|
||||
confirmLabel="Зафиксировать"
|
||||
pendingLabel="Фиксируем…"
|
||||
onClose={() => setFinalizeOpen(false)}
|
||||
onConfirm={finalize}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import { E30EvidenceTelemetry } from "../components/laboratory/E30EvidenceTelemetry";
|
||||
import { E30EngineeringGenerationSummary } from "../components/laboratory/E30EngineeringGenerationSummary";
|
||||
import {
|
||||
fetchE30EngineeringCatalog,
|
||||
fetchE30EngineeringExceptions,
|
||||
type E30EngineeringGeneration,
|
||||
} from "../core/laboratory/e30Engineering";
|
||||
import type { E30HumanReviewDraft } from "../core/laboratory/e30HumanReview";
|
||||
import {
|
||||
E30_STRATA,
|
||||
fetchE30ReviewItemDetail,
|
||||
fetchE30ReviewItems,
|
||||
type E30ReviewItem,
|
||||
type E30ReviewItemDetail,
|
||||
type E30ReviewResult,
|
||||
type E30Stratum,
|
||||
} from "../core/laboratory/e30Review";
|
||||
import { formatNumber } from "../presentation";
|
||||
import { E30EvidencePointCloud } from "./E30EvidencePointCloud";
|
||||
import { E30EvidenceProjection } from "./E30EvidenceProjection";
|
||||
import { E30HumanReviewPanel } from "./E30HumanReviewPanel";
|
||||
|
||||
type E30EvidenceMode = "camera" | "3d";
|
||||
type E30Filter = E30Stratum | "review";
|
||||
|
||||
const FILTER_LABELS: Record<E30Filter, string> = {
|
||||
conflict: "Конфликт",
|
||||
agree: "Согласовано",
|
||||
"camera-only": "Только камера",
|
||||
unknown: "Неизвестно",
|
||||
"geometry-only": "Только геометрия",
|
||||
review: "Проверка",
|
||||
};
|
||||
const FILTERS: readonly E30Filter[] = [...E30_STRATA, "review"];
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
||||
}
|
||||
|
||||
function itemTitle(item: E30ReviewItem): string {
|
||||
if (item.snapshot.label) return item.snapshot.label;
|
||||
return item.locatorKind === "geometry-only-cluster"
|
||||
? "Геометрический кластер"
|
||||
: "Семантическое наблюдение";
|
||||
}
|
||||
|
||||
function evidenceRange(item: E30ReviewItem): string {
|
||||
const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM;
|
||||
return value === null
|
||||
? "Дальность недоступна"
|
||||
: `${value.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} м`;
|
||||
}
|
||||
|
||||
export function E30ReviewWorkspace({
|
||||
result,
|
||||
}: {
|
||||
result: E30ReviewResult;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<E30Filter>("conflict");
|
||||
const [items, setItems] = useState<readonly E30ReviewItem[]>([]);
|
||||
const [itemTotal, setItemTotal] = useState(result.stratumCounts.conflict);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<E30ReviewItemDetail | null>(null);
|
||||
const [itemsLoading, setItemsLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [evidenceMode, setEvidenceMode] = useState<E30EvidenceMode>("camera");
|
||||
const [pointLayerVisible, setPointLayerVisible] = useState(true);
|
||||
const [viewerExpanded, setViewerExpanded] = useState(false);
|
||||
const [engineeringGeneration, setEngineeringGeneration] =
|
||||
useState<E30EngineeringGeneration | null>(null);
|
||||
const [engineeringLoading, setEngineeringLoading] = useState(true);
|
||||
const [humanReview, setHumanReview] =
|
||||
useState<E30HumanReviewDraft | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter === "review" && !engineeringGeneration) {
|
||||
setItems([]);
|
||||
setItemTotal(0);
|
||||
setSelectedItemId(null);
|
||||
setItemsLoading(engineeringLoading);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setItemsLoading(true);
|
||||
setError(null);
|
||||
setDetail(null);
|
||||
const request = filter === "review"
|
||||
? fetchE30EngineeringExceptions(
|
||||
result.resultId,
|
||||
engineeringGeneration!.generationId,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
: fetchE30ReviewItems(result.resultId, filter, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
void request.then((next) => {
|
||||
setItems(next.items);
|
||||
setItemTotal(next.total);
|
||||
setSelectedItemId((current) => (
|
||||
next.items.some((item) => item.itemId === current)
|
||||
? current
|
||||
: next.items[0]?.itemId ?? null
|
||||
));
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setItems([]);
|
||||
setSelectedItemId(null);
|
||||
setError(caught instanceof Error ? caught.message : "Выборка E30 недоступна.");
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setItemsLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [
|
||||
engineeringGeneration,
|
||||
engineeringLoading,
|
||||
filter,
|
||||
result.resultId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEngineeringLoading(true);
|
||||
setEngineeringGeneration(null);
|
||||
void fetchE30EngineeringCatalog(result.resultId, {
|
||||
signal: controller.signal,
|
||||
}).then((catalog) => {
|
||||
setEngineeringGeneration(catalog.items[0] ?? null);
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setEngineeringGeneration(null);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setEngineeringLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedItemId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setDetailLoading(true);
|
||||
setError(null);
|
||||
void fetchE30ReviewItemDetail(result.resultId, selectedItemId, {
|
||||
signal: controller.signal,
|
||||
}).then(setDetail).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setDetail(null);
|
||||
setError(caught instanceof Error ? caught.message : "Доказательство E30 недоступно.");
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setDetailLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedItemId]);
|
||||
|
||||
const selectItem = (item: E30ReviewItem) => {
|
||||
setSelectedItemId(item.itemId);
|
||||
};
|
||||
|
||||
const advanceAfterDecision = (next: E30HumanReviewDraft) => {
|
||||
const resolved = new Set(next.decisions.map((decision) => decision.itemId));
|
||||
const currentIndex = items.findIndex((item) => item.itemId === selectedItemId);
|
||||
const ordered = [
|
||||
...items.slice(currentIndex + 1),
|
||||
...items.slice(0, currentIndex + 1),
|
||||
];
|
||||
const unresolved = ordered.find((item) => !resolved.has(item.itemId));
|
||||
if (unresolved) setSelectedItemId(unresolved.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassSurface
|
||||
className="e30-review-workspace"
|
||||
tone="soft"
|
||||
padding="md"
|
||||
materialRim={false}
|
||||
role="region"
|
||||
aria-label="Рабочее место ревью E30"
|
||||
>
|
||||
<header className="e30-review-workspace__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">CAMERA-BACKED REVIEW SUBSTRATE</span>
|
||||
<h2>A2 evidence · A3 engineering audit</h2>
|
||||
<p>
|
||||
Точный camera frame, LiDAR-проекция и синхронный 3D сохраняют A2
|
||||
неизменяемым. A3 выпускает отдельные решения с явным provenance.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={result.cameraEvidenceAvailable ? "accent" : "warning"}>
|
||||
{result.cameraEvidenceAvailable
|
||||
? "Camera evidence привязано"
|
||||
: "Camera evidence отсутствует"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{engineeringGeneration ? (
|
||||
<E30EngineeringGenerationSummary
|
||||
generation={engineeringGeneration}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SegmentedControl
|
||||
className="e30-review-workspace__strata"
|
||||
value={filter}
|
||||
label="Группа E30"
|
||||
items={FILTERS.map((value) => ({
|
||||
value,
|
||||
label: `${FILTER_LABELS[value]} · ${formatNumber(
|
||||
value === "review"
|
||||
? engineeringGeneration?.summary.humanExceptionCount ?? 0
|
||||
: result.stratumCounts[value],
|
||||
0,
|
||||
)}`,
|
||||
}))}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
|
||||
<div className="e30-review-workspace__body">
|
||||
<aside className="e30-review-workspace__items" aria-label="Кейсы выбранной страты">
|
||||
<header>
|
||||
<span>{FILTER_LABELS[filter]}</span>
|
||||
<small>показано {items.length} из {itemTotal}</small>
|
||||
</header>
|
||||
<div className="e30-review-workspace__item-list">
|
||||
{itemsLoading ? (
|
||||
<div className="e30-review-workspace__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Проверяем индекс</span>
|
||||
</div>
|
||||
) : items.length ? items.map((item) => (
|
||||
<Button
|
||||
key={item.itemId}
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
width="full"
|
||||
className="e30-review-workspace__item"
|
||||
data-active={item.itemId === selectedItemId ? "true" : undefined}
|
||||
aria-pressed={item.itemId === selectedItemId}
|
||||
onClick={() => selectItem(item)}
|
||||
>
|
||||
<span>{itemTitle(item)}</span>
|
||||
<strong>Кадр {formatNumber(item.sourceFrameIndex, 0)}</strong>
|
||||
<small>
|
||||
{formatSeconds(item.sessionSeconds)}
|
||||
{" · "}
|
||||
{evidenceRange(item)}
|
||||
{filter === "review"
|
||||
&& humanReview?.decisions.some(
|
||||
(decision) => decision.itemId === item.itemId,
|
||||
)
|
||||
? " · Решено"
|
||||
: ""}
|
||||
</small>
|
||||
</Button>
|
||||
)) : (
|
||||
<div className="e30-review-workspace__state">
|
||||
<Icon name="database" size={18} />
|
||||
<span>В этой страте кейсов нет</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="e30-review-workspace__detail">
|
||||
{detailLoading ? (
|
||||
<div className="e30-review-workspace__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Проверяем camera-LiDAR доказательство</span>
|
||||
</div>
|
||||
) : error || !detail ? (
|
||||
<div className="e30-review-workspace__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Выберите кейс."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<header className="e30-review-workspace__case">
|
||||
<div>
|
||||
<span className="section-eyebrow">{detail.reviewKey}</span>
|
||||
<h3>{itemTitle(detail)}</h3>
|
||||
<p>
|
||||
{detail.snapshot.geometryReason ?? "Независимый geometry-only слой"}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={detail.stratum === "conflict" ? "danger" : "neutral"}>
|
||||
{FILTER_LABELS[detail.stratum]}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="e30-review-evidence">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="Доказательство E30"
|
||||
mode={evidenceMode}
|
||||
modes={[
|
||||
{ value: "camera", label: "Камера" },
|
||||
{ value: "3d", label: "3D" },
|
||||
]}
|
||||
expanded={viewerExpanded}
|
||||
onModeChange={setEvidenceMode}
|
||||
onExpandedChange={setViewerExpanded}
|
||||
actions={evidenceMode === "camera" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant={pointLayerVisible ? "primary" : "secondary"}
|
||||
icon={<Icon name="sliders" size={16} />}
|
||||
aria-pressed={pointLayerVisible}
|
||||
onClick={() => setPointLayerVisible((visible) => !visible)}
|
||||
>
|
||||
LiDAR
|
||||
</Button>
|
||||
) : undefined}
|
||||
overlay={(
|
||||
<E30EvidenceTelemetry
|
||||
detail={detail}
|
||||
mode={evidenceMode}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{evidenceMode === "camera" ? (
|
||||
<E30EvidenceProjection
|
||||
detail={detail}
|
||||
projectionWidth={result.projection.width}
|
||||
projectionHeight={result.projection.height}
|
||||
pointLayerVisible={pointLayerVisible}
|
||||
/>
|
||||
) : (
|
||||
<E30EvidencePointCloud detail={detail} />
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{!detailLoading
|
||||
&& !error
|
||||
&& filter === "review"
|
||||
&& engineeringGeneration
|
||||
&& detail ? (
|
||||
<E30HumanReviewPanel
|
||||
result={result}
|
||||
generation={engineeringGeneration}
|
||||
item={detail}
|
||||
review={humanReview}
|
||||
onReviewChange={setHumanReview}
|
||||
onDecisionSaved={advanceAfterDecision}
|
||||
/>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type { ObservationSessionReplayCallbacks } from "../components/ObservationSessionSelect";
|
||||
import type {
|
||||
DeviceModelDefinition,
|
||||
DevicePluginConnectionProps,
|
||||
} from "../core/device-plugins/contracts";
|
||||
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
BackendStatus,
|
||||
MissionRuntimeState,
|
||||
} from "../core/runtime/contracts";
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
|
||||
export interface WorkspaceNavigation {
|
||||
openView: (viewId: string) => void;
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
requestedPlaybackSeconds?: number | null;
|
||||
recordedReplay: ObservationSessionReplayLaunch | null;
|
||||
recordedSessionAdmission: RecordedSessionAdmissionController | null;
|
||||
sceneSettings: SceneSettings;
|
||||
accumulationSeconds: number;
|
||||
onAccumulationChange: (value: number) => void;
|
||||
onAccumulationCommit: () => void;
|
||||
livePerceptionLayers: {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
};
|
||||
onLivePerceptionLayersChange: (next: {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
}) => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
deviceLabel: string | null;
|
||||
navigation: WorkspaceNavigation;
|
||||
spatialControls: {
|
||||
View: ComponentType<DevicePluginConnectionProps>;
|
||||
model: DeviceModelDefinition;
|
||||
} | null;
|
||||
sessionArchive: ObservationSessionReplayCallbacks & {
|
||||
disabled: boolean;
|
||||
blockedReason: string | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratorySelector,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
type LaboratoryMethod,
|
||||
type LaboratoryMethodComponent,
|
||||
type LaboratoryOption,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../../core/observation/useObservationSessions";
|
||||
import {
|
||||
fetchE29EvidenceCatalog,
|
||||
fetchE29EvidenceFrame,
|
||||
type E29EvidenceFrame,
|
||||
type E29EvidenceResult,
|
||||
} from "../../core/laboratory/e29Evidence";
|
||||
import {
|
||||
fetchE30ReviewCatalog,
|
||||
type E30ReviewResult,
|
||||
} from "../../core/laboratory/e30Review";
|
||||
import {
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceModel,
|
||||
} from "../../core/lidar/localSurface";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E30ReviewWorkspace } from "../E30ReviewWorkspace";
|
||||
import { LidarQualityWorkspace } from "../LidarQualityWorkspace";
|
||||
import type { WorkspaceRendererProps } from "../contracts";
|
||||
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
|
||||
type LaboratoryProfileId = "sensor-fusion" | "published-perception";
|
||||
type LaboratoryWorkId =
|
||||
| "e28-local-surface"
|
||||
| "e29-camera-geometry"
|
||||
| "e30-evidence-review"
|
||||
| `session:${string}`;
|
||||
|
||||
function digestFromContentId(value: string | null | undefined): string | null {
|
||||
const digest = value?.split("-").at(-1) ?? "";
|
||||
return /^[a-f0-9]{64}$/.test(digest) ? digest : null;
|
||||
}
|
||||
|
||||
function publishedLaboratoryMethod(
|
||||
session: ObservationSessionSummary,
|
||||
): LaboratoryMethod {
|
||||
const method = session.lab?.provenance.method;
|
||||
if (method && typeof method === "object" && !Array.isArray(method)) {
|
||||
const value = method as Record<string, unknown>;
|
||||
const rawComponents = Array.isArray(value.components) ? value.components : [];
|
||||
const components: LaboratoryMethodComponent[] = rawComponents.flatMap((component) => {
|
||||
if (!component || typeof component !== "object" || Array.isArray(component)) return [];
|
||||
const item = component as Record<string, unknown>;
|
||||
const kind = item.kind;
|
||||
if (
|
||||
kind !== "source"
|
||||
&& kind !== "tool"
|
||||
&& kind !== "model"
|
||||
&& kind !== "algorithm"
|
||||
&& kind !== "runtime"
|
||||
) return [];
|
||||
if (
|
||||
typeof item.name !== "string"
|
||||
|| typeof item.version !== "string"
|
||||
|| typeof item.role !== "string"
|
||||
) return [];
|
||||
return [{
|
||||
kind: kind as LaboratoryMethodComponent["kind"],
|
||||
name: item.name,
|
||||
version: item.version,
|
||||
role: item.role,
|
||||
identitySha256: typeof item.identity_sha256 === "string"
|
||||
? item.identity_sha256
|
||||
: null,
|
||||
}];
|
||||
});
|
||||
const executionClass = value.execution_class;
|
||||
const completeness = value.completeness;
|
||||
if (
|
||||
components.length
|
||||
&& typeof value.pipeline_id === "string"
|
||||
&& (
|
||||
executionClass === "deterministic"
|
||||
|| executionClass === "ai-inference"
|
||||
|| executionClass === "hybrid"
|
||||
)
|
||||
&& (completeness === "complete" || completeness === "legacy-partial")
|
||||
) {
|
||||
return {
|
||||
completeness,
|
||||
executionClass,
|
||||
pipelineId: value.pipeline_id,
|
||||
components,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resultKind = session.lab?.resultKind ?? "unknown";
|
||||
const algorithmNames: Record<string, string> = {
|
||||
"e10-integrated-perception": "Camera semantics + LiDAR metric fusion",
|
||||
"e21-realtime-envelope": "Bounded real-time perception replay",
|
||||
"e22-temporal-stability": "Temporal 2D/3D/semantic stabilization",
|
||||
"e23-inline-temporal-stability": "Inline warm-worker stabilization",
|
||||
"e24-world-motion": "World-frame motion tracking",
|
||||
"e25-persistent-support-motion": "Persistent occupied-support tracking",
|
||||
"e26-camera-ego-motion-fusion": "KB4 ego-motion + persistent LiDAR support",
|
||||
};
|
||||
return {
|
||||
completeness: "legacy-partial",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: resultKind,
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: session.lab?.sourceResultId ?? session.lab?.sourceSessionId ?? session.id,
|
||||
version: "immutable source evidence",
|
||||
role: "read-only input",
|
||||
identitySha256: digestFromContentId(session.lab?.sourceResultId),
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: algorithmNames[resultKind] ?? resultKind,
|
||||
version: resultKind,
|
||||
role: "laboratory derivative",
|
||||
identitySha256: session.lab?.configSha256 ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
||||
}
|
||||
|
||||
function E29LaboratoryResult({
|
||||
props,
|
||||
rigLabel,
|
||||
result,
|
||||
sourceSession,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
props: LaboratoryWorkspaceProps;
|
||||
rigLabel: string;
|
||||
result: E29EvidenceResult;
|
||||
sourceSession: ObservationSessionSummary;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [selectedFrameIndex, setSelectedFrameIndex] = useState(
|
||||
result.reviewFrames[0]?.frameIndex ?? 0,
|
||||
);
|
||||
const [frame, setFrame] = useState<E29EvidenceFrame | null>(null);
|
||||
const [frameLoading, setFrameLoading] = useState(false);
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const replayReady = props.recordedReplay?.sessionId === sourceSession.id;
|
||||
const semantic = result.metrics.semanticObservations;
|
||||
const geometryStatus = semantic.geometryStatus;
|
||||
const conflicts = frame?.semanticObservations.filter(
|
||||
(observation) => observation.geometryStatus === "conflict",
|
||||
) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setFrameLoading(true);
|
||||
setFrameError(null);
|
||||
void fetchE29EvidenceFrame(result.resultId, selectedFrameIndex, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
setFrame(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFrame(null);
|
||||
setFrameError(
|
||||
caught instanceof Error ? caught.message : "Кадр E29 недоступен.",
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setFrameLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedFrameIndex]);
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E29 · camera-first semantics + независимая геометрия"
|
||||
description="Камера сохраняет класс и идентичность объекта, а LiDAR независимо подтверждает дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
|
||||
status="Проверенные артефакты"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
|
||||
{
|
||||
label: "Источник",
|
||||
value: `${sourceSession.label} · ${formatNumber(result.identity.frameCount, 0)} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Наблюдений",
|
||||
value: formatNumber(semantic.total, 0),
|
||||
},
|
||||
{
|
||||
label: "Контур",
|
||||
value: "Read-only · hash verified",
|
||||
},
|
||||
]}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: result.identity.profileId,
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: result.linkedEvidence.sourceResultId,
|
||||
version: "camera-first semantic observations",
|
||||
role: "semantic identity and class",
|
||||
identitySha256: digestFromContentId(result.linkedEvidence.sourceResultId),
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "Camera/LiDAR local-surface validation",
|
||||
version: result.identity.profileId,
|
||||
role: "range, occupied support and conflict classification",
|
||||
identitySha256: result.identity.producerSha256,
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
name: result.linkedEvidence.localSurfaceModelId,
|
||||
version: "L2.6 local surface",
|
||||
role: "independent metric geometry",
|
||||
identitySha256: digestFromContentId(
|
||||
result.linkedEvidence.localSurfaceModelId,
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ИСХОДНЫЕ ДАННЫЕ"
|
||||
title="LiDAR, траектория и камера RAVNOVES00"
|
||||
kind="recorded-replay"
|
||||
resizable
|
||||
>
|
||||
{replayReady ? (
|
||||
<props.SpatialView {...props} />
|
||||
) : (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
|
||||
<strong>{loading ? "Проверяем и открываем запись" : "Исходная запись не открыта"}</strong>
|
||||
<p>
|
||||
{error ?? (loading
|
||||
? "Viewer появится после серверной проверки неизменяемого RRD."
|
||||
: "Выберите LAB E29 повторно, чтобы открыть связанный источник.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
||||
<h2>Camera-first контракт рассчитан, production gate не пройден</h2>
|
||||
</div>
|
||||
<StatusBadge tone={result.decision.productionPromotion ? "success" : "warning"}>
|
||||
{result.decision.productionPromotion ? "Допущено" : "Только диагностика"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-result-metrics">
|
||||
<div>
|
||||
<span>Поддержка геометрией</span>
|
||||
<strong>{formatNumber(geometryStatus.agree, 0)}</strong>
|
||||
<small>{(semantic.agreementFractionOfCurrent * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% current</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Только камера</span>
|
||||
<strong>{formatNumber(geometryStatus.cameraOnly, 0)}</strong>
|
||||
<small>Семантика без LiDAR-подтверждения</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Postprocess p95</span>
|
||||
<strong>{result.metrics.runtime.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс</strong>
|
||||
<small>{formatSeconds(result.metrics.runtime.buildElapsedMs / 1000)} полный build</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Только геометрия</span>
|
||||
<strong>{formatNumber(result.metrics.geometryOnlyOccupied.clusterCount, 0)}</strong>
|
||||
<small>{formatNumber(result.metrics.geometryOnlyOccupied.pointCount, 0)} точек</small>
|
||||
</div>
|
||||
</div>
|
||||
<p>{result.decision.nextGate}</p>
|
||||
</section>
|
||||
)}
|
||||
details={(
|
||||
<section className="laboratory-frame-review">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">КАДРЫ С КОНФЛИКТОМ</span>
|
||||
<h2>Покадровое доказательство из camera-geometry-frames.jsonl</h2>
|
||||
</div>
|
||||
<StatusBadge tone="warning">
|
||||
{formatNumber(geometryStatus.conflict, 0)} конфликтов
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-frame-review__picker" role="list">
|
||||
{result.reviewFrames.slice(0, 16).map((review) => (
|
||||
<button
|
||||
key={review.frameIndex}
|
||||
type="button"
|
||||
className={review.frameIndex === selectedFrameIndex ? "is-active" : undefined}
|
||||
onClick={() => setSelectedFrameIndex(review.frameIndex)}
|
||||
>
|
||||
<span>Кадр {formatNumber(review.sourceFrameIndex, 0)}</span>
|
||||
<small>{formatSeconds(review.sessionSeconds)} · {review.conflictCount} конфликт</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{frameLoading ? (
|
||||
<div className="laboratory-frame-review__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Читаем подтверждённый кадр</span>
|
||||
</div>
|
||||
) : frameError || !frame ? (
|
||||
<div className="laboratory-frame-review__state" role="status">
|
||||
<Icon name="database" size={18} />
|
||||
<span>{frameError ?? "Кадр недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="laboratory-frame-review__detail">
|
||||
<div>
|
||||
<span>Кадр источника</span>
|
||||
<strong>{formatNumber(frame.sourceFrameIndex, 0)}</strong>
|
||||
<small>{formatSeconds(frame.sessionSeconds)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Семантические наблюдения</span>
|
||||
<strong>{formatNumber(frame.semanticObservations.length, 0)}</strong>
|
||||
<small>{formatNumber(conflicts.length, 0)} требуют разбора</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Geometry-only компоненты</span>
|
||||
<strong>{formatNumber(frame.geometryOnlyOccupied.length, 0)}</strong>
|
||||
<small>Класс не назначается</small>
|
||||
</div>
|
||||
<div className="laboratory-frame-review__conflicts">
|
||||
<span>Фактические конфликты</span>
|
||||
{conflicts.length ? conflicts.map((observation) => (
|
||||
<p key={observation.trackId}>
|
||||
<strong>{observation.label} · track {observation.trackId}</strong>
|
||||
<small>
|
||||
{observation.geometryReason} · classified {observation.support.classifiedPoints}
|
||||
{" · "}surface {observation.support.surfacePoints}
|
||||
</small>
|
||||
</p>
|
||||
)) : <small>В этом кадре конфликт не найден.</small>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function E30LaboratoryResult({
|
||||
rigLabel,
|
||||
result,
|
||||
sourceSession,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E30ReviewResult;
|
||||
sourceSession: ObservationSessionSummary;
|
||||
}) {
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E30 · рабочее место evidence review"
|
||||
description="A2 связывает каждый кейс E29 с точным camera frame, LiDAR-проекцией и frame-local индексами. Камера отвечает на вопрос «что видит детектор», синхронный 3D проверяет принадлежность и форму точек."
|
||||
status="Camera evidence проверено"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · A2` },
|
||||
{ label: "Источник", value: sourceSession.label },
|
||||
{ label: "Кейсов", value: formatNumber(result.itemCount, 0) },
|
||||
{ label: "Контур", value: "Read-only · без LAB publish" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="CAMERA + LIDAR ДОКАЗАТЕЛЬСТВО"
|
||||
title="Точный кадр, проекция и синхронный 3D"
|
||||
kind="diagnostic-model"
|
||||
>
|
||||
<E30ReviewWorkspace result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLaboratoryMetric(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "Да" : "Нет";
|
||||
if (typeof value === "number") {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function laboratoryMetricLabel(key: string): string {
|
||||
return key
|
||||
.replace(/_p95_ms$/, " · p95 мс")
|
||||
.replaceAll("_", " ")
|
||||
.replace(/^./, (value) => value.toLocaleUpperCase("ru-RU"));
|
||||
}
|
||||
|
||||
function laboratorySessionTitle(session: ObservationSessionSummary): string {
|
||||
const labPrefix = session.lab ? `${session.lab.labId} · ` : "";
|
||||
return labPrefix && session.label.startsWith(labPrefix)
|
||||
? session.label.slice(labPrefix.length)
|
||||
: session.label;
|
||||
}
|
||||
|
||||
function PublishedLaboratoryResult({
|
||||
props,
|
||||
session,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
props: LaboratoryWorkspaceProps;
|
||||
session: ObservationSessionSummary;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const lab = session.lab;
|
||||
const metrics = Object.entries(lab?.provenance ?? {})
|
||||
.filter(([key, value]) => (
|
||||
(typeof value === "number" || typeof value === "boolean")
|
||||
&& !key.includes("sha256")
|
||||
&& !key.includes("authority")
|
||||
))
|
||||
.slice(0, 8);
|
||||
const replayReady = props.recordedReplay?.sessionId === session.id;
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title={`${lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`}
|
||||
description="Опубликованная работа открывается по неизменяемой записи. Viewer показывает исходные синхронные каналы, а продуктовая выжимка — только зафиксированный метод и LAB-provenance."
|
||||
status="Зафиксированный результат"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Тип результата", value: lab?.resultKind ?? "—" },
|
||||
{ label: "Источник", value: lab?.sourceSessionId ?? session.id },
|
||||
{ label: "Конфигурация", value: lab?.configSha256?.slice(0, 16) ?? "Не зафиксирована" },
|
||||
{ label: "Контур", value: "Диагностика · без команд" },
|
||||
]}
|
||||
method={publishedLaboratoryMethod(session)}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
||||
title="Исходная запись выбранной лабораторной работы"
|
||||
kind="recorded-replay"
|
||||
resizable
|
||||
>
|
||||
{replayReady ? (
|
||||
<props.SpatialView {...props} />
|
||||
) : (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
|
||||
<strong>{loading ? "Подготавливаем лабораторную запись" : "Запись не открыта"}</strong>
|
||||
<p>
|
||||
{error ?? (loading
|
||||
? "Связанное визуальное доказательство откроется после проверки записи."
|
||||
: "Выберите работу ещё раз, чтобы открыть связанное визуальное доказательство.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЗАФИКСИРОВАННЫЕ МЕТРИКИ</span>
|
||||
<h2>Результат из LAB-provenance</h2>
|
||||
</div>
|
||||
<StatusBadge tone={
|
||||
lab?.provenance.benchmark_passed === true ? "success" : "warning"
|
||||
}>
|
||||
{lab?.provenance.benchmark_passed === true ? "Benchmark passed" : "Требует разбора"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-result-metrics">
|
||||
{metrics.length ? metrics.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span>{laboratoryMetricLabel(key)}</span>
|
||||
<strong>{formatLaboratoryMetric(value)}</strong>
|
||||
<small>Immutable provenance</small>
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<span>Метрики</span>
|
||||
<strong>Не опубликованы</strong>
|
||||
<small>Доступна исходная запись</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
|
||||
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
||||
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit: 100,
|
||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||
onReplayBegin: props.sessionArchive.onReplayBegin,
|
||||
onReplayAccepted: props.sessionArchive.onReplayAccepted,
|
||||
onReplaySettled: props.sessionArchive.onReplaySettled,
|
||||
});
|
||||
const publishedWorks = useMemo(
|
||||
() => sessions.items.filter((session) => (
|
||||
session.lab !== null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud")
|
||||
)),
|
||||
[sessions.items],
|
||||
);
|
||||
const sourceSessions = useMemo(
|
||||
() => new Map(sessions.items.map((session) => [session.id, session])),
|
||||
[sessions.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEvidenceLoading(true);
|
||||
setEvidenceError(null);
|
||||
void Promise.allSettled([
|
||||
fetchLidarLocalSurfaces({ signal: controller.signal }),
|
||||
fetchE29EvidenceCatalog({ signal: controller.signal }),
|
||||
fetchE30ReviewCatalog({ signal: controller.signal }),
|
||||
]).then(([e28, e29, e30]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const nextE28 = e28.status === "fulfilled" ? e28.value.items[0] ?? null : null;
|
||||
const nextE29 = e29.status === "fulfilled" ? e29.value.items[0] ?? null : null;
|
||||
const nextE30 = e30.status === "fulfilled" ? e30.value.items[0] ?? null : null;
|
||||
setE28Model(nextE28);
|
||||
setE29Result(nextE29);
|
||||
setE30Result(nextE30);
|
||||
const failures = [
|
||||
e28.status === "rejected" ? "E28" : null,
|
||||
e29.status === "rejected" ? "E29" : null,
|
||||
e30.status === "rejected" ? "E30" : null,
|
||||
].filter(Boolean);
|
||||
setEvidenceError(
|
||||
failures.length
|
||||
? `${failures.join(" и ")} не прошли серверную проверку и скрыты.`
|
||||
: null,
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setEvidenceLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const rigLabel = useMemo(() => {
|
||||
if (props.deviceLabel) return props.deviceLabel;
|
||||
const coordinateFrame = publishedWorks
|
||||
.map((session) => session.lab?.provenance.coordinate_frame)
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
const sensorToken = typeof coordinateFrame === "string"
|
||||
? coordinateFrame.split("-")[0]?.trim()
|
||||
: "";
|
||||
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
|
||||
}, [props.deviceLabel, publishedWorks]);
|
||||
const sensorWorks = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryWorkId>[] = [];
|
||||
if (e28Model) {
|
||||
items.push({
|
||||
id: "e28-local-surface",
|
||||
label: "LAB E28 · локальная поверхность L2.6",
|
||||
});
|
||||
}
|
||||
if (
|
||||
e29Result
|
||||
&& sourceSessions.has(e29Result.linkedEvidence.sourceSessionId)
|
||||
) {
|
||||
items.push({
|
||||
id: "e29-camera-geometry",
|
||||
label: "LAB E29 · camera-first + geometry",
|
||||
});
|
||||
}
|
||||
if (e30Result && sourceSessions.has(e30Result.sourceSessionId)) {
|
||||
items.push({
|
||||
id: "e30-evidence-review",
|
||||
label: "LAB E30 · evidence review A2",
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [e28Model, e29Result, e30Result, sourceSessions]);
|
||||
const profiles = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
|
||||
if (sensorWorks.length) {
|
||||
items.push({
|
||||
id: "sensor-fusion",
|
||||
label: `${rigLabel} · камера + LiDAR · control plane`,
|
||||
});
|
||||
}
|
||||
if (publishedWorks.length) {
|
||||
items.push({
|
||||
id: "published-perception",
|
||||
label: `${rigLabel} · опубликованный perception pipeline`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [publishedWorks.length, rigLabel, sensorWorks.length]);
|
||||
const workOptions: readonly LaboratoryOption<LaboratoryWorkId>[] =
|
||||
profileId === "sensor-fusion"
|
||||
? sensorWorks
|
||||
: publishedWorks.map((session) => ({
|
||||
id: `session:${session.id}` as const,
|
||||
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
|
||||
}));
|
||||
const selectedSessionId = workId.startsWith("session:")
|
||||
? workId.slice("session:".length)
|
||||
: null;
|
||||
const selectedSession = selectedSessionId
|
||||
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
|
||||
: null;
|
||||
const e29SourceSession = e29Result
|
||||
? sourceSessions.get(e29Result.linkedEvidence.sourceSessionId) ?? null
|
||||
: null;
|
||||
const e30SourceSession = e30Result
|
||||
? sourceSessions.get(e30Result.sourceSessionId) ?? null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
evidenceLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) return;
|
||||
if (!profiles.some((profile) => profile.id === profileId)) {
|
||||
const firstProfile = profiles[0];
|
||||
if (!firstProfile) return;
|
||||
setProfileId(firstProfile.id);
|
||||
if (firstProfile.id === "sensor-fusion") {
|
||||
const firstWork = sensorWorks[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
} else {
|
||||
const first = publishedWorks[0];
|
||||
if (first) setWorkId(`session:${first.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!workOptions.some((work) => work.id === workId)) {
|
||||
const firstWork = workOptions[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
}
|
||||
}, [
|
||||
evidenceLoading,
|
||||
profileId,
|
||||
profiles,
|
||||
publishedWorks,
|
||||
sensorWorks,
|
||||
sessions.state,
|
||||
workId,
|
||||
workOptions,
|
||||
]);
|
||||
|
||||
const selectProfile = (next: LaboratoryProfileId) => {
|
||||
setProfileId(next);
|
||||
if (next === "sensor-fusion") {
|
||||
const first = sensorWorks[0];
|
||||
if (first) setWorkId(first.id);
|
||||
return;
|
||||
}
|
||||
const first = publishedWorks[0];
|
||||
if (!first) return;
|
||||
const nextWork = `session:${first.id}` as const;
|
||||
setWorkId(nextWork);
|
||||
void sessions.replay(first.id);
|
||||
};
|
||||
|
||||
const selectWork = (next: LaboratoryWorkId) => {
|
||||
setWorkId(next);
|
||||
if (next.startsWith("session:")) {
|
||||
void sessions.replay(next.slice("session:".length));
|
||||
return;
|
||||
}
|
||||
if (next === "e29-camera-geometry" && e29SourceSession) {
|
||||
void sessions.replay(e29SourceSession.id);
|
||||
return;
|
||||
}
|
||||
if (next === "e30-evidence-review" && e30SourceSession) {
|
||||
void sessions.replay(e30SourceSession.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
evidenceLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Ревизия лабораторных данных</strong>
|
||||
<p>Проверяем артефакты, связанные исходные записи и доступность replay.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profiles.length) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Подтверждённых лабораторных работ нет</strong>
|
||||
<p>
|
||||
{evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewerFocused = Boolean(
|
||||
props.observationLayout.focusedSourceId
|
||||
|| props.observationLayout.maximizedFloatingSourceId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lab-archive-workspace"
|
||||
data-viewer-focused={viewerFocused ? "true" : undefined}
|
||||
>
|
||||
<LaboratorySelector
|
||||
eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА"
|
||||
title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel}
|
||||
description="Профиль фиксирует объект исследования, сенсорные модули и вычислительный контур. Исходные данные остаются read-only; профиль объединяет серию сопоставимых лабораторных работ."
|
||||
label="Профиль"
|
||||
value={profileId}
|
||||
options={profiles}
|
||||
onChange={selectProfile}
|
||||
/>
|
||||
|
||||
<LaboratorySelector
|
||||
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
|
||||
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
|
||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства."
|
||||
label="Работа"
|
||||
value={workId}
|
||||
options={workOptions}
|
||||
disabled={workOptions.length === 0}
|
||||
onChange={selectWork}
|
||||
/>
|
||||
|
||||
<div className="laboratory-work-output">
|
||||
{workId === "e28-local-surface" ? (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E28 · локальная модель поверхности L2.6"
|
||||
description="Запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает поверхность, препятствия, временные скачки и ошибку предсказания без изменения источника и без командного канала."
|
||||
status="Проверенные артефакты"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
|
||||
{
|
||||
label: "Покрытие",
|
||||
value: `${formatNumber(e28Model?.metrics.frames.valid ?? 0, 0)} / ${formatNumber(e28Model?.metrics.frames.total ?? 0, 0)} кадров`,
|
||||
},
|
||||
{ label: "Режим", value: "Recorded-source-paced shadow" },
|
||||
{ label: "Контур", value: "Read-only · hash verified" },
|
||||
]}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: e28Model?.method.executionClass ?? "deterministic",
|
||||
pipelineId: e28Model?.method.pipelineId ?? "local-surface/unavailable",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: e28Model?.sourcePackId ?? "Источник не загружен",
|
||||
version: "immutable vendor MAP + pose",
|
||||
role: "read-only LiDAR evidence",
|
||||
identitySha256: digestFromContentId(e28Model?.sourcePackId),
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: e28Model?.method.algorithm ?? "Rolling local surface",
|
||||
version: e28Model?.method.pipelineId ?? "—",
|
||||
role: "robust local plane, occupancy and temporal residuals",
|
||||
identitySha256: e28Model?.method.producerSha256 ?? null,
|
||||
},
|
||||
{
|
||||
kind: "runtime",
|
||||
name: "Mission Core worker D",
|
||||
version: "recorded-source-paced shadow",
|
||||
role: "bounded passive replay",
|
||||
identitySha256: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
||||
title="Диагностическая поверхность и кадры LAB E28"
|
||||
kind="diagnostic-model"
|
||||
>
|
||||
<LidarQualityWorkspace
|
||||
embedded
|
||||
deviceLabel={props.deviceLabel}
|
||||
onOpenObservation={() => props.navigation.openView("spatial-scene")}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
/>
|
||||
) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? (
|
||||
<E29LaboratoryResult
|
||||
props={props}
|
||||
rigLabel={rigLabel}
|
||||
result={e29Result}
|
||||
sourceSession={e29SourceSession}
|
||||
loading={sessions.replayingSessionId === e29SourceSession.id}
|
||||
error={
|
||||
sessions.failedSessionId === e29SourceSession.id
|
||||
? sessions.error
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : workId === "e30-evidence-review" && e30Result && e30SourceSession ? (
|
||||
<E30LaboratoryResult
|
||||
rigLabel={rigLabel}
|
||||
result={e30Result}
|
||||
sourceSession={e30SourceSession}
|
||||
/>
|
||||
) : selectedSession ? (
|
||||
<PublishedLaboratoryResult
|
||||
props={props}
|
||||
session={selectedSession}
|
||||
loading={sessions.replayingSessionId === selectedSession.id}
|
||||
error={sessions.failedSessionId === selectedSession.id ? sessions.error : null}
|
||||
/>
|
||||
) : (
|
||||
<div className="laboratory-result-pending">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Работа не прошла ревизию</strong>
|
||||
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
const sourceRoot = new URL("../src/", import.meta.url);
|
||||
|
||||
async function sourceFiles(relativeDirectory) {
|
||||
const directory = new URL(`${relativeDirectory}/`, sourceRoot);
|
||||
const entries = await readdir(directory, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
});
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && [".ts", ".tsx"].includes(extname(entry.name)))
|
||||
.map((entry) => join(entry.parentPath, entry.name));
|
||||
}
|
||||
|
||||
async function read(relativePath) {
|
||||
return readFile(new URL(relativePath, sourceRoot), "utf8");
|
||||
}
|
||||
|
||||
test("dependency direction keeps core and reusable components below workspaces", async () => {
|
||||
const coreFiles = await sourceFiles("core");
|
||||
const componentFiles = await sourceFiles("components");
|
||||
|
||||
for (const file of coreFiles) {
|
||||
const source = await readFile(file, "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/from\s+["'][^"']*(?:workspaces|\/App)["']/,
|
||||
`${file} imports an application composition layer`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/@nodedc\/ui-(?:react|dom)/,
|
||||
`${file} imports a visual adapter from the domain layer`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of componentFiles) {
|
||||
const source = await readFile(file, "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/from\s+["'][^"']*(?:workspaces|\/App)["']/,
|
||||
`${file} imports a workspace composition layer`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("shared visual primitives come only from the Design Guideline packages", async () => {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
for (const packageName of [
|
||||
"@nodedc/tokens",
|
||||
"@nodedc/ui-core",
|
||||
"@nodedc/ui-react",
|
||||
]) {
|
||||
assert.equal(typeof packageJson.dependencies[packageName], "string");
|
||||
}
|
||||
|
||||
const files = await sourceFiles(".");
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, "utf8");
|
||||
assert.doesNotMatch(source, /lucide-react/);
|
||||
assert.doesNotMatch(source, /NODEDC_DESIGN_GUIDELINE\/(?:apps|src)\//);
|
||||
}
|
||||
});
|
||||
|
||||
test("laboratory UI is a bounded feature slice, not a central workspace branch", async () => {
|
||||
const workspaceHub = await read("workspaces/Workspaces.tsx");
|
||||
const laboratory = await read(
|
||||
"workspaces/laboratory/LaboratoryArchiveWorkspace.tsx",
|
||||
);
|
||||
const workspaceCss = await read("styles/workspaces.css");
|
||||
const laboratoryCss = await read("styles/laboratory.css");
|
||||
const e30Review = await read("workspaces/E30ReviewWorkspace.tsx");
|
||||
const e30HumanReview = await read(
|
||||
"workspaces/E30HumanReviewPanel.tsx",
|
||||
);
|
||||
const e30HumanReviewCss = await read("styles/e30-human-review.css");
|
||||
|
||||
assert.doesNotMatch(
|
||||
workspaceHub,
|
||||
/fetchE(?:29|30)|LaboratoryWorkTemplate|function\s+E(?:29|30)LaboratoryResult/,
|
||||
);
|
||||
assert.match(
|
||||
workspaceHub,
|
||||
/<LaboratoryArchiveWorkspace[\s\S]*SpatialView=\{SpatialWorkspace\}/,
|
||||
);
|
||||
assert.match(laboratory, /export function LaboratoryArchiveWorkspace/);
|
||||
assert.match(laboratory, /type LaboratoryWorkspaceProps = WorkspaceRendererProps/);
|
||||
assert.doesNotMatch(workspaceCss, /\.(?:lab-|laboratory-|e30-)/);
|
||||
assert.match(laboratoryCss, /\.laboratory-work-template/);
|
||||
assert.match(laboratoryCss, /\.e30-review-workspace/);
|
||||
assert.doesNotMatch(laboratory, /E30HumanReviewPanel/);
|
||||
assert.match(e30Review, /<E30HumanReviewPanel/);
|
||||
assert.doesNotMatch(e30Review, /E30EngineeringAuditPanel/);
|
||||
assert.match(e30HumanReview, /from "@nodedc\/ui-react"/);
|
||||
assert.doesNotMatch(laboratoryCss, /\.e30-human-review/);
|
||||
assert.match(e30HumanReviewCss, /\.e30-human-review/);
|
||||
});
|
||||
|
||||
test("central composition files cannot silently become monoliths again", async () => {
|
||||
const ratchets = [
|
||||
["App.tsx", 1_250],
|
||||
["workspaces/Workspaces.tsx", 1_200],
|
||||
["workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", 1_000],
|
||||
["styles/workspaces.css", 4_350],
|
||||
["styles/laboratory.css", 900],
|
||||
];
|
||||
|
||||
for (const [relativePath, maximumLines] of ratchets) {
|
||||
const lineCount = (await read(relativePath)).split("\n").length;
|
||||
assert.ok(
|
||||
lineCount <= maximumLines,
|
||||
`${relativePath} has ${lineCount} lines; split the feature instead of raising ${maximumLines}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let parseE30EngineeringCatalog;
|
||||
let parseE30EngineeringDecision;
|
||||
let fetchE30EngineeringDecision;
|
||||
let E30EngineeringContractError;
|
||||
|
||||
const resultId = `e30-materialization-${"a".repeat(64)}`;
|
||||
const generationId = `e30-engineering-generation-${"b".repeat(64)}`;
|
||||
const itemId = `e30-review-item-${"c".repeat(64)}`;
|
||||
|
||||
function generation(overrides = {}) {
|
||||
return {
|
||||
generation_id: generationId,
|
||||
created_at_utc: "2026-07-26T22:01:18.464Z",
|
||||
materialization_id: resultId,
|
||||
producer: {
|
||||
kind: "ai-assisted-engineering-review",
|
||||
producer_id: "codex:a3-engineering-review",
|
||||
method_id: "camera-lidar-42-sheet-audit/v1",
|
||||
review_sheet_identity_sha256: "d".repeat(64),
|
||||
claims_human_ground_truth: false,
|
||||
},
|
||||
summary: {
|
||||
item_count: 486,
|
||||
reviewed_item_count: 486,
|
||||
verdict_distribution: {
|
||||
confirmed: 401,
|
||||
corrected: 80,
|
||||
"insufficient-evidence": 5,
|
||||
},
|
||||
detector_distribution: { valid: 259, "false-positive": 78 },
|
||||
projection_distribution: { aligned: 371, "not-assessable": 115 },
|
||||
point_ownership_distribution: { object: 108 },
|
||||
human_exception_count: 5,
|
||||
mean_confidence: 0.8705,
|
||||
},
|
||||
cause_distribution: {
|
||||
schema_version: "missioncore.e30-engineering-causes/v1",
|
||||
item_count_with_cause: 312,
|
||||
reasons: [
|
||||
{ reason_code: "detector_error", count: 109 },
|
||||
{ reason_code: "time_mismatch", count: 91 },
|
||||
],
|
||||
},
|
||||
human_exceptions: [{
|
||||
item_id: itemId,
|
||||
review_key: "semantic:162:7",
|
||||
source_stratum: "camera-only",
|
||||
confidence: 0.48,
|
||||
review_prompt: {
|
||||
question: "Белый кластер — самостоятельное препятствие?",
|
||||
focus: "Проверьте выбранные белые точки.",
|
||||
effects: {
|
||||
"object-present": "Сохранить занятую геометрию.",
|
||||
"background-or-noise": "Исключить кластер.",
|
||||
"insufficient-evidence": "Оставить неизвестным.",
|
||||
},
|
||||
},
|
||||
}],
|
||||
ai_review_complete: true,
|
||||
human_exception_complete: false,
|
||||
human_review_complete: false,
|
||||
lab_published: false,
|
||||
access: "read-only",
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-engineering-generations/v1",
|
||||
configured: true,
|
||||
items: [generation()],
|
||||
candidate_total: 1,
|
||||
invalid_total: 0,
|
||||
access: "read-only",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decision(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-engineering-decision/v1",
|
||||
generation_id: generationId,
|
||||
decision: {
|
||||
sequence: 362,
|
||||
item_id: itemId,
|
||||
review_key: "semantic:343:2",
|
||||
source_stratum: "conflict",
|
||||
verdict: "corrected",
|
||||
effective_stratum: null,
|
||||
detector_assessment: "false-positive",
|
||||
projection_assessment: "aligned",
|
||||
point_ownership: "surface-or-background",
|
||||
cause_code: "detector_error",
|
||||
confidence: 0.95,
|
||||
human_exception_required: false,
|
||||
exception_reason: null,
|
||||
evidence_note: "Кадр 343. Рамка находится на ограждении.",
|
||||
review_sheet: {
|
||||
path: "conflict-01.jpg",
|
||||
sha256: "e".repeat(64),
|
||||
ordinal: 1,
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
parseE30EngineeringCatalog,
|
||||
parseE30EngineeringDecision,
|
||||
fetchE30EngineeringDecision,
|
||||
E30EngineeringContractError,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/e30Engineering.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("decodes immutable A3 summary and per-item engineering decision", () => {
|
||||
const decodedCatalog = parseE30EngineeringCatalog(catalog());
|
||||
const decodedDecision = parseE30EngineeringDecision(decision());
|
||||
|
||||
assert.equal(decodedCatalog.items[0].summary.reviewedItemCount, 486);
|
||||
assert.equal(decodedCatalog.items[0].summary.humanExceptionCount, 5);
|
||||
assert.equal(decodedCatalog.items[0].humanExceptions[0].reviewKey, "semantic:162:7");
|
||||
assert.equal(
|
||||
decodedCatalog.items[0].humanExceptions[0].reviewPrompt.effects["object-present"],
|
||||
"Сохранить занятую геометрию.",
|
||||
);
|
||||
assert.equal(decodedDecision.verdict, "corrected");
|
||||
assert.equal(decodedDecision.effectiveStratum, null);
|
||||
assert.equal(decodedDecision.detectorAssessment, "false-positive");
|
||||
});
|
||||
|
||||
test("rejects a generation that claims human ground truth", () => {
|
||||
assert.throws(
|
||||
() => parseE30EngineeringCatalog(catalog({
|
||||
items: [generation({
|
||||
producer: {
|
||||
...generation().producer,
|
||||
claims_human_ground_truth: true,
|
||||
},
|
||||
})],
|
||||
})),
|
||||
E30EngineeringContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches one content-addressed item decision", async () => {
|
||||
const requests = [];
|
||||
const decoded = await fetchE30EngineeringDecision(
|
||||
resultId,
|
||||
generationId,
|
||||
itemId,
|
||||
{
|
||||
fetcher: async (input, init) => {
|
||||
requests.push({ input: String(input), method: init?.method });
|
||||
return new Response(JSON.stringify(decision()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(decoded.pointOwnership, "surface-or-background");
|
||||
assert.deepEqual(requests, [{
|
||||
input:
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/items/${itemId}`,
|
||||
method: "GET",
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let parseE30HumanReviewDraft;
|
||||
let parseE30HumanReviewFinalized;
|
||||
let createOrResumeE30HumanReview;
|
||||
let saveE30HumanReviewDecision;
|
||||
let finalizeE30HumanReview;
|
||||
let E30ReviewContractError;
|
||||
|
||||
const resultId = `e30-materialization-${"a".repeat(64)}`;
|
||||
const engineeringGenerationId =
|
||||
`e30-engineering-generation-${"b".repeat(64)}`;
|
||||
const draftId = `e30-human-draft-${"c".repeat(64)}`;
|
||||
const reviewGenerationId = `e30-review-generation-${"d".repeat(64)}`;
|
||||
const firstItemId = `e30-review-item-${"e".repeat(64)}`;
|
||||
const secondItemId = `e30-review-item-${"f".repeat(64)}`;
|
||||
const firstEventId = `e30-review-event-${"1".repeat(64)}`;
|
||||
const secondEventId = `e30-review-event-${"2".repeat(64)}`;
|
||||
|
||||
function decision(overrides = {}) {
|
||||
return {
|
||||
item_id: firstItemId,
|
||||
source_stratum: "conflict",
|
||||
disposition: "object-present",
|
||||
notes: null,
|
||||
event_id: firstEventId,
|
||||
decided_at_utc: "2026-07-27T06:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function draft(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.e30-human-review-draft/v2",
|
||||
draft_id: draftId,
|
||||
materialization_id: resultId,
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
reviewer_id: "DC",
|
||||
created_at_utc: "2026-07-27T06:00:00Z",
|
||||
state: "active",
|
||||
revision: 1,
|
||||
item_count: 2,
|
||||
reviewed_item_count: 1,
|
||||
remaining_item_count: 1,
|
||||
disposition_distribution: { "object-present": 1 },
|
||||
generation_id: null,
|
||||
decisions: [decision()],
|
||||
lab_published: false,
|
||||
access: "review-write",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function finalized() {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-human-review-finalized/v2",
|
||||
draft: draft({
|
||||
state: "finalized",
|
||||
revision: 2,
|
||||
reviewed_item_count: 2,
|
||||
remaining_item_count: 0,
|
||||
disposition_distribution: {
|
||||
"object-present": 1,
|
||||
"background-or-noise": 1,
|
||||
},
|
||||
generation_id: reviewGenerationId,
|
||||
decisions: [
|
||||
decision(),
|
||||
decision({
|
||||
item_id: secondItemId,
|
||||
source_stratum: "unknown",
|
||||
disposition: "background-or-noise",
|
||||
event_id: secondEventId,
|
||||
}),
|
||||
],
|
||||
access: "read-only",
|
||||
}),
|
||||
generation: {
|
||||
schema_version: "missioncore.e30-human-review-generation/v2",
|
||||
generation_id: reviewGenerationId,
|
||||
materialization_id: resultId,
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
reviewer_id: "DC",
|
||||
created_at_utc: "2026-07-27T06:05:00Z",
|
||||
item_count: 2,
|
||||
disposition_distribution: {
|
||||
"object-present": 1,
|
||||
"background-or-noise": 1,
|
||||
},
|
||||
coverage: {
|
||||
expected_item_count: 2,
|
||||
reviewed_item_count: 2,
|
||||
complete: true,
|
||||
},
|
||||
human_review_complete: true,
|
||||
lab_published: false,
|
||||
access: "read-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
parseE30HumanReviewDraft,
|
||||
parseE30HumanReviewFinalized,
|
||||
createOrResumeE30HumanReview,
|
||||
saveE30HumanReviewDecision,
|
||||
finalizeE30HumanReview,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/e30HumanReview.ts",
|
||||
));
|
||||
({ E30ReviewContractError } = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/e30Review.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("decodes an exception-only draft and immutable final generation", () => {
|
||||
const active = parseE30HumanReviewDraft(draft());
|
||||
const complete = parseE30HumanReviewFinalized(finalized());
|
||||
|
||||
assert.equal(active.engineeringGenerationId, engineeringGenerationId);
|
||||
assert.equal(active.reviewedItemCount, 1);
|
||||
assert.equal(active.decisions[0].disposition, "object-present");
|
||||
assert.equal(complete.draft.state, "finalized");
|
||||
assert.equal(complete.generation.humanReviewComplete, true);
|
||||
assert.equal(complete.generation.labPublished, false);
|
||||
});
|
||||
|
||||
test("rejects unknown dispositions and finalized writable access", () => {
|
||||
assert.throws(
|
||||
() => parseE30HumanReviewDraft(draft({
|
||||
decisions: [decision({ disposition: "invented" })],
|
||||
})),
|
||||
E30ReviewContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseE30HumanReviewDraft(draft({
|
||||
state: "finalized",
|
||||
access: "review-write",
|
||||
})),
|
||||
E30ReviewContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses generation-bound write endpoints and explicit finalization", async () => {
|
||||
const requests = [];
|
||||
const fetcher = async (input, init) => {
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
requests.push({ input: String(input), method: init?.method, body });
|
||||
const payload = String(input).includes("/finalize?")
|
||||
? finalized()
|
||||
: draft();
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
await createOrResumeE30HumanReview(
|
||||
resultId,
|
||||
engineeringGenerationId,
|
||||
"DC",
|
||||
{ fetcher },
|
||||
);
|
||||
await saveE30HumanReviewDecision(
|
||||
resultId,
|
||||
engineeringGenerationId,
|
||||
draftId,
|
||||
firstItemId,
|
||||
{
|
||||
expectedRevision: 1,
|
||||
idempotencyKey: "ui-decision-001",
|
||||
disposition: "insufficient-evidence",
|
||||
notes: null,
|
||||
},
|
||||
{ fetcher },
|
||||
);
|
||||
await finalizeE30HumanReview(
|
||||
resultId,
|
||||
engineeringGenerationId,
|
||||
draftId,
|
||||
2,
|
||||
{ fetcher },
|
||||
);
|
||||
|
||||
assert.equal(requests[0].method, "POST");
|
||||
assert.deepEqual(requests[0].body, {
|
||||
reviewer_id: "DC",
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
});
|
||||
assert.match(requests[1].input, /engineering_generation_id=/);
|
||||
assert.equal(requests[1].body.disposition, "insufficient-evidence");
|
||||
assert.equal(requests[2].body.confirm_generation, true);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let parseE30ReviewCatalog;
|
||||
let parseE30ReviewItems;
|
||||
let parseE30ReviewItemDetail;
|
||||
let fetchE30ReviewItems;
|
||||
let E30ReviewContractError;
|
||||
|
||||
const resultId = `e30-materialization-${"a".repeat(64)}`;
|
||||
const reviewPackId = `e30-review-pack-${"b".repeat(64)}`;
|
||||
const e29ResultId = `e29-camera-geometry-${"c".repeat(64)}`;
|
||||
const itemId = `e30-review-item-${"d".repeat(64)}`;
|
||||
|
||||
function result(overrides = {}) {
|
||||
return {
|
||||
result_id: resultId,
|
||||
created_at_utc: "2026-07-26T00:00:00Z",
|
||||
review_pack_id: reviewPackId,
|
||||
e29_result_id: e29ResultId,
|
||||
source_session_id: "20260720T065719Z_viewer_live",
|
||||
item_count: 486,
|
||||
stratum_counts: {
|
||||
conflict: 38,
|
||||
agree: 96,
|
||||
"camera-only": 128,
|
||||
unknown: 96,
|
||||
"geometry-only": 128,
|
||||
},
|
||||
reason_taxonomy: ["time_mismatch", "geometry_mismatch", "other"],
|
||||
projection: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
source_id: "sensor.camera.right",
|
||||
calibration_slot: "camera_1",
|
||||
},
|
||||
camera_evidence_available: true,
|
||||
human_review_complete: false,
|
||||
lab_published: false,
|
||||
access: "read-only",
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function item(overrides = {}) {
|
||||
return {
|
||||
item_id: itemId,
|
||||
sequence: 212,
|
||||
review_key: "semantic:1800:1",
|
||||
stratum: "conflict",
|
||||
range_bucket: "unavailable",
|
||||
frame_index: 1800,
|
||||
source_frame_index: 1800,
|
||||
session_seconds: 215.323857292,
|
||||
locator: {
|
||||
kind: "semantic-observation",
|
||||
observation_index: 1,
|
||||
},
|
||||
snapshot: {
|
||||
label: "car",
|
||||
geometry_status: "conflict",
|
||||
geometry_reason: "camera-object-region-observed-as-local-surface",
|
||||
range_m: null,
|
||||
bbox_xyxy: [223.6, 270.2, 282.4, 305.2],
|
||||
},
|
||||
materialization: {
|
||||
frame_point_count: 1813,
|
||||
projected_point_count: 3,
|
||||
candidate_point_count: 2,
|
||||
selected_point_count: 1,
|
||||
rejected_candidate_point_count: 1,
|
||||
selected_and_candidate_lossless: true,
|
||||
free_space_valid: false,
|
||||
human_review_complete: false,
|
||||
detector_score: 0.83,
|
||||
},
|
||||
camera_frame_available: true,
|
||||
engineering_triage: {
|
||||
provenance: "deterministic-evidence-readiness/v1",
|
||||
state: "ready-for-ai-review",
|
||||
attention: "standard",
|
||||
signals: [],
|
||||
semantic_verdict: null,
|
||||
human_exception_required: null,
|
||||
},
|
||||
review: {
|
||||
state: "unreviewed",
|
||||
reason_code: null,
|
||||
notes: null,
|
||||
},
|
||||
access: "read-only",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-catalog/v1",
|
||||
configured: true,
|
||||
items: [result()],
|
||||
candidate_total: 1,
|
||||
invalid_total: 0,
|
||||
access: "read-only",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function items(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-items/v1",
|
||||
result_id: resultId,
|
||||
stratum: "conflict",
|
||||
items: [item()],
|
||||
total: 38,
|
||||
next_cursor: null,
|
||||
reason_taxonomy: ["time_mismatch", "geometry_mismatch", "other"],
|
||||
access: "read-only",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function detail(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-item-detail/v1",
|
||||
result_id: resultId,
|
||||
item: {
|
||||
...item(),
|
||||
camera_frame: {
|
||||
available: true,
|
||||
url: `/api/v1/laboratory/e30/reviews/${resultId}/items/${itemId}/camera-frame?generation=${"e".repeat(64)}`,
|
||||
sha256: "e".repeat(64),
|
||||
width: 800,
|
||||
height: 600,
|
||||
source_frame_index: 1800,
|
||||
exact_source_frame: true,
|
||||
},
|
||||
selected: {
|
||||
source_indices: [7],
|
||||
points_map_xyz_m: [[1, 2, 3]],
|
||||
},
|
||||
candidate: {
|
||||
source_indices: [7, 8],
|
||||
points_map_xyz_m: [[1, 2, 3], [1.1, 2.1, 3.1]],
|
||||
},
|
||||
projection: {
|
||||
source_indices: [7, 8, 9],
|
||||
points_map_xyz_m: [[1, 2, 3], [1.1, 2.1, 3.1], [2, 3, 4]],
|
||||
pixels_xy: [[220, 270], [230, 275], [500, 400]],
|
||||
depth_m: [3, 3.1, 4],
|
||||
point_class: [2, 1, 0],
|
||||
point_height_m: [0.8, 0.1, 0],
|
||||
candidate_mask: [1, 1, 0],
|
||||
selected_mask: [1, 0, 0],
|
||||
},
|
||||
pose: {
|
||||
position_map_xyz_m: [10, 11, 0],
|
||||
orientation_map_from_lidar_xyzw: [0, 0, 0, 1],
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
parseE30ReviewCatalog,
|
||||
parseE30ReviewItems,
|
||||
parseE30ReviewItemDetail,
|
||||
fetchE30ReviewItems,
|
||||
E30ReviewContractError,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/e30Review.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("decodes immutable E30 catalog, strata and exact point evidence", () => {
|
||||
const decodedCatalog = parseE30ReviewCatalog(catalog());
|
||||
const decodedItems = parseE30ReviewItems(items());
|
||||
const decodedDetail = parseE30ReviewItemDetail(detail());
|
||||
|
||||
assert.equal(decodedCatalog.items[0].stratumCounts.conflict, 38);
|
||||
assert.equal(decodedCatalog.items[0].humanReviewComplete, false);
|
||||
assert.equal(decodedItems.items[0].sessionSeconds, 215.323857292);
|
||||
assert.deepEqual(decodedDetail.selected.sourceIndices, [7]);
|
||||
assert.deepEqual(decodedDetail.projection.selectedMask, [1, 0, 0]);
|
||||
assert.equal(decodedDetail.cameraFrame.exactSourceFrame, true);
|
||||
});
|
||||
|
||||
test("rejects authority escalation and inconsistent materialized arrays", () => {
|
||||
assert.throws(
|
||||
() => parseE30ReviewCatalog(catalog({
|
||||
items: [result({
|
||||
authority: {
|
||||
commands_enabled: true,
|
||||
navigation_or_safety_accepted: false,
|
||||
},
|
||||
})],
|
||||
})),
|
||||
E30ReviewContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseE30ReviewItemDetail(detail({
|
||||
selected: {
|
||||
source_indices: [7, 8],
|
||||
points_map_xyz_m: [[1, 2, 3]],
|
||||
},
|
||||
})),
|
||||
E30ReviewContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches the canonical read-only stratum endpoint", async () => {
|
||||
const requests = [];
|
||||
const decoded = await fetchE30ReviewItems(resultId, "conflict", {
|
||||
fetcher: async (input, init) => {
|
||||
requests.push({ input: String(input), method: init?.method });
|
||||
return new Response(JSON.stringify(items()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(decoded.total, 38);
|
||||
assert.deepEqual(requests, [{
|
||||
input: `/api/v1/laboratory/e30/reviews/${resultId}/items?stratum=conflict&limit=128&cursor=0`,
|
||||
method: "GET",
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
|
||||
const workspaceSourceUrl = new URL(
|
||||
"../src/workspaces/E30ReviewWorkspace.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const presentationSourceUrl = new URL(
|
||||
"../src/components/laboratory/LaboratoryPresentation.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const viewerSourceUrl = new URL(
|
||||
"../src/components/laboratory/LaboratoryEvidenceViewer.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const laboratoryStylesUrl = new URL(
|
||||
"../src/styles/laboratory.css",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test("E30 uses one reusable evidence viewer with camera, 3D and expand controls", async () => {
|
||||
const [workspaceSource, viewerSource, laboratoryStyles] = await Promise.all([
|
||||
readFile(workspaceSourceUrl, "utf8"),
|
||||
readFile(viewerSourceUrl, "utf8"),
|
||||
readFile(laboratoryStylesUrl, "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(workspaceSource, /<LaboratoryEvidenceViewer/);
|
||||
assert.match(workspaceSource, /\{ value: "3d", label: "3D" \}/);
|
||||
assert.match(workspaceSource, /\{ value: "camera", label: "Камера" \}/);
|
||||
assert.match(workspaceSource, /<E30EvidenceProjection/);
|
||||
assert.match(workspaceSource, /<E30EvidenceTelemetry/);
|
||||
assert.match(workspaceSource, /aria-pressed=\{pointLayerVisible\}/);
|
||||
assert.match(workspaceSource, />\s*LiDAR\s*<\/Button>/);
|
||||
assert.match(workspaceSource, /review: "Проверка"/);
|
||||
assert.doesNotMatch(workspaceSource, /e30-review-evidence__facts/);
|
||||
assert.doesNotMatch(workspaceSource, /E30EngineeringAuditPanel/);
|
||||
assert.match(viewerSource, /createPortal/);
|
||||
assert.match(viewerSource, /name=\{expanded \? "minimize" : "expand"\}/);
|
||||
assert.match(
|
||||
laboratoryStyles,
|
||||
/\.e30-evidence-telemetry dl\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/s,
|
||||
);
|
||||
assert.match(
|
||||
laboratoryStyles,
|
||||
/\.e30-projection-scene canvas\s*\{[^}]*object-fit:\s*cover/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("E30 exception review is a separate block after the fixed evidence workspace", async () => {
|
||||
const workspaceSource = await readFile(workspaceSourceUrl, "utf8");
|
||||
const bodyStart = workspaceSource.indexOf(
|
||||
'<div className="e30-review-workspace__body">',
|
||||
);
|
||||
const detailClose = workspaceSource.indexOf(
|
||||
"</div>\n\n {!detailLoading",
|
||||
bodyStart,
|
||||
);
|
||||
const reviewPanel = workspaceSource.indexOf(
|
||||
"<E30HumanReviewPanel",
|
||||
bodyStart,
|
||||
);
|
||||
|
||||
assert.ok(bodyStart >= 0);
|
||||
assert.ok(detailClose > bodyStart);
|
||||
assert.ok(reviewPanel > detailClose);
|
||||
});
|
||||
|
||||
test("LAB product surface has a compact canonical summary and no roadmap footer", async () => {
|
||||
const [workspaceSource, presentationSource] = await Promise.all([
|
||||
readFile(workspaceSourceUrl, "utf8"),
|
||||
readFile(presentationSourceUrl, "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(presentationSource, /export function LaboratorySummary/);
|
||||
assert.match(presentationSource, /export function LaboratoryWorkTemplate/);
|
||||
assert.doesNotMatch(workspaceSource, /СЛЕДУЮЩИЙ GATE/);
|
||||
assert.doesNotMatch(workspaceSource, /e30-review-workspace__taxonomy/);
|
||||
});
|
||||
@@ -178,23 +178,30 @@ test("data recordings keep the compact session dropdown and laboratory results s
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const laboratorySource = await readFile(
|
||||
new URL(
|
||||
"../src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const productSource = await readFile(
|
||||
new URL("../src/productModel.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const recordingsSource = workspaceSource.slice(
|
||||
workspaceSource.indexOf("function RecordingsWorkspace"),
|
||||
workspaceSource.indexOf("type LaboratoryProfileId"),
|
||||
workspaceSource.indexOf("export function WorkspaceRenderer"),
|
||||
);
|
||||
|
||||
assert.match(appSource, /activeDefinition\.kind === "recordings"[\s\S]*<ObservationSessionSelect/);
|
||||
assert.match(recordingsSource, /<SpatialWorkspace \{\.\.\.props\} \/>/);
|
||||
assert.doesNotMatch(recordingsSource, /ObservationSessionArchive/);
|
||||
assert.match(productSource, /label: "Лабораторные контуры"/);
|
||||
assert.match(workspaceSource, /ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА/);
|
||||
assert.match(workspaceSource, /ЛАБОРАТОРНАЯ РАБОТА/);
|
||||
assert.match(workspaceSource, /e28-local-surface/);
|
||||
assert.match(workspaceSource, /e29-camera-geometry/);
|
||||
assert.match(laboratorySource, /ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА/);
|
||||
assert.match(laboratorySource, /ЛАБОРАТОРНАЯ РАБОТА/);
|
||||
assert.match(laboratorySource, /e28-local-surface/);
|
||||
assert.match(laboratorySource, /e29-camera-geometry/);
|
||||
});
|
||||
|
||||
test("source and laboratory catalogs are requested as disjoint backend projections", async () => {
|
||||
|
||||
@@ -31,11 +31,21 @@ test("top navigation has no Center and Park owns contour health first", () => {
|
||||
|
||||
test("every laboratory result uses the shared evidence template", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL(
|
||||
"../src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const presentation = await readFile(
|
||||
new URL(
|
||||
"../src/components/laboratory/LaboratoryPresentation.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/workspaces.css", import.meta.url),
|
||||
new URL("../src/styles/laboratory.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const app = await readFile(
|
||||
@@ -43,11 +53,14 @@ test("every laboratory result uses the shared evidence template", async () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(source, /function LaboratoryWorkTemplate\(/);
|
||||
assert.match(source, /function LaboratoryEvidence\(/);
|
||||
assert.match(source, /function LaboratoryMethodCard\(/);
|
||||
assert.match(source, /method:\s*ReactNode/);
|
||||
assert.match(source, /data-evidence-kind=\{kind\}/);
|
||||
assert.match(source, /LaboratoryWorkTemplate,/);
|
||||
assert.match(source, /LaboratorySummary,/);
|
||||
assert.match(presentation, /export function LaboratoryWorkTemplate\(/);
|
||||
assert.match(presentation, /export function LaboratoryEvidence\(/);
|
||||
assert.match(presentation, /export function LaboratorySummary\(/);
|
||||
assert.match(presentation, /summary:\s*ReactNode/);
|
||||
assert.match(presentation, /data-evidence-kind=\{kind\}/);
|
||||
assert.doesNotMatch(source, /function LaboratoryWorkTemplate\(/);
|
||||
assert.match(source, /data-viewer-focused=/);
|
||||
assert.match(css, /height:\s*clamp\(42rem,\s*68vh,\s*58rem\)/);
|
||||
assert.match(css, /resize:\s*vertical/);
|
||||
|
||||
Reference in New Issue
Block a user