refactor(lab): canonize selected evidence reports
This commit is contained in:
@@ -863,6 +863,7 @@ export default function App() {
|
||||
onDeleteBegin: releaseRecordedReplayForDelete,
|
||||
}}
|
||||
onLaboratoryAnnotationActionChange={laboratoryAnnotation.setAction}
|
||||
onLaboratoryViewActionChange={laboratoryAnnotation.setViewAction}
|
||||
navigation={{
|
||||
openView,
|
||||
openSource,
|
||||
@@ -1243,7 +1244,6 @@ export default function App() {
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Select,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
import { Select, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryOption<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
status?: "progress" | "retained" | "failed" | "unreviewed";
|
||||
}
|
||||
|
||||
export type LaboratoryExecutionClass =
|
||||
@@ -103,6 +101,20 @@ export function LaboratorySelector<T extends string>({
|
||||
options={options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
icon: option.status ? (
|
||||
<span
|
||||
className="laboratory-status-dot"
|
||||
data-status={option.status}
|
||||
aria-label={option.status === "progress"
|
||||
? "Есть подтверждённый прогресс"
|
||||
: option.status === "retained"
|
||||
? "Сохранено для сравнения"
|
||||
: option.status === "failed"
|
||||
? "Gate не пройден"
|
||||
: "Требует классификации"}
|
||||
>
|
||||
</span>
|
||||
) : undefined,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type { LaboratoryAnnotationAction } from "../../workspaces/contracts";
|
||||
import type {
|
||||
LaboratoryAnnotationAction,
|
||||
LaboratoryViewAction,
|
||||
} from "../../workspaces/contracts";
|
||||
|
||||
export function useLaboratoryAnnotationHeader(): {
|
||||
control: ReactNode;
|
||||
setAction: (action: LaboratoryAnnotationAction | null) => void;
|
||||
setViewAction: (action: LaboratoryViewAction | null) => void;
|
||||
} {
|
||||
const [action, setAction] = useState<LaboratoryAnnotationAction | null>(null);
|
||||
const [viewAction, setViewAction] = useState<LaboratoryViewAction | null>(null);
|
||||
return {
|
||||
setAction,
|
||||
control: action ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant="accent"
|
||||
icon={<Icon name="edit" size={16} />}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
setViewAction,
|
||||
control: action || viewAction ? (
|
||||
<div className="laboratory-header-tools">
|
||||
{viewAction ? (
|
||||
<Button shape="pill" variant="secondary" onClick={viewAction.onClick}>
|
||||
{viewAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{action ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant="accent"
|
||||
icon={<Icon name="edit" size={16} />}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
export type LaboratoryEvidenceCompleteness = "recorded" | "not-recorded";
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export interface LaboratoryEvidenceArtifact {
|
||||
kind: string | null;
|
||||
path: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
schemaVersion: string | null;
|
||||
mediaType: string | null;
|
||||
verified: true;
|
||||
}
|
||||
|
||||
export interface LaboratoryEvidenceReport {
|
||||
schemaVersion: "missioncore.laboratory-evidence-report/v1";
|
||||
workId: string;
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
access: "read-only";
|
||||
proof: {
|
||||
documentSchemaVersion: string;
|
||||
documentSha256: string;
|
||||
identitySha256: string;
|
||||
reportSchemaVersion: string | null;
|
||||
reportSha256: string | null;
|
||||
artifactCount: number;
|
||||
verifiedArtifactCount: number;
|
||||
};
|
||||
completeness: Readonly<Record<string, LaboratoryEvidenceCompleteness>>;
|
||||
identity: Record<string, JsonValue>;
|
||||
source: Record<string, JsonValue> | null;
|
||||
configuration: Record<string, JsonValue> | null;
|
||||
method: Record<string, JsonValue> | null;
|
||||
execution: Record<string, JsonValue> | null;
|
||||
resources: Record<string, JsonValue> | null;
|
||||
metrics: Record<string, JsonValue> | null;
|
||||
gates: Record<string, JsonValue> | null;
|
||||
decision: JsonValue | undefined;
|
||||
limitations: JsonValue | undefined;
|
||||
authority: Record<string, JsonValue> | null;
|
||||
artifacts: readonly LaboratoryEvidenceArtifact[];
|
||||
visualEvidence: Record<string, JsonValue>;
|
||||
rawReport: Record<string, JsonValue>;
|
||||
canonicalJson: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export class LaboratoryEvidenceReportContractError extends Error {}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nullableRecord(value: unknown, label: string): Record<string, JsonValue> | null {
|
||||
return value === null ? null : jsonRecord(value, label);
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown, label: string): Record<string, JsonValue> {
|
||||
const document = record(value, label);
|
||||
for (const [key, item] of Object.entries(document)) validateJson(item, `${label}.${key}`);
|
||||
return document as Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
function validateJson(value: unknown, label: string): asserts value is JsonValue {
|
||||
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: число не конечно.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => validateJson(item, `${label}[${index}]`));
|
||||
return;
|
||||
}
|
||||
const document = record(value, label);
|
||||
Object.entries(document).forEach(([key, item]) => validateJson(item, `${label}.${key}`));
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(value: unknown, label: string): string | null {
|
||||
return value === null ? null : text(value, label);
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseArtifact(value: unknown): LaboratoryEvidenceArtifact {
|
||||
const artifact = record(value, "LAB artifact");
|
||||
if (artifact.verified !== true) {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB artifact: хэш не подтверждён.");
|
||||
}
|
||||
return {
|
||||
kind: nullableText(artifact.kind, "LAB artifact kind"),
|
||||
path: text(artifact.path, "LAB artifact path"),
|
||||
byteLength: integer(artifact.byte_length, "LAB artifact byte_length"),
|
||||
sha256: text(artifact.sha256, "LAB artifact sha256"),
|
||||
schemaVersion: nullableText(artifact.schema_version, "LAB artifact schema_version"),
|
||||
mediaType: nullableText(artifact.media_type, "LAB artifact media_type"),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLaboratoryEvidenceReport(value: unknown): LaboratoryEvidenceReport {
|
||||
const payload = jsonRecord(value, "LAB evidence report") as Record<string, unknown>;
|
||||
if (payload.schema_version !== "missioncore.laboratory-evidence-report/v1") {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence report: неизвестная схема.");
|
||||
}
|
||||
if (payload.access !== "read-only") {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence report: доступ не read-only.");
|
||||
}
|
||||
const proof = record(payload.proof, "LAB evidence proof");
|
||||
const completenessPayload = record(payload.completeness, "LAB evidence completeness");
|
||||
const completeness: Record<string, LaboratoryEvidenceCompleteness> = {};
|
||||
for (const [key, state] of Object.entries(completenessPayload)) {
|
||||
if (state !== "recorded" && state !== "not-recorded") {
|
||||
throw new LaboratoryEvidenceReportContractError(`LAB completeness ${key}: неизвестное значение.`);
|
||||
}
|
||||
completeness[key] = state;
|
||||
}
|
||||
if (!Array.isArray(payload.artifacts)) {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence artifacts: ожидался список.");
|
||||
}
|
||||
return {
|
||||
schemaVersion: "missioncore.laboratory-evidence-report/v1",
|
||||
workId: text(payload.work_id, "LAB work_id"),
|
||||
resultId: text(payload.result_id, "LAB result_id"),
|
||||
createdAtUtc: nullableText(payload.created_at_utc, "LAB created_at_utc"),
|
||||
access: "read-only",
|
||||
proof: {
|
||||
documentSchemaVersion: text(proof.document_schema_version, "document schema"),
|
||||
documentSha256: text(proof.document_sha256, "document sha256"),
|
||||
identitySha256: text(proof.identity_sha256, "identity sha256"),
|
||||
reportSchemaVersion: nullableText(proof.report_schema_version, "report schema"),
|
||||
reportSha256: nullableText(proof.report_sha256, "report sha256"),
|
||||
artifactCount: integer(proof.artifact_count, "artifact_count"),
|
||||
verifiedArtifactCount: integer(proof.verified_artifact_count, "verified_artifact_count"),
|
||||
},
|
||||
completeness,
|
||||
identity: jsonRecord(payload.identity, "LAB identity"),
|
||||
source: nullableRecord(payload.source, "LAB source"),
|
||||
configuration: nullableRecord(payload.configuration, "LAB configuration"),
|
||||
method: nullableRecord(payload.method, "LAB method"),
|
||||
execution: nullableRecord(payload.execution, "LAB execution"),
|
||||
resources: nullableRecord(payload.resources, "LAB resources"),
|
||||
metrics: nullableRecord(payload.metrics, "LAB metrics"),
|
||||
gates: nullableRecord(payload.gates, "LAB gates"),
|
||||
decision: payload.decision as JsonValue | undefined,
|
||||
limitations: payload.limitations as JsonValue | undefined,
|
||||
authority: nullableRecord(payload.authority, "LAB authority"),
|
||||
artifacts: payload.artifacts.map(parseArtifact),
|
||||
visualEvidence: jsonRecord(payload.visual_evidence, "LAB visual evidence"),
|
||||
rawReport: jsonRecord(payload.raw_report, "LAB raw report"),
|
||||
canonicalJson: payload as Record<string, JsonValue>,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLaboratoryEvidenceReport({
|
||||
workId,
|
||||
resultId,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
workId: string;
|
||||
resultId: string;
|
||||
fetcher?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<LaboratoryEvidenceReport> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/evidence-reports/${encodeURIComponent(workId)}/${encodeURIComponent(resultId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new LaboratoryEvidenceReportContractError(
|
||||
response.status === 404
|
||||
? "Для этой LAB канонический evidence-report пока не опубликован."
|
||||
: `Evidence-report LAB не прошёл серверную проверку: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const report = parseLaboratoryEvidenceReport(await response.json());
|
||||
if (report.workId !== workId || report.resultId !== resultId) {
|
||||
throw new LaboratoryEvidenceReportContractError("Evidence-report не совпадает с выбранной LAB identity.");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export type LaboratoryValueSignal = "progress" | "retained" | "failed";
|
||||
export type LaboratoryValueLifecycle = "current" | "legacy";
|
||||
export type LaboratoryVisualEvidence = "available" | "partial" | "missing";
|
||||
|
||||
export interface LaboratoryValueReviewEntry {
|
||||
catalogId: string;
|
||||
evidenceId: string;
|
||||
signal: LaboratoryValueSignal;
|
||||
lifecycle: LaboratoryValueLifecycle;
|
||||
visualEvidence: LaboratoryVisualEvidence;
|
||||
}
|
||||
|
||||
export interface LaboratoryValueReviewIndex {
|
||||
reviewedAtUtc: string;
|
||||
items: readonly LaboratoryValueReviewEntry[];
|
||||
}
|
||||
|
||||
export class LaboratoryValueReviewContractError extends Error {}
|
||||
|
||||
const ENTRY_KEYS = [
|
||||
"access",
|
||||
"catalog_id",
|
||||
"evidence_id",
|
||||
"lifecycle",
|
||||
"signal",
|
||||
"visual_evidence",
|
||||
] as const;
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new LaboratoryValueReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
if (actual.join("\0") !== [...expected].sort().join("\0")) {
|
||||
throw new LaboratoryValueReviewContractError(`${label}: нарушен состав полей.`);
|
||||
}
|
||||
}
|
||||
|
||||
function textValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim() || value !== value.trim()) {
|
||||
throw new LaboratoryValueReviewContractError(`${label}: ожидалась непустая строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEntry(value: unknown): LaboratoryValueReviewEntry {
|
||||
const item = objectValue(value, "LAB value-review item");
|
||||
exactKeys(item, ENTRY_KEYS, "LAB value-review item");
|
||||
if (item.access !== "read-only") {
|
||||
throw new LaboratoryValueReviewContractError("LAB value-review item: доступ не read-only.");
|
||||
}
|
||||
if (!(["progress", "retained", "failed"] as const).includes(
|
||||
item.signal as LaboratoryValueSignal,
|
||||
)) {
|
||||
throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный signal.");
|
||||
}
|
||||
if (!(["current", "legacy"] as const).includes(
|
||||
item.lifecycle as LaboratoryValueLifecycle,
|
||||
)) {
|
||||
throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный lifecycle.");
|
||||
}
|
||||
if (!(["available", "partial", "missing"] as const).includes(
|
||||
item.visual_evidence as LaboratoryVisualEvidence,
|
||||
)) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
"LAB value-review item: неизвестный visual_evidence.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
catalogId: textValue(item.catalog_id, "LAB value-review catalog_id"),
|
||||
evidenceId: textValue(item.evidence_id, "LAB value-review evidence_id"),
|
||||
signal: item.signal as LaboratoryValueSignal,
|
||||
lifecycle: item.lifecycle as LaboratoryValueLifecycle,
|
||||
visualEvidence: item.visual_evidence as LaboratoryVisualEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLaboratoryValueReviewIndex({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<LaboratoryValueReviewIndex> {
|
||||
const response = await fetcher("/api/v1/laboratory/value-review-index", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
`Value-review индекс LAB недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "LAB value-review index");
|
||||
exactKeys(
|
||||
payload,
|
||||
["access", "items", "reviewed_at_utc", "schema_version"],
|
||||
"LAB value-review index",
|
||||
);
|
||||
if (
|
||||
payload.schema_version !== "missioncore.laboratory-value-review-index/v1"
|
||||
|| payload.access !== "read-only"
|
||||
|| !Array.isArray(payload.items)
|
||||
|| payload.items.length > 128
|
||||
) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
"LAB value-review index: нарушен контракт.",
|
||||
);
|
||||
}
|
||||
const items = payload.items.map(parseEntry);
|
||||
if (new Set(items.map((item) => item.catalogId)).size !== items.length) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
"LAB value-review index: catalog_id продублирован.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
reviewedAtUtc: textValue(payload.reviewed_at_utc, "LAB value-review reviewed_at_utc"),
|
||||
items,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@import "./styles/l3-pointpillars-visual-audit.css";
|
||||
@import "./styles/l34-annotation.css";
|
||||
@import "./styles/laboratory-reporting.css";
|
||||
@import "./styles/laboratory-evidence-report.css";
|
||||
@import "./styles/e34-temporal-layer.css";
|
||||
@import "./styles/e35-degradation-recovery.css";
|
||||
@import "./styles/e30-human-review.css";
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
.laboratory-header-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.laboratory-header-tools > .nodedc-button:first-child {
|
||||
min-width: 6.9rem;
|
||||
}
|
||||
|
||||
.laboratory-status-dot {
|
||||
display: inline-block;
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
flex: 0 0 0.48rem;
|
||||
border-radius: 50%;
|
||||
background: currentcolor;
|
||||
color: var(--nodedc-text-muted);
|
||||
box-shadow: 0 0 0 0.08rem rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
.laboratory-status-dot[data-status="progress"] {
|
||||
color: rgb(var(--nodedc-success-rgb));
|
||||
box-shadow: 0 0 0.38rem rgb(var(--nodedc-success-rgb) / 0.35);
|
||||
}
|
||||
|
||||
.laboratory-status-dot[data-status="retained"] {
|
||||
color: rgb(var(--nodedc-warning-rgb));
|
||||
box-shadow: 0 0 0.38rem rgb(var(--nodedc-warning-rgb) / 0.28);
|
||||
}
|
||||
|
||||
.laboratory-status-dot[data-status="failed"],
|
||||
.laboratory-status-dot[data-status="unreviewed"] {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.laboratory-evidence-report {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
min-width: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__header,
|
||||
.laboratory-evidence-report__integrity,
|
||||
.laboratory-evidence-report__section {
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__header,
|
||||
.laboratory-evidence-report__integrity > header,
|
||||
.laboratory-evidence-report__artifact-list article > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report h2,
|
||||
.laboratory-evidence-report h3,
|
||||
.laboratory-evidence-report p,
|
||||
.laboratory-evidence-report dl,
|
||||
.laboratory-evidence-report dd,
|
||||
.laboratory-evidence-report ol {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report h2 {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report h3 {
|
||||
margin-top: 0.22rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__header p {
|
||||
margin-top: 0.32rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__identity {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.42rem;
|
||||
margin-top: 0.8rem !important;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__identity > div,
|
||||
.laboratory-evidence-report__completeness > div {
|
||||
min-width: 0;
|
||||
border-radius: 0.72rem;
|
||||
background: rgb(255 255 255 / 0.028);
|
||||
padding: 0.68rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__identity dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-family: var(--nodedc-font-family-mono, monospace);
|
||||
font-size: 0.56rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__completeness {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.45rem !important;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__completeness dd {
|
||||
margin-top: 0.22rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__completeness [data-state="recorded"] dd {
|
||||
color: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__completeness [data-state="not-recorded"] dd {
|
||||
color: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__section > header {
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__tree {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__tree > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7.5rem, 0.38fr) minmax(0, 1fr);
|
||||
gap: 0.55rem;
|
||||
border-top: 1px solid rgb(255 255 255 / 0.04);
|
||||
padding-top: 0.34rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__tree > div:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__tree[data-depth]:not([data-depth="0"]) > div {
|
||||
grid-template-columns: minmax(6rem, 0.32fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__tree dd,
|
||||
.laboratory-evidence-report__value,
|
||||
.laboratory-evidence-report__array {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.59rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__array {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__missing {
|
||||
color: rgb(var(--nodedc-warning-rgb));
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__artifact-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__artifact-list article {
|
||||
min-width: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.028);
|
||||
padding: 0.72rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__artifact-list strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.63rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__artifact-list p {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0.34rem 0 !important;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__artifact-list dl {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__artifact-list dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem minmax(0, 1fr);
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__artifact-list dd {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-family: var(--nodedc-font-family-mono, monospace);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__canonical-json pre {
|
||||
max-height: 34rem;
|
||||
overflow: auto;
|
||||
border-radius: 0.72rem;
|
||||
background: rgb(0 0 0 / 0.24);
|
||||
padding: 0.8rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-family: var(--nodedc-font-family-mono, monospace);
|
||||
font-size: 0.55rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
border-radius: 0.8rem;
|
||||
background: rgb(var(--nodedc-warning-rgb) / 0.08);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__notice strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__notice p {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.laboratory-evidence-report__completeness {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.laboratory-evidence-report__header,
|
||||
.laboratory-evidence-report__integrity > header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__grid,
|
||||
.laboratory-evidence-report__artifact-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.laboratory-evidence-report__identity,
|
||||
.laboratory-evidence-report__completeness {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ export interface LaboratoryAnnotationAction {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface LaboratoryViewAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
@@ -65,4 +70,5 @@ export interface WorkspaceRendererProps {
|
||||
onLaboratoryAnnotationActionChange: (
|
||||
action: LaboratoryAnnotationAction | null,
|
||||
) => void;
|
||||
onLaboratoryViewActionChange: (action: LaboratoryViewAction | null) => void;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
LaboratorySelector,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
type LaboratoryMethod,
|
||||
type LaboratoryMethodComponent,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../../core/observation/useObservationSessions";
|
||||
@@ -26,9 +24,7 @@ import {
|
||||
fetchE30ReviewCatalog,
|
||||
type E30ReviewResult,
|
||||
} from "../../core/laboratory/e30Review";
|
||||
import {
|
||||
type AdvancedLaboratoryResults,
|
||||
} from "../../core/laboratory/advancedResults";
|
||||
import { type AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults";
|
||||
import {
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceModel,
|
||||
@@ -42,11 +38,15 @@ import {
|
||||
advancedLaboratorySourceSession,
|
||||
isAdvancedLaboratoryWorkId,
|
||||
} from "./AdvancedLaboratoryResult";
|
||||
import { LaboratoryEvidenceReportView } from "./LaboratoryEvidenceReportView";
|
||||
import {
|
||||
e28LaboratoryBrief, e29LaboratoryBrief,
|
||||
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
|
||||
} from "./laboratoryArchiveBriefs";
|
||||
import { useAdvancedLaboratoryCatalog } from "./useAdvancedLaboratoryCatalog";
|
||||
import { useLaboratoryValueReviewIndex } from "./useLaboratoryValueReviewIndex";
|
||||
import { useLaboratoryEvidenceReport } from "./useLaboratoryEvidenceReport";
|
||||
import { useLaboratoryViewMode } from "./useLaboratoryViewMode";
|
||||
import { useL34AnnotationCapability } from "./annotation/useL34AnnotationCapability";
|
||||
import {
|
||||
buildLaboratoryCatalog,
|
||||
@@ -54,6 +54,16 @@ import {
|
||||
experimentOptionsForProfile,
|
||||
workOptionsForExperiment,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
import {
|
||||
experimentOptionsWithSignals,
|
||||
profileOptionsWithSignals,
|
||||
projectLaboratoryValueReviews,
|
||||
workOptionsWithSignals,
|
||||
} from "./laboratoryValueReviewProjection";
|
||||
import {
|
||||
digestFromContentId,
|
||||
publishedLaboratoryMethod,
|
||||
} from "./publishedLaboratoryMethod";
|
||||
import type {
|
||||
LaboratoryCatalogSeed,
|
||||
LaboratoryExperimentId,
|
||||
@@ -64,98 +74,6 @@ type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
|
||||
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 })} с`;
|
||||
}
|
||||
@@ -550,6 +468,10 @@ function PublishedLaboratoryResult({
|
||||
}
|
||||
|
||||
export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [viewMode] = useLaboratoryViewMode(
|
||||
props.onLaboratoryViewActionChange,
|
||||
);
|
||||
const laboratoryValueReview = useLaboratoryValueReviewIndex();
|
||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>(
|
||||
"rig-right-yolox-lidar-range-v1",
|
||||
);
|
||||
@@ -602,7 +524,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
},
|
||||
});
|
||||
const advancedResults: AdvancedLaboratoryResults = advanced.results;
|
||||
const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: workId, l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||
const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: viewMode === "laboratory" ? workId : "", l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEvidenceLoading(true);
|
||||
@@ -651,6 +573,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
items.push({
|
||||
id: "e28-local-surface",
|
||||
createdAtUtc: e28Model.createdAtUtc ?? "",
|
||||
evidenceId: e28Model.modelId,
|
||||
});
|
||||
}
|
||||
if (
|
||||
@@ -660,12 +583,14 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
items.push({
|
||||
id: "e29-camera-geometry",
|
||||
createdAtUtc: e29Result.createdAtUtc ?? "",
|
||||
evidenceId: e29Result.resultId,
|
||||
});
|
||||
}
|
||||
if (e30Result && sourceSessions.has(e30Result.sourceSessionId)) {
|
||||
items.push({
|
||||
id: "e30-evidence-review",
|
||||
createdAtUtc: e30Result.createdAtUtc ?? "",
|
||||
evidenceId: e30Result.resultId,
|
||||
});
|
||||
}
|
||||
return items.filter(({ createdAtUtc }) => createdAtUtc.trim());
|
||||
@@ -684,18 +609,33 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
}),
|
||||
[advanced.index, knownWorks, publishedWorks, rigLabel],
|
||||
);
|
||||
const valueReviews = useMemo(() => projectLaboratoryValueReviews({
|
||||
catalog,
|
||||
index: laboratoryValueReview.index,
|
||||
publishedWorks,
|
||||
}), [catalog, laboratoryValueReview.index, publishedWorks]);
|
||||
const profiles = useMemo(
|
||||
() => buildLaboratoryProfiles(catalog),
|
||||
[catalog],
|
||||
() => profileOptionsWithSignals(buildLaboratoryProfiles(catalog), catalog, valueReviews),
|
||||
[catalog, valueReviews],
|
||||
);
|
||||
const experimentOptions = useMemo(
|
||||
() => experimentOptionsForProfile(profileId, catalog),
|
||||
[catalog, profileId],
|
||||
() => experimentOptionsWithSignals(
|
||||
experimentOptionsForProfile(profileId, catalog), profileId, catalog, valueReviews,
|
||||
),
|
||||
[catalog, profileId, valueReviews],
|
||||
);
|
||||
const workOptions = useMemo(
|
||||
() => workOptionsForExperiment(profileId, experimentId, catalog),
|
||||
[catalog, experimentId, profileId],
|
||||
() => workOptionsWithSignals(
|
||||
workOptionsForExperiment(profileId, experimentId, catalog), valueReviews,
|
||||
),
|
||||
[catalog, experimentId, profileId, valueReviews],
|
||||
);
|
||||
const selectedCatalog = catalog.find((entry) => entry.id === workId) ?? null;
|
||||
const evidenceReport = useLaboratoryEvidenceReport({
|
||||
workId,
|
||||
resultId: selectedCatalog?.evidenceId ?? "",
|
||||
enabled: viewMode === "report" && selectedCatalog !== null,
|
||||
});
|
||||
const selectedSessionId = workId.startsWith("session:")
|
||||
? workId.slice("session:".length)
|
||||
: null;
|
||||
@@ -828,6 +768,17 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
|| props.observationLayout.maximizedFloatingSourceId,
|
||||
);
|
||||
|
||||
if (viewMode === "report" && selectedCatalog) {
|
||||
return (
|
||||
<LaboratoryEvidenceReportView
|
||||
catalog={selectedCatalog}
|
||||
report={evidenceReport.report}
|
||||
loading={evidenceReport.loading}
|
||||
error={evidenceReport.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lab-archive-workspace"
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
JsonValue,
|
||||
LaboratoryEvidenceReport,
|
||||
} from "../../core/laboratory/evidenceReport";
|
||||
import {
|
||||
laboratoryTimestamp,
|
||||
type LaboratoryCatalogEntry,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
|
||||
const FIELD_LABELS: Readonly<Record<string, string>> = {
|
||||
acceptance: "Приёмка",
|
||||
accepted: "Принято",
|
||||
architecture: "Архитектура",
|
||||
authority: "Полномочия",
|
||||
byte_length: "Размер",
|
||||
calibration_model: "Модель калибровки",
|
||||
calibration_sha256: "SHA калибровки",
|
||||
camera_source_id: "Камера",
|
||||
checks: "Проверки",
|
||||
commands_enabled: "Команды разрешены",
|
||||
completeness: "Полнота",
|
||||
config_sha256: "SHA конфига",
|
||||
container_image: "Образ контейнера",
|
||||
core_capacity_fps: "Вычислительная ёмкость, FPS",
|
||||
core_path_p95_ms: "Core path p95, мс",
|
||||
created_at_utc: "Создано UTC",
|
||||
decision: "Решение",
|
||||
detector: "Детектор",
|
||||
execution_class: "Класс исполнения",
|
||||
frame_count: "Кадры",
|
||||
failed_frame_count: "Ошибки кадров",
|
||||
gpu_memory_used_mib: "GPU memory, MiB",
|
||||
gpu_name: "GPU",
|
||||
gpu_power_watts: "GPU power, W",
|
||||
gpu_temperature_celsius: "GPU temperature, °C",
|
||||
gpu_utilization_percent: "GPU utilization, %",
|
||||
ground_truth: "Ground truth",
|
||||
identity_sha256: "SHA identity",
|
||||
limitations: "Ограничения",
|
||||
metrics: "Метрики",
|
||||
model_sha256: "SHA модели",
|
||||
navigation_or_safety_accepted: "Допуск navigation/safety",
|
||||
next_action: "Следующее действие",
|
||||
pipeline_id: "Pipeline",
|
||||
preprocessing_contract: "Preprocessing contract",
|
||||
profile_sha256: "SHA профиля",
|
||||
provider_promoted: "Provider promoted",
|
||||
report_sha256: "SHA отчёта",
|
||||
resolution: "Разрешение",
|
||||
resources: "Ресурсы",
|
||||
result_id: "Result identity",
|
||||
runtime: "Runtime",
|
||||
schema_version: "Версия схемы",
|
||||
session_id: "Сессия",
|
||||
source: "Источник",
|
||||
configuration: "Конфигурация",
|
||||
status: "Статус",
|
||||
stream_sha256: "SHA потока",
|
||||
worker_host: "Worker host",
|
||||
};
|
||||
|
||||
const COMPLETENESS_LABELS: Readonly<Record<string, string>> = {
|
||||
identity: "Identity",
|
||||
source: "Источник",
|
||||
method: "Метод и модули",
|
||||
execution: "Runtime / worker",
|
||||
resources: "Нагрузка",
|
||||
metrics: "Метрики",
|
||||
gates: "Acceptance gates",
|
||||
decision: "Решение",
|
||||
limitations: "Ограничения",
|
||||
authority: "Полномочия",
|
||||
artifacts: "Артефакты",
|
||||
visual_evidence: "Визуал",
|
||||
};
|
||||
|
||||
function fieldLabel(value: string): string {
|
||||
return FIELD_LABELS[value] ?? value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function primitive(value: string | number | boolean | null): string {
|
||||
if (value === null) return "Не зафиксировано";
|
||||
if (typeof value === "boolean") return value ? "Да" : "Нет";
|
||||
if (typeof value === "number") {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: 6 });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function EvidenceValue({ value, depth = 0 }: { value: JsonValue; depth?: number }) {
|
||||
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
||||
return <span className="laboratory-evidence-report__value">{primitive(value as string | number | boolean | null)}</span>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) return <span className="laboratory-evidence-report__missing">Пустой список</span>;
|
||||
return (
|
||||
<ol className="laboratory-evidence-report__array">
|
||||
{value.map((item, index) => (
|
||||
<li key={index}><EvidenceValue value={item} depth={depth + 1} /></li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<dl className="laboratory-evidence-report__tree" data-depth={depth}>
|
||||
{Object.entries(value).map(([key, item]) => (
|
||||
<div key={key}>
|
||||
<dt>{fieldLabel(key)}</dt>
|
||||
<dd><EvidenceValue value={item} depth={depth + 1} /></dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportSection({
|
||||
eyebrow,
|
||||
title,
|
||||
value,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
value: JsonValue | undefined;
|
||||
}) {
|
||||
return (
|
||||
<section className="laboratory-evidence-report__section">
|
||||
<header>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<h3>{title}</h3>
|
||||
</header>
|
||||
{value === null || value === undefined ? (
|
||||
<p className="laboratory-evidence-report__missing">
|
||||
Не зафиксировано в immutable evidence этой лабораторной работы.
|
||||
</p>
|
||||
) : (
|
||||
<EvidenceValue value={value} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingReport({ catalog }: { catalog: LaboratoryCatalogEntry }) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Проверяем доказательства {catalog.variantName}</strong>
|
||||
<p>Сверяем identity, manifest и SHA-256 каждого опубликованного артефакта.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryEvidenceReportView({
|
||||
catalog,
|
||||
report,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
catalog: LaboratoryCatalogEntry;
|
||||
report: LaboratoryEvidenceReport | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (loading) return <LoadingReport catalog={catalog} />;
|
||||
if (!report) {
|
||||
return (
|
||||
<section className="laboratory-evidence-report laboratory-evidence-report--unavailable">
|
||||
<header className="laboratory-evidence-report__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ОТЧЁТ ВЫБРАННОЙ LAB · EVIDENCE IDENTITY</span>
|
||||
<h2>{catalog.variantName}</h2>
|
||||
<p>{catalog.evidenceId}</p>
|
||||
</div>
|
||||
<StatusBadge tone="warning">Неполный evidence contract</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-evidence-report__notice">
|
||||
<Icon name="database" size={20} />
|
||||
<div>
|
||||
<strong>Канонический доказательный JSON не опубликован</strong>
|
||||
<p>{error ?? "Для этой legacy LAB доступен визуал, но нет полного manifest/report контракта."}</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="laboratory-evidence-report__identity">
|
||||
<div><dt>LAB</dt><dd>{catalog.id}</dd></div>
|
||||
<div><dt>Evidence identity</dt><dd>{catalog.evidenceId}</dd></div>
|
||||
<div><dt>Дата</dt><dd>{laboratoryTimestamp(catalog.createdAtUtc)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const recorded = Object.values(report.completeness).filter((value) => value === "recorded").length;
|
||||
const total = Object.keys(report.completeness).length;
|
||||
return (
|
||||
<div className="laboratory-evidence-report">
|
||||
<header className="laboratory-evidence-report__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ОТЧЁТ ВЫБРАННОЙ LAB · IMMUTABLE EVIDENCE</span>
|
||||
<h2>{catalog.variantName}</h2>
|
||||
<p>{catalog.profileName} · {laboratoryTimestamp(catalog.createdAtUtc)}</p>
|
||||
</div>
|
||||
<StatusBadge tone={recorded === total ? "success" : "warning"}>
|
||||
{recorded}/{total} доказательных разделов
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<section className="laboratory-evidence-report__integrity">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЦЕЛОСТНОСТЬ И ПРОИСХОЖДЕНИЕ</span>
|
||||
<h3>Отчёт собран из проверенного manifest, а не из UI-копирайта</h3>
|
||||
</div>
|
||||
<StatusBadge tone={report.proof.artifactCount === report.proof.verifiedArtifactCount ? "success" : "warning"}>
|
||||
SHA-256 {report.proof.verifiedArtifactCount}/{report.proof.artifactCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl className="laboratory-evidence-report__identity">
|
||||
<div><dt>LAB work ID</dt><dd>{report.workId}</dd></div>
|
||||
<div><dt>Result identity</dt><dd>{report.resultId}</dd></div>
|
||||
<div><dt>Identity SHA-256</dt><dd>{report.proof.identitySha256}</dd></div>
|
||||
<div><dt>Report SHA-256</dt><dd>{report.proof.reportSha256 ?? "Отдельный report artifact не зафиксирован"}</dd></div>
|
||||
<div><dt>Manifest/document SHA-256</dt><dd>{report.proof.documentSha256}</dd></div>
|
||||
<div><dt>Schema</dt><dd>{report.proof.reportSchemaVersion ?? report.proof.documentSchemaVersion}</dd></div>
|
||||
</dl>
|
||||
<dl className="laboratory-evidence-report__completeness">
|
||||
{Object.entries(report.completeness).map(([key, state]) => (
|
||||
<div key={key} data-state={state}>
|
||||
<dt>{COMPLETENESS_LABELS[key] ?? fieldLabel(key)}</dt>
|
||||
<dd>{state === "recorded" ? "Зафиксировано" : "Не зафиксировано"}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<div className="laboratory-evidence-report__grid">
|
||||
<ReportSection eyebrow="SOURCE CONTRACT" title="Источник, калибровка и preprocessing" value={report.source} />
|
||||
<ReportSection eyebrow="RUN CONFIGURATION" title="Профиль, параметры и пороги запуска" value={report.configuration} />
|
||||
<ReportSection eyebrow="METHOD CONTRACT" title="Модули, модели, алгоритмы и их identity" value={report.method} />
|
||||
<ReportSection eyebrow="EXECUTION" title="Worker, runtime и фактическое исполнение" value={report.execution} />
|
||||
<ReportSection eyebrow="RESOURCE TELEMETRY" title="Нагрузка CPU / RAM / GPU" value={report.resources} />
|
||||
<ReportSection eyebrow="MEASUREMENTS" title="Измеренные показатели" value={report.metrics} />
|
||||
<ReportSection eyebrow="ACCEPTANCE" title="Пороги, проверки и результат gate" value={report.gates} />
|
||||
<ReportSection eyebrow="DECISION" title="Решение, границы вывода и следующий шаг" value={report.decision} />
|
||||
<ReportSection eyebrow="LIMITATIONS" title="Что эта LAB не доказывает" value={report.limitations} />
|
||||
<ReportSection eyebrow="AUTHORITY" title="Сохранённые запреты и полномочия" value={report.authority} />
|
||||
<ReportSection
|
||||
eyebrow="VISUAL EVIDENCE"
|
||||
title="Визуальная проверка и связанные артефакты"
|
||||
value={report.completeness.visual_evidence === "recorded" ? report.visualEvidence : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="laboratory-evidence-report__section laboratory-evidence-report__artifacts">
|
||||
<header>
|
||||
<span className="section-eyebrow">VERIFIED ARTIFACTS</span>
|
||||
<h3>Файлы доказательства, размер и полный SHA-256</h3>
|
||||
</header>
|
||||
{report.artifacts.length ? (
|
||||
<div className="laboratory-evidence-report__artifact-list">
|
||||
{report.artifacts.map((artifact) => (
|
||||
<article key={artifact.path}>
|
||||
<header>
|
||||
<strong>{artifact.kind ?? "artifact"}</strong>
|
||||
<StatusBadge tone="success">SHA verified</StatusBadge>
|
||||
</header>
|
||||
<p>{artifact.path}</p>
|
||||
<dl>
|
||||
<div><dt>Размер</dt><dd>{artifact.byteLength.toLocaleString("ru-RU")} байт</dd></div>
|
||||
<div><dt>SHA-256</dt><dd>{artifact.sha256}</dd></div>
|
||||
<div><dt>Schema / media</dt><dd>{artifact.schemaVersion ?? artifact.mediaType ?? "Не размечено"}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="laboratory-evidence-report__missing">Artifact manifest не зафиксирован.</p>}
|
||||
</section>
|
||||
|
||||
<section className="laboratory-evidence-report__section laboratory-evidence-report__canonical-json">
|
||||
<header>
|
||||
<span className="section-eyebrow">CANONICAL JSON · READ-ONLY</span>
|
||||
<h3>Полный нормализованный evidence-report без потери исходных полей</h3>
|
||||
</header>
|
||||
<pre>{JSON.stringify(report.canonicalJson, null, 2)}</pre>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,11 +31,13 @@ export type LaboratoryWorkId =
|
||||
export interface LaboratoryCatalogSeed {
|
||||
id: LaboratoryWorkId;
|
||||
createdAtUtc: string;
|
||||
evidenceId: string;
|
||||
}
|
||||
|
||||
export interface LaboratoryCatalogEntry {
|
||||
id: LaboratoryWorkId;
|
||||
createdAtUtc: string;
|
||||
evidenceId: string;
|
||||
profileId: LaboratoryProfileId;
|
||||
profileName: string;
|
||||
experimentId: LaboratoryExperimentId;
|
||||
@@ -369,18 +371,23 @@ export function buildLaboratoryCatalog({
|
||||
advancedIndex: readonly AdvancedLaboratoryIndexItem[];
|
||||
publishedWorks: readonly ObservationSessionSummary[];
|
||||
}): readonly LaboratoryCatalogEntry[] {
|
||||
const seeded = new Map<LaboratoryWorkId, string>();
|
||||
for (const work of knownWorks) seeded.set(work.id, work.createdAtUtc);
|
||||
for (const work of advancedIndex) seeded.set(work.workId, work.createdAtUtc);
|
||||
const seeded = new Map<LaboratoryWorkId, { createdAtUtc: string; evidenceId: string }>();
|
||||
for (const work of knownWorks) {
|
||||
seeded.set(work.id, { createdAtUtc: work.createdAtUtc, evidenceId: work.evidenceId });
|
||||
}
|
||||
for (const work of advancedIndex) {
|
||||
seeded.set(work.workId, { createdAtUtc: work.createdAtUtc, evidenceId: work.resultId });
|
||||
}
|
||||
|
||||
const entries: LaboratoryCatalogEntry[] = [];
|
||||
for (const [id, createdAtUtc] of seeded) {
|
||||
for (const [id, identity] of seeded) {
|
||||
if (id.startsWith("session:")) continue;
|
||||
const definition = KNOWN_WORKS[id as Exclude<LaboratoryWorkId, `session:${string}`>];
|
||||
if (!definition) continue;
|
||||
entries.push({
|
||||
id,
|
||||
createdAtUtc,
|
||||
createdAtUtc: identity.createdAtUtc,
|
||||
evidenceId: identity.evidenceId,
|
||||
profileId: definition.profileId,
|
||||
profileName: definition.profileName(rigLabel),
|
||||
experimentId: definition.experimentId,
|
||||
@@ -396,6 +403,7 @@ export function buildLaboratoryCatalog({
|
||||
entries.push({
|
||||
id: `session:${session.id}`,
|
||||
createdAtUtc: session.lab?.runCreatedAtUtc ?? session.startedAtUtc,
|
||||
evidenceId: session.lab?.sourceResultId ?? session.lab?.resultId ?? session.id,
|
||||
profileId,
|
||||
profileName: `${rig(rigLabel)} RIGHT · ${pipelineName}`,
|
||||
experimentId: `${profileId}:ravnoves00`,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type {
|
||||
LaboratoryValueReviewEntry,
|
||||
LaboratoryValueReviewIndex,
|
||||
LaboratoryValueLifecycle,
|
||||
LaboratoryValueSignal,
|
||||
LaboratoryVisualEvidence,
|
||||
} from "../../core/laboratory/valueReviewIndex";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
import type {
|
||||
LaboratoryCatalogEntry,
|
||||
LaboratoryExperimentId,
|
||||
LaboratoryProfileId,
|
||||
LaboratoryWorkId,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
|
||||
export type ProjectedLaboratorySignal = LaboratoryValueSignal | "unreviewed";
|
||||
|
||||
export interface ProjectedLaboratoryValueReview {
|
||||
catalog: LaboratoryCatalogEntry;
|
||||
signal: ProjectedLaboratorySignal;
|
||||
lifecycle: LaboratoryValueLifecycle;
|
||||
visualEvidence: LaboratoryVisualEvidence;
|
||||
}
|
||||
|
||||
function legacySessionReview(
|
||||
catalog: LaboratoryCatalogEntry,
|
||||
session: ObservationSessionSummary,
|
||||
): ProjectedLaboratoryValueReview {
|
||||
const passed = session.lab?.provenance.benchmark_passed;
|
||||
const signal: LaboratoryValueSignal = passed === true
|
||||
? "progress"
|
||||
: passed === false
|
||||
? "failed"
|
||||
: "retained";
|
||||
return {
|
||||
catalog,
|
||||
signal,
|
||||
lifecycle: "legacy",
|
||||
visualEvidence: "available",
|
||||
};
|
||||
}
|
||||
|
||||
function reviewedValue(
|
||||
catalog: LaboratoryCatalogEntry,
|
||||
review: LaboratoryValueReviewEntry,
|
||||
): ProjectedLaboratoryValueReview {
|
||||
return {
|
||||
catalog,
|
||||
signal: review.signal,
|
||||
lifecycle: review.lifecycle,
|
||||
visualEvidence: review.visualEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectLaboratoryValueReviews({
|
||||
catalog,
|
||||
index,
|
||||
publishedWorks,
|
||||
}: {
|
||||
catalog: readonly LaboratoryCatalogEntry[];
|
||||
index: LaboratoryValueReviewIndex | null;
|
||||
publishedWorks: readonly ObservationSessionSummary[];
|
||||
}): readonly ProjectedLaboratoryValueReview[] {
|
||||
const reviewed = new Map(index?.items.map((item) => [item.catalogId, item]));
|
||||
const sessions = new Map(publishedWorks.map((session) => [session.id, session]));
|
||||
return catalog.map((entry) => {
|
||||
const review = reviewed.get(entry.id);
|
||||
if (review?.evidenceId === entry.evidenceId) return reviewedValue(entry, review);
|
||||
if (entry.id.startsWith("session:")) {
|
||||
const session = sessions.get(entry.id.slice("session:".length));
|
||||
if (session) return legacySessionReview(entry, session);
|
||||
}
|
||||
return {
|
||||
catalog: entry,
|
||||
signal: "unreviewed",
|
||||
lifecycle: "current",
|
||||
visualEvidence: "partial",
|
||||
} satisfies ProjectedLaboratoryValueReview;
|
||||
});
|
||||
}
|
||||
|
||||
function statusMap(
|
||||
reviews: readonly ProjectedLaboratoryValueReview[],
|
||||
): ReadonlyMap<LaboratoryWorkId, ProjectedLaboratorySignal> {
|
||||
return new Map(reviews.map((review) => [review.catalog.id, review.signal]));
|
||||
}
|
||||
|
||||
export function profileOptionsWithSignals(
|
||||
options: readonly LaboratoryOption<LaboratoryProfileId>[],
|
||||
catalog: readonly LaboratoryCatalogEntry[],
|
||||
reviews: readonly ProjectedLaboratoryValueReview[],
|
||||
): readonly LaboratoryOption<LaboratoryProfileId>[] {
|
||||
const signals = statusMap(reviews);
|
||||
return options.map((option) => {
|
||||
const latest = catalog.find((entry) => entry.profileId === option.id);
|
||||
return { ...option, status: latest ? signals.get(latest.id) ?? "unreviewed" : "unreviewed" };
|
||||
});
|
||||
}
|
||||
|
||||
export function experimentOptionsWithSignals(
|
||||
options: readonly LaboratoryOption<LaboratoryExperimentId>[],
|
||||
profileId: LaboratoryProfileId,
|
||||
catalog: readonly LaboratoryCatalogEntry[],
|
||||
reviews: readonly ProjectedLaboratoryValueReview[],
|
||||
): readonly LaboratoryOption<LaboratoryExperimentId>[] {
|
||||
const signals = statusMap(reviews);
|
||||
return options.map((option) => {
|
||||
const latest = catalog.find((entry) => (
|
||||
entry.profileId === profileId && entry.experimentId === option.id
|
||||
));
|
||||
return { ...option, status: latest ? signals.get(latest.id) ?? "unreviewed" : "unreviewed" };
|
||||
});
|
||||
}
|
||||
|
||||
export function workOptionsWithSignals(
|
||||
options: readonly LaboratoryOption<LaboratoryWorkId>[],
|
||||
reviews: readonly ProjectedLaboratoryValueReview[],
|
||||
): readonly LaboratoryOption<LaboratoryWorkId>[] {
|
||||
const signals = statusMap(reviews);
|
||||
return options.map((option) => ({
|
||||
...option,
|
||||
status: signals.get(option.id) ?? "unreviewed",
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
LaboratoryMethod,
|
||||
LaboratoryMethodComponent,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
|
||||
export function digestFromContentId(value: string | null | undefined): string | null {
|
||||
const digest = value?.split("-").at(-1) ?? "";
|
||||
return /^[a-f0-9]{64}$/.test(digest) ? digest : null;
|
||||
}
|
||||
|
||||
export 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchLaboratoryEvidenceReport,
|
||||
type LaboratoryEvidenceReport,
|
||||
} from "../../core/laboratory/evidenceReport";
|
||||
|
||||
export function useLaboratoryEvidenceReport({
|
||||
workId,
|
||||
resultId,
|
||||
enabled,
|
||||
}: {
|
||||
workId: string;
|
||||
resultId: string;
|
||||
enabled: boolean;
|
||||
}): {
|
||||
report: LaboratoryEvidenceReport | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
} {
|
||||
const [report, setReport] = useState<LaboratoryEvidenceReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
setReport(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchLaboratoryEvidenceReport({ workId, resultId, signal: controller.signal })
|
||||
.then(setReport)
|
||||
.catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(caught instanceof Error ? caught.message : "Evidence-report LAB недоступен.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [enabled, resultId, workId]);
|
||||
|
||||
return { report, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchLaboratoryValueReviewIndex,
|
||||
type LaboratoryValueReviewIndex,
|
||||
} from "../../core/laboratory/valueReviewIndex";
|
||||
|
||||
export function useLaboratoryValueReviewIndex(): {
|
||||
index: LaboratoryValueReviewIndex | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
} {
|
||||
const [index, setIndex] = useState<LaboratoryValueReviewIndex | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchLaboratoryValueReviewIndex({ signal: controller.signal })
|
||||
.then(setIndex)
|
||||
.catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setIndex(null);
|
||||
setError(caught instanceof Error ? caught.message : "Value-review индекс LAB недоступен.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
return { index, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { LaboratoryViewAction } from "../contracts";
|
||||
|
||||
export type LaboratoryViewMode = "laboratory" | "report";
|
||||
|
||||
export function useLaboratoryViewMode(
|
||||
onActionChange: (action: LaboratoryViewAction | null) => void,
|
||||
): [LaboratoryViewMode, (next: LaboratoryViewMode) => void] {
|
||||
const [mode, setMode] = useState<LaboratoryViewMode>("laboratory");
|
||||
const toggle = useCallback(() => {
|
||||
setMode((current) => current === "laboratory" ? "report" : "laboratory");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onActionChange({
|
||||
label: mode === "laboratory" ? "Отчёт" : "Лабораторные контуры",
|
||||
onClick: toggle,
|
||||
});
|
||||
return () => onActionChange(null);
|
||||
}, [mode, onActionChange, toggle]);
|
||||
|
||||
return [mode, setMode];
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchLaboratoryValueReviewIndex;
|
||||
let fetchLaboratoryEvidenceReport;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({ server: { middlewareMode: true }, appType: "custom" });
|
||||
({ fetchLaboratoryValueReviewIndex } = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/valueReviewIndex.ts",
|
||||
));
|
||||
({ fetchLaboratoryEvidenceReport } = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/evidenceReport.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function payload(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-value-review-index/v1",
|
||||
reviewed_at_utc: "2026-08-05T08:30:00Z",
|
||||
items: [
|
||||
{
|
||||
catalog_id: "e46j-raw-fisheye-realtime",
|
||||
evidence_id: `e46j-raw-fisheye-realtime-${"a".repeat(64)}`,
|
||||
signal: "progress",
|
||||
lifecycle: "current",
|
||||
visual_evidence: "available",
|
||||
access: "read-only",
|
||||
},
|
||||
],
|
||||
access: "read-only",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("LAB value-review index preserves the reviewed evidence identity", async () => {
|
||||
const index = await fetchLaboratoryValueReviewIndex({
|
||||
fetcher: async () => new Response(JSON.stringify(payload()), { status: 200 }),
|
||||
});
|
||||
|
||||
assert.equal(index.items[0].catalogId, "e46j-raw-fisheye-realtime");
|
||||
assert.match(index.items[0].evidenceId, /^e46j-raw-fisheye-realtime-[a-f0-9]{64}$/);
|
||||
assert.equal(index.items[0].signal, "progress");
|
||||
});
|
||||
|
||||
test("LAB value-review index rejects extra fields instead of trusting presentation data", async () => {
|
||||
const document = payload();
|
||||
document.items[0] = { ...document.items[0], renderer: "local" };
|
||||
|
||||
await assert.rejects(
|
||||
fetchLaboratoryValueReviewIndex({
|
||||
fetcher: async () => new Response(JSON.stringify(document), { status: 200 }),
|
||||
}),
|
||||
/состав полей/,
|
||||
);
|
||||
});
|
||||
|
||||
test("selected LAB evidence report preserves proof, telemetry and canonical JSON", async () => {
|
||||
const workId = "e46j-raw-fisheye-realtime";
|
||||
const resultId = `e46j-raw-fisheye-realtime-${"b".repeat(64)}`;
|
||||
const report = await fetchLaboratoryEvidenceReport({
|
||||
workId,
|
||||
resultId,
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.laboratory-evidence-report/v1",
|
||||
work_id: workId,
|
||||
result_id: resultId,
|
||||
created_at_utc: "2026-08-04T19:37:51.841Z",
|
||||
access: "read-only",
|
||||
proof: {
|
||||
document_schema_version: "missioncore.e46j-result/v1",
|
||||
document_sha256: "c".repeat(64),
|
||||
identity_sha256: "b".repeat(64),
|
||||
report_schema_version: "missioncore.e46j-report/v1",
|
||||
report_sha256: "d".repeat(64),
|
||||
artifact_count: 1,
|
||||
verified_artifact_count: 1,
|
||||
},
|
||||
completeness: {
|
||||
identity: "recorded",
|
||||
source: "recorded",
|
||||
configuration: "recorded",
|
||||
method: "recorded",
|
||||
execution: "recorded",
|
||||
resources: "recorded",
|
||||
metrics: "recorded",
|
||||
gates: "recorded",
|
||||
decision: "recorded",
|
||||
limitations: "recorded",
|
||||
authority: "recorded",
|
||||
artifacts: "recorded",
|
||||
visual_evidence: "recorded",
|
||||
},
|
||||
identity: { profile_sha256: "e".repeat(64) },
|
||||
source: { frame_count: 4489, stream_sha256: "f".repeat(64) },
|
||||
configuration: { detector: { config_sha256: "8".repeat(64) } },
|
||||
method: { components: [{ kind: "model", identity_sha256: "a".repeat(64) }] },
|
||||
execution: { worker_host: "worker-006", gpu_name: "RTX 4090" },
|
||||
resources: { gpu_utilization_percent: { p95: 49 } },
|
||||
metrics: { core_capacity_fps: 47.84, core_path_p95_ms: 25.35 },
|
||||
gates: { passed: true },
|
||||
decision: { provider_promoted: false },
|
||||
limitations: ["No temporal identity."],
|
||||
authority: { commands_enabled: false },
|
||||
artifacts: [{
|
||||
kind: "visual-overlay-video",
|
||||
path: "overlay.mp4",
|
||||
byte_length: 150563706,
|
||||
sha256: "9".repeat(64),
|
||||
schema_version: null,
|
||||
media_type: "video/mp4",
|
||||
verified: true,
|
||||
}],
|
||||
visual_evidence: { review: { completed: true }, artifacts: [] },
|
||||
raw_report: { schema_version: "missioncore.e46j-report/v1", metrics: { frame_count: 4489 } },
|
||||
}), { status: 200 }),
|
||||
});
|
||||
|
||||
assert.equal(report.workId, workId);
|
||||
assert.equal(report.resultId, resultId);
|
||||
assert.equal(report.resources.gpu_utilization_percent.p95, 49);
|
||||
assert.equal(report.artifacts[0].verified, true);
|
||||
assert.equal(report.canonicalJson.raw_report.metrics.frame_count, 4489);
|
||||
});
|
||||
|
||||
test("LAB report UI uses the panel header contract and canonical controls", async () => {
|
||||
const root = new URL("../src/", import.meta.url);
|
||||
const [workspace, header, report, presentation, styles] = await Promise.all([
|
||||
readFile(new URL("workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", root), "utf8"),
|
||||
readFile(new URL("components/laboratory/useLaboratoryAnnotationHeader.tsx", root), "utf8"),
|
||||
readFile(new URL("workspaces/laboratory/LaboratoryEvidenceReportView.tsx", root), "utf8"),
|
||||
readFile(new URL("components/laboratory/LaboratoryPresentation.tsx", root), "utf8"),
|
||||
readFile(new URL("styles/laboratory-evidence-report.css", root), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(workspace, /useLaboratoryViewMode/);
|
||||
assert.match(workspace, /<LaboratoryEvidenceReportView/);
|
||||
assert.match(workspace, /selectedCatalog/);
|
||||
assert.match(header, /Отчёт|viewAction\.label/);
|
||||
assert.match(header, /<Button/);
|
||||
assert.doesNotMatch(header, /size="compact"[^>]*viewAction/);
|
||||
assert.match(report, /CANONICAL JSON/);
|
||||
assert.match(report, /verifiedArtifactCount/);
|
||||
assert.doesNotMatch(report, /Открыть LAB/);
|
||||
assert.match(presentation, /className="laboratory-status-dot"/);
|
||||
assert.doesNotMatch(presentation, /<Icon name="circle"/);
|
||||
assert.match(styles, /background: currentcolor/);
|
||||
assert.doesNotMatch(styles, /#[a-f0-9]{3,8}/i);
|
||||
});
|
||||
Reference in New Issue
Block a user