feat(lab): add lazy E40 evidence review
This commit is contained in:
@@ -460,7 +460,7 @@ export function E30EvidencePointCloud({ detail }: E30EvidencePointCloudProps) {
|
||||
</span>
|
||||
<span data-point="rejected">Отклонено · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
Проверяемый кластер E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,7 +206,7 @@ export function E30EvidenceProjection({
|
||||
</span>
|
||||
<span data-point="rejected">Кандидаты · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
Проверяемый кластер E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation";
|
||||
import {
|
||||
isAdvancedLaboratoryWorkId,
|
||||
type AdvancedLaboratoryIndexItem,
|
||||
type AdvancedLaboratoryWorkId,
|
||||
} from "../../core/laboratory/advancedIndex";
|
||||
import type { AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
import type { WorkspaceRendererProps } from "../contracts";
|
||||
@@ -15,97 +20,29 @@ import { E39Result } from "./E39Result";
|
||||
import { E40Result } from "./E40Result";
|
||||
import { RecordedReplayEvidence } from "./RecordedReplayEvidence";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "e31-source-binding"
|
||||
| "e32-track-geometry"
|
||||
| "e33-worker-shadow"
|
||||
| "e34-temporal-layer"
|
||||
| "e35-degradation-recovery"
|
||||
| "e37-ravnoves-acceptance"
|
||||
| "e38-perception-baseline"
|
||||
| "e39-perception-refinement"
|
||||
| "e40-perception-product-gate";
|
||||
export { isAdvancedLaboratoryWorkId };
|
||||
export type { AdvancedLaboratoryWorkId };
|
||||
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
|
||||
export function isAdvancedLaboratoryWorkId(
|
||||
value: string,
|
||||
): value is AdvancedLaboratoryWorkId {
|
||||
return (
|
||||
value === "e31-source-binding"
|
||||
|| value === "e32-track-geometry"
|
||||
|| value === "e33-worker-shadow"
|
||||
|| value === "e34-temporal-layer"
|
||||
|| value === "e35-degradation-recovery"
|
||||
|| value === "e37-ravnoves-acceptance"
|
||||
|| value === "e38-perception-baseline"
|
||||
|| value === "e39-perception-refinement"
|
||||
|| value === "e40-perception-product-gate"
|
||||
);
|
||||
}
|
||||
|
||||
export function advancedLaboratoryWorkOptions(
|
||||
results: AdvancedLaboratoryResults,
|
||||
sourceSessions: ReadonlyMap<string, ObservationSessionSummary>,
|
||||
index: readonly AdvancedLaboratoryIndexItem[],
|
||||
): readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] {
|
||||
const options: LaboratoryOption<AdvancedLaboratoryWorkId>[] = [];
|
||||
if (results.e31 && sourceSessions.has(results.e31.sourceSessionId)) {
|
||||
options.push({
|
||||
id: "e31-source-binding",
|
||||
label: "LAB E31 · source binding",
|
||||
});
|
||||
}
|
||||
if (results.e32 && sourceSessions.has(results.e32.sourceSessionId)) {
|
||||
options.push({
|
||||
id: "e32-track-geometry",
|
||||
label: "LAB E32 · TrackGeometry v1",
|
||||
});
|
||||
}
|
||||
if (results.e33 && sourceSessions.has(results.e33.sourceSessionId)) {
|
||||
options.push({
|
||||
id: "e33-worker-shadow",
|
||||
label: "LAB E33 · worker shadow 1×",
|
||||
});
|
||||
}
|
||||
if (results.e34) {
|
||||
options.push({
|
||||
id: "e34-temporal-layer",
|
||||
label: "LAB E34 · temporal occupied/unknown",
|
||||
});
|
||||
}
|
||||
if (results.e35) {
|
||||
options.push({
|
||||
id: "e35-degradation-recovery",
|
||||
label: "LAB E35 · degradation recovery",
|
||||
});
|
||||
}
|
||||
if (results.e37) {
|
||||
options.push({
|
||||
id: "e37-ravnoves-acceptance",
|
||||
label: "LAB E37 · RAVNOVES00 acceptance R0",
|
||||
});
|
||||
}
|
||||
if (results.e38) {
|
||||
options.push({
|
||||
id: "e38-perception-baseline",
|
||||
label: "LAB E38 · perception quality R1",
|
||||
});
|
||||
}
|
||||
if (results.e39) {
|
||||
options.push({
|
||||
id: "e39-perception-refinement",
|
||||
label: "LAB E39 · perception refinement R1",
|
||||
});
|
||||
}
|
||||
if (results.e40) {
|
||||
options.push({
|
||||
id: "e40-perception-product-gate",
|
||||
label: "LAB E40 · leakage-resistant product gate",
|
||||
});
|
||||
}
|
||||
return options;
|
||||
const available = new Set(index.map(({ workId }) => workId));
|
||||
const options: readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] = [
|
||||
{ id: "e31-source-binding", label: "LAB E31 · source binding" },
|
||||
{ id: "e32-track-geometry", label: "LAB E32 · TrackGeometry v1" },
|
||||
{ id: "e33-worker-shadow", label: "LAB E33 · worker shadow 1×" },
|
||||
{ id: "e34-temporal-layer", label: "LAB E34 · temporal occupied/unknown" },
|
||||
{ id: "e35-degradation-recovery", label: "LAB E35 · degradation recovery" },
|
||||
{ id: "e37-ravnoves-acceptance", label: "LAB E37 · RAVNOVES00 acceptance R0" },
|
||||
{ id: "e38-perception-baseline", label: "LAB E38 · perception quality R1" },
|
||||
{ id: "e39-perception-refinement", label: "LAB E39 · perception refinement R1" },
|
||||
{ id: "e40-perception-product-gate", label: "LAB E40 · leakage-resistant product gate" },
|
||||
];
|
||||
return options.filter(({ id }) => available.has(id));
|
||||
}
|
||||
|
||||
export function advancedLaboratorySourceSession(
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchE30ReviewItemDetail,
|
||||
type E30ReviewItemDetail,
|
||||
} from "../../core/laboratory/e30Review";
|
||||
import {
|
||||
fetchE40CaseCatalog,
|
||||
fetchE40OperatorReview,
|
||||
saveE40OperatorVerdict,
|
||||
type E40CaseCatalog,
|
||||
type E40CaseDimension,
|
||||
type E40CaseSeverity,
|
||||
type E40CaseState,
|
||||
type E40CaseStratum,
|
||||
type E40ErrorCase,
|
||||
type E40OperatorReview,
|
||||
type E40OperatorVerdict,
|
||||
} from "../../core/laboratory/e40CaseReview";
|
||||
import { E30EvidencePointCloud } from "../E30EvidencePointCloud";
|
||||
import { E30EvidenceProjection } from "../E30EvidenceProjection";
|
||||
|
||||
type EvidenceMode = "camera" | "3d";
|
||||
|
||||
const SEVERITY_LABELS: Record<E40CaseSeverity, string> = {
|
||||
high: "Высокий приоритет",
|
||||
medium: "Средний приоритет",
|
||||
standard: "Стандартный приоритет",
|
||||
};
|
||||
|
||||
const STRATUM_LABELS: Record<E40CaseStratum, string> = {
|
||||
agree: "согласованные источники",
|
||||
"camera-only": "только камера",
|
||||
conflict: "конфликт источников",
|
||||
"geometry-only": "только геометрия",
|
||||
unknown: "неопределённый источник",
|
||||
};
|
||||
|
||||
const DIMENSION_LABELS: Record<E40CaseDimension, string> = {
|
||||
presence: "Присутствие",
|
||||
geometry_association: "Связь с геометрией",
|
||||
freshness: "Актуальность",
|
||||
};
|
||||
|
||||
const VALUE_LABELS: Readonly<Record<string, string>> = {
|
||||
"background-or-noise": "Фон или шум",
|
||||
"object-present": "Объект присутствует",
|
||||
"occupied-environment": "Занятая среда",
|
||||
"independent-occupied": "Независимая геометрия",
|
||||
"insufficient-support": "Недостаточно опоры",
|
||||
"object-associated": "Связано с объектом",
|
||||
"rejected-nonobject": "Не объект",
|
||||
unknown: "Не определено",
|
||||
current: "Актуально",
|
||||
stale: "Устарело",
|
||||
unavailable: "Недоступно",
|
||||
};
|
||||
|
||||
function stateValue(
|
||||
state: E40CaseState,
|
||||
dimension: E40CaseDimension,
|
||||
): string {
|
||||
if (dimension === "presence") return state.presence;
|
||||
if (dimension === "geometry_association") {
|
||||
return state.geometryAssociation;
|
||||
}
|
||||
return state.freshness;
|
||||
}
|
||||
|
||||
function caseLabel(item: E40ErrorCase): string {
|
||||
const dimensions = item.mismatchedDimensions
|
||||
.map((dimension) => DIMENSION_LABELS[dimension])
|
||||
.join(" + ");
|
||||
return `${SEVERITY_LABELS[item.severity]} · кадр ${item.sourceFrameIndex} · ${dimensions}`;
|
||||
}
|
||||
|
||||
function reviewPrompt(
|
||||
item: E40ErrorCase,
|
||||
detail: E30ReviewItemDetail,
|
||||
): {
|
||||
question: string;
|
||||
evidence: string;
|
||||
confirm: string;
|
||||
reject: string;
|
||||
} {
|
||||
const hasSelectedPoints = detail.selected.pointsMapXyzM.length > 0;
|
||||
const subject = hasSelectedPoints
|
||||
? "Белый LiDAR-кластер"
|
||||
: "Выделенная область";
|
||||
const evidence = hasSelectedPoints
|
||||
? detail.locatorKind === "geometry-only-cluster"
|
||||
? (
|
||||
"Белые точки — самостоятельный кластер занятой геометрии E29 без "
|
||||
+ "класса. Это проверяемая LiDAR-опора, а не отметки ошибки."
|
||||
)
|
||||
: (
|
||||
"Белые точки — LiDAR-опора, которую E29 связал с наблюдением. "
|
||||
+ "Это проверяемые исходные точки, а не отметки ошибки."
|
||||
)
|
||||
: (
|
||||
"Белого LiDAR-кластера в этом кейсе нет: решение принимается по "
|
||||
+ "кадру и рамке наблюдения."
|
||||
);
|
||||
|
||||
if (item.reference.presence === "object-present") {
|
||||
return {
|
||||
question: `${subject} относится к отдельному видимому объекту?`,
|
||||
evidence,
|
||||
confirm: (
|
||||
"Да — объект виден; эталон верен, расхождение E40 подтверждается."
|
||||
),
|
||||
reject: (
|
||||
"Нет — это фон или часть среды; предполагаемая ошибка отклоняется."
|
||||
),
|
||||
};
|
||||
}
|
||||
if (item.reference.presence === "background-or-noise") {
|
||||
return {
|
||||
question: `${subject} является фоном или шумом, а не объектом?`,
|
||||
evidence,
|
||||
confirm: (
|
||||
"Да — это фон или шум; эталон верен, расхождение E40 подтверждается."
|
||||
),
|
||||
reject: (
|
||||
"Нет — отдельный объект есть; предполагаемая ошибка отклоняется."
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
question: "Проверяемый эталон точнее описывает кадр, чем решение E40?",
|
||||
evidence,
|
||||
confirm: "Да — эталон верен, расхождение E40 подтверждается.",
|
||||
reject: "Нет — эталон спорен, предполагаемая ошибка отклоняется.",
|
||||
};
|
||||
}
|
||||
|
||||
export function E40CaseReview({ resultId }: { resultId: string }) {
|
||||
const [catalog, setCatalog] = useState<E40CaseCatalog | null>(null);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<E30ReviewItemDetail | null>(null);
|
||||
const [review, setReview] = useState<E40OperatorReview | null>(null);
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [verdictPending, setVerdictPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reviewError, setReviewError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<EvidenceMode>("camera");
|
||||
const [pointLayerVisible, setPointLayerVisible] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setCatalogLoading(true);
|
||||
setCatalog(null);
|
||||
setDetail(null);
|
||||
setReview(null);
|
||||
setSelectedItemId(null);
|
||||
setError(null);
|
||||
setReviewError(null);
|
||||
void Promise.all([
|
||||
fetchE40CaseCatalog(resultId, { signal: controller.signal }),
|
||||
fetchE40OperatorReview(resultId, { signal: controller.signal }),
|
||||
]).then(([nextCatalog, nextReview]) => {
|
||||
if (
|
||||
nextReview.materializationId !== nextCatalog.materializationId
|
||||
|| nextReview.reviewedItemCount > nextCatalog.total
|
||||
) {
|
||||
throw new Error(
|
||||
"Операторская проверка E40 не совпадает с каталогом ошибок.",
|
||||
);
|
||||
}
|
||||
setCatalog(nextCatalog);
|
||||
setReview(nextReview);
|
||||
setSelectedItemId(nextCatalog.items[0]?.itemId ?? null);
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(
|
||||
caught instanceof Error
|
||||
? caught.message
|
||||
: "Case-review E40 недоступен.",
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setCatalogLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalog || !selectedItemId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
setError(null);
|
||||
void fetchE30ReviewItemDetail(
|
||||
catalog.materializationId,
|
||||
selectedItemId,
|
||||
{ signal: controller.signal },
|
||||
).then((next) => {
|
||||
setDetail(next);
|
||||
setMode(next.cameraFrame ? "camera" : "3d");
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(
|
||||
caught instanceof Error
|
||||
? caught.message
|
||||
: "Визуальное доказательство кейса недоступно.",
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setDetailLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [catalog, selectedItemId]);
|
||||
|
||||
const selectedCase = catalog?.items.find(
|
||||
(item) => item.itemId === selectedItemId,
|
||||
) ?? null;
|
||||
const selectedIndex = selectedCase && catalog
|
||||
? catalog.items.findIndex((item) => item.itemId === selectedCase.itemId)
|
||||
: -1;
|
||||
const selectedDecision = selectedCase
|
||||
? review?.decisions.find(
|
||||
(decision) => decision.itemId === selectedCase.itemId,
|
||||
) ?? null
|
||||
: null;
|
||||
const prompt = selectedCase && detail
|
||||
? reviewPrompt(selectedCase, detail)
|
||||
: null;
|
||||
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
if (!catalog?.items.length || selectedIndex < 0) return;
|
||||
const index = (
|
||||
selectedIndex + offset + catalog.items.length
|
||||
) % catalog.items.length;
|
||||
setSelectedItemId(catalog.items[index].itemId);
|
||||
};
|
||||
|
||||
const saveVerdict = async (verdict: E40OperatorVerdict) => {
|
||||
if (!review || !selectedCase || verdictPending) return;
|
||||
setVerdictPending(true);
|
||||
setReviewError(null);
|
||||
try {
|
||||
setReview(await saveE40OperatorVerdict(
|
||||
resultId,
|
||||
selectedCase.itemId,
|
||||
{
|
||||
expectedRevision: review.revision,
|
||||
idempotencyKey: `ui-${crypto.randomUUID()}`,
|
||||
verdict,
|
||||
},
|
||||
));
|
||||
} catch (caught) {
|
||||
setReviewError(
|
||||
caught instanceof Error
|
||||
? caught.message
|
||||
: "Решение оператора не сохранено.",
|
||||
);
|
||||
} finally {
|
||||
setVerdictPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (catalogLoading) {
|
||||
return (
|
||||
<div className="e40-case-review__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Проверяем индекс исторической видимой оценки E40</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !selectedCase) {
|
||||
return (
|
||||
<div className="e40-case-review__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!catalog?.items.length) {
|
||||
return (
|
||||
<div className="e40-case-review__state" role="status">
|
||||
<Icon name="check" size={18} />
|
||||
<span>Расхождений исторической оценки E40 не опубликовано.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="e40-case-review">
|
||||
<header className="e40-case-review__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
CASE REVIEW · HISTORICAL VISIBLE EVALUATION
|
||||
</span>
|
||||
<strong>
|
||||
Визуальная проверка {catalog.items.length} из {catalog.total}{" "}
|
||||
расхождений
|
||||
</strong>
|
||||
<small>
|
||||
Загружается только выбранный camera/LiDAR-кейс. Проверено
|
||||
оператором: {review?.reviewedItemCount ?? 0} из {catalog.total}.
|
||||
</small>
|
||||
</div>
|
||||
{selectedCase ? (
|
||||
<StatusBadge
|
||||
tone={selectedCase.severity === "high"
|
||||
? "danger"
|
||||
: selectedCase.severity === "medium"
|
||||
? "warning"
|
||||
: "neutral"}
|
||||
>
|
||||
{SEVERITY_LABELS[selectedCase.severity]}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="e40-case-review__viewer">
|
||||
{detailLoading ? (
|
||||
<div className="e40-case-review__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Проверяем точный кадр и LiDAR-геометрию</span>
|
||||
</div>
|
||||
) : error || !detail || !selectedCase ? (
|
||||
<div className="e40-case-review__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Выберите ошибку для проверки."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="Визуальная проверка расхождения E40"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "camera", label: "Камера" },
|
||||
{ value: "3d", label: "3D" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={(
|
||||
<div className="e40-case-review__controls">
|
||||
<div
|
||||
className="e40-case-review__pagination"
|
||||
aria-label="Последовательная проверка ошибок"
|
||||
>
|
||||
<IconButton
|
||||
className="e40-case-review__glass-button"
|
||||
label="Предыдущее расхождение"
|
||||
disabled={catalog.items.length < 2}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="e40-case-review__glass-button"
|
||||
label="Следующее расхождение"
|
||||
disabled={catalog.items.length < 2}
|
||||
onClick={() => navigate(1)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
className="e40-case-review__select"
|
||||
menuClassName="e40-case-review__select-menu"
|
||||
label="Расхождение исторической оценки E40"
|
||||
value={selectedCase.itemId}
|
||||
options={catalog.items.map((item) => ({
|
||||
value: item.itemId,
|
||||
label: caseLabel(item),
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={setSelectedItemId}
|
||||
/>
|
||||
{mode === "camera" ? (
|
||||
<Button
|
||||
className="e40-case-review__layer-button"
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
shape="pill"
|
||||
icon={<Icon name="sliders" size={16} />}
|
||||
data-active={pointLayerVisible ? "true" : undefined}
|
||||
aria-pressed={pointLayerVisible}
|
||||
onClick={() => setPointLayerVisible((visible) => !visible)}
|
||||
>
|
||||
LiDAR
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
overlay={(
|
||||
<aside
|
||||
className="e40-case-review__telemetry"
|
||||
aria-label="Эталон и результат E40"
|
||||
>
|
||||
<div className="e40-case-review__telemetry-content">
|
||||
<span>
|
||||
Кадр {detail.sourceFrameIndex}
|
||||
{" · "}
|
||||
{STRATUM_LABELS[selectedCase.sourceStratum]}
|
||||
{selectedCase.predictionBasis === "camera-only-softmax"
|
||||
? (
|
||||
<>
|
||||
{" · "}
|
||||
вероятность camera-only softmax{" "}
|
||||
{(selectedCase.presenceConfidence * 100).toLocaleString(
|
||||
"ru-RU",
|
||||
{ maximumFractionDigits: 1 },
|
||||
)}%
|
||||
</>
|
||||
)
|
||||
: " · фиксированная stratum-policy, не вероятность"}
|
||||
</span>
|
||||
{prompt ? (
|
||||
<div className="e40-case-review__question">
|
||||
<span>ЧТО ПРОВЕРЯЕМ</span>
|
||||
<strong>{prompt.question}</strong>
|
||||
<small>{prompt.evidence}</small>
|
||||
</div>
|
||||
) : null}
|
||||
<dl>
|
||||
{selectedCase.mismatchedDimensions.map((dimension) => (
|
||||
<div key={dimension}>
|
||||
<dt>{DIMENSION_LABELS[dimension]}</dt>
|
||||
<dd>
|
||||
<span>
|
||||
Эталон ·{" "}
|
||||
{VALUE_LABELS[
|
||||
stateValue(selectedCase.reference, dimension)
|
||||
]}
|
||||
</span>
|
||||
<strong>
|
||||
{selectedCase.predictionBasis
|
||||
=== "camera-only-softmax"
|
||||
? "Модель E40"
|
||||
: "Правило E40"}{" "}
|
||||
·{" "}
|
||||
{VALUE_LABELS[
|
||||
stateValue(selectedCase.prediction, dimension)
|
||||
]}
|
||||
</strong>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
<div className="e40-case-review__verdict">
|
||||
<span>
|
||||
{verdictPending
|
||||
? "Сохраняем…"
|
||||
: selectedDecision?.verdict === "confirmed-error"
|
||||
? "Ошибка подтверждена"
|
||||
: selectedDecision?.verdict === "rejected-error"
|
||||
? "Расхождение отклонено"
|
||||
: "Решение оператора"}
|
||||
</span>
|
||||
<div
|
||||
className="e40-case-review__verdict-buttons"
|
||||
aria-label="Решение оператора"
|
||||
>
|
||||
<IconButton
|
||||
className="e40-case-review__verdict-button"
|
||||
data-verdict="confirmed"
|
||||
data-active={
|
||||
selectedDecision?.verdict === "confirmed-error"
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
label={prompt?.confirm ?? "Подтвердить ошибку модели"}
|
||||
aria-pressed={
|
||||
selectedDecision?.verdict === "confirmed-error"
|
||||
}
|
||||
disabled={verdictPending}
|
||||
onClick={() => void saveVerdict("confirmed-error")}
|
||||
>
|
||||
<Icon name="check" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="e40-case-review__verdict-button"
|
||||
data-verdict="rejected"
|
||||
data-active={
|
||||
selectedDecision?.verdict === "rejected-error"
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
label={prompt?.reject ?? "Отклонить ошибку модели"}
|
||||
aria-pressed={
|
||||
selectedDecision?.verdict === "rejected-error"
|
||||
}
|
||||
disabled={verdictPending}
|
||||
onClick={() => void saveVerdict("rejected-error")}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{prompt ? (
|
||||
<div className="e40-case-review__verdict-guide">
|
||||
<span data-verdict="confirmed">
|
||||
<Icon name="check" size={12} />
|
||||
{prompt.confirm}
|
||||
</span>
|
||||
<span data-verdict="rejected">
|
||||
<Icon name="close" size={12} />
|
||||
{prompt.reject}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<small className="e40-case-review__commit-note">
|
||||
Выбор сохраняется сразу; исходные E30/E40 не изменяются.
|
||||
</small>
|
||||
{reviewError ? (
|
||||
<small
|
||||
className="e40-case-review__verdict-error"
|
||||
role="alert"
|
||||
>
|
||||
{reviewError}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
>
|
||||
{mode === "camera" ? (
|
||||
<E30EvidenceProjection
|
||||
detail={detail}
|
||||
projectionWidth={detail.materialization.projectionWidth}
|
||||
projectionHeight={detail.materialization.projectionHeight}
|
||||
pointLayerVisible={pointLayerVisible}
|
||||
/>
|
||||
) : (
|
||||
<E30EvidencePointCloud detail={detail} />
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryMetricGrid,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
@@ -9,6 +8,7 @@ import type {
|
||||
E40PerceptionProductGateResult,
|
||||
} from "../../core/laboratory/e40ProductGate";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E40CaseReview } from "./E40CaseReview";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", {
|
||||
@@ -32,14 +32,14 @@ export function E40Result({
|
||||
result.developmentCrossValidation.protocols.wholeTrackOrSceneWindow.dimensions
|
||||
);
|
||||
const gateLabel = result.qualityGatePassed
|
||||
? "RAVNOVES00 product gate пройден"
|
||||
: "RAVNOVES00 product gate не пройден";
|
||||
? "Историческая оценка E40 пройдена"
|
||||
: "Историческая оценка E40 не пройдена";
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E40 · leakage-resistant product gate"
|
||||
description="После E39 проверена route-coordinate-free модель: смежные кадры, целые track и scene-window изолированы при development-проверке, а sealed validation исполнен один раз на Worker 006."
|
||||
title="LAB E40 · historical visible engineering evaluation"
|
||||
description="Историческая source-scoped оценка E40: camera-only softmax и фиксированная stratum-policy измерены на уже видимом инженерном контракте RAVNOVES00. Это не operational camera→LiDAR pipeline и не blind validation."
|
||||
status={gateLabel}
|
||||
statusTone={result.qualityGatePassed ? "success" : "warning"}
|
||||
facts={[
|
||||
@@ -53,7 +53,7 @@ export function E40Result({
|
||||
},
|
||||
{
|
||||
label: "Validation",
|
||||
value: `${formatNumber(metrics.validationItems, 0)} · sealed`,
|
||||
value: `${formatNumber(metrics.validationItems, 0)} · historical visible`,
|
||||
},
|
||||
{
|
||||
label: "Исполнение",
|
||||
@@ -61,10 +61,10 @@ export function E40Result({
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Удерживает ли perception выбранного сенсорного рига не менее 90% по presence, geometry association и freshness после удаления route-coordinate leakage и группировки зависимых кадров?",
|
||||
approach: "Для 340 development-кейсов зафиксированы 125 признаков без frame index, session time, review ordinal, track ID и абсолютных map-координат. Консервативная stratum-policy и camera-only softmax прошли два групповых пятифолдовых протокола; validation labels при выборе и обучении не использовались.",
|
||||
principalResult: `Development: contiguous presence ${percent(contiguous.presence.accuracy)}, grouped presence ${percent(grouped.presence.accuracy)}. Sealed validation: presence ${percent(dimensions.presence.accuracy)}, geometry ${percent(dimensions.geometryAssociation.accuracy)}, freshness ${percent(dimensions.freshness.accuracy)}.`,
|
||||
limitation: "Это source-scoped доказательство только для RAVNOVES00 на инженерно проверенной разметке. Оно не доказывает перенос на другой маршрут, растительность, камеру, риг или живой ровер и не выдаёт навигационных либо safety-полномочий.",
|
||||
question: "Как camera-only softmax и фиксированная политика страт согласуются с уже видимым инженерным контрактом RAVNOVES00?",
|
||||
approach: "Для 340 development-кейсов зафиксированы 125 признаков без frame index, session time, review ordinal, track ID и абсолютных map-координат. Только camera-only использует softmax; agree, conflict, geometry-only и unknown получают детерминированные policy-state, а не вероятность operational detector.",
|
||||
principalResult: `Development: contiguous presence ${percent(contiguous.presence.accuracy)}, grouped presence ${percent(grouped.presence.accuracy)}. Historical visible evaluation: presence ${percent(dimensions.presence.accuracy)}, geometry ${percent(dimensions.geometryAssociation.accuracy)}, freshness ${percent(dimensions.freshness.accuracy)}.`,
|
||||
limitation: "E40 — историческая source-scoped инженерная оценка поверх E29/E30, а не сама camera-first perception. Она не является blind validation и не доказывает независимую точность, перенос, навигацию или safety.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
@@ -74,7 +74,7 @@ export function E40Result({
|
||||
{
|
||||
kind: "source",
|
||||
name: "E37 frozen acceptance contract",
|
||||
version: `${formatNumber(metrics.developmentItems, 0)} development + ${formatNumber(metrics.validationItems, 0)} sealed validation`,
|
||||
version: `${formatNumber(metrics.developmentItems, 0)} development + ${formatNumber(metrics.validationItems, 0)} historical visible evaluation`,
|
||||
role: "неизменяемый denominator и reference-метрики трёх измерений",
|
||||
identitySha256: null,
|
||||
},
|
||||
@@ -96,7 +96,7 @@ export function E40Result({
|
||||
kind: "runtime",
|
||||
name: "Worker 006",
|
||||
version: result.workerNode,
|
||||
role: "одно sealed evaluation в package-bound контейнере без командных полномочий",
|
||||
role: "одно historical visible evaluation в package-bound контейнере без командных полномочий",
|
||||
identitySha256: null,
|
||||
},
|
||||
],
|
||||
@@ -105,34 +105,11 @@ export function E40Result({
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="GROUPED DEVELOPMENT → SEALED VALIDATION"
|
||||
eyebrow="GROUPED DEVELOPMENT → HISTORICAL VISIBLE EVALUATION"
|
||||
title="Устойчивость без route-coordinate leakage"
|
||||
kind="diagnostic-model"
|
||||
>
|
||||
<LaboratoryMetricGrid
|
||||
metrics={[
|
||||
{
|
||||
label: "Contiguous time CV",
|
||||
value: percent(contiguous.presence.accuracy),
|
||||
hint: `presence · geometry ${percent(contiguous.geometryAssociation.accuracy)}`,
|
||||
},
|
||||
{
|
||||
label: "Whole track / scene CV",
|
||||
value: percent(grouped.presence.accuracy),
|
||||
hint: `presence · geometry ${percent(grouped.geometryAssociation.accuracy)}`,
|
||||
},
|
||||
{
|
||||
label: "Sealed presence",
|
||||
value: percent(dimensions.presence.accuracy),
|
||||
hint: `${formatNumber(dimensions.presence.correct, 0)} / ${formatNumber(dimensions.presence.total, 0)} · цель 90%`,
|
||||
},
|
||||
{
|
||||
label: "Sealed freshness",
|
||||
value: percent(dimensions.freshness.accuracy),
|
||||
hint: `${formatNumber(dimensions.freshness.correct, 0)} / ${formatNumber(dimensions.freshness.total, 0)} · цель 90%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<E40CaseReview resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
@@ -167,7 +144,7 @@ export function E40Result({
|
||||
conclusion={{
|
||||
proved: result.qualityGatePassed
|
||||
? "На неизменяемом RAVNOVES00 все три task-level dimension достигли 90%, полный denominator учтён, ложное свободное пространство не опубликовано и high-severity ошибок нет."
|
||||
: `Development-профиль устойчив к двум зависимым разбиениям; sealed evaluation завершён с полным учётом ${formatNumber(metrics.validationItems, 0)} кейсов и без ложного свободного пространства.`,
|
||||
: `Development-профиль устойчив к двум зависимым разбиениям; historical visible evaluation завершён с полным учётом ${formatNumber(metrics.validationItems, 0)} кейсов и без ложного свободного пространства.`,
|
||||
notProved: "Не доказаны независимая физическая ground truth, второй маршрут, другой риг, растительная среда, живой rover runtime, навигация, команды или safety.",
|
||||
decision: result.qualityGatePassed
|
||||
? "Зафиксировать RAVNOVES00 perception gate как закрытый source-scoped этап. Следующий шаг — temporal product state и live-rover replay без расширения полномочий."
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
type E30ReviewResult,
|
||||
} from "../../core/laboratory/e30Review";
|
||||
import {
|
||||
fetchAdvancedLaboratoryResults,
|
||||
type AdvancedLaboratoryResults,
|
||||
} from "../../core/laboratory/advancedResults";
|
||||
import {
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
e28LaboratoryBrief, e29LaboratoryBrief,
|
||||
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
|
||||
} from "./laboratoryArchiveBriefs";
|
||||
import { useAdvancedLaboratoryCatalog } from "./useAdvancedLaboratoryCatalog";
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
@@ -63,18 +63,6 @@ type LaboratoryWorkId =
|
||||
| AdvancedLaboratoryWorkId
|
||||
| `session:${string}`;
|
||||
|
||||
const EMPTY_ADVANCED_RESULTS: AdvancedLaboratoryResults = {
|
||||
e31: null,
|
||||
e32: null,
|
||||
e33: null,
|
||||
e34: null,
|
||||
e35: null,
|
||||
e37: null,
|
||||
e38: null,
|
||||
e39: null,
|
||||
e40: null,
|
||||
};
|
||||
|
||||
function laboratoryWorkOrdinal(value: string): number {
|
||||
const match = value.match(/\bE(\d+)\b/i);
|
||||
return match ? Number(match[1]) : -1;
|
||||
@@ -572,13 +560,11 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||
const [advancedResults, setAdvancedResults] = useState(
|
||||
EMPTY_ADVANCED_RESULTS,
|
||||
);
|
||||
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
||||
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit: 100,
|
||||
pollingEnabled: false,
|
||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||
onReplayBegin: props.sessionArchive.onReplayBegin,
|
||||
onReplayAccepted: props.sessionArchive.onReplayAccepted,
|
||||
@@ -603,6 +589,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
() => new Map(sessions.items.map((session) => [session.id, session])),
|
||||
[sessions.items],
|
||||
);
|
||||
const advanced = useAdvancedLaboratoryCatalog({
|
||||
selectedWorkId: workId,
|
||||
onResultLoaded: (nextWorkId, result) => {
|
||||
const sourceSession = advancedLaboratorySourceSession(
|
||||
nextWorkId,
|
||||
result,
|
||||
sourceSessions,
|
||||
);
|
||||
if (sourceSession) void sessions.replay(sourceSession.id);
|
||||
},
|
||||
});
|
||||
const advancedResults: AdvancedLaboratoryResults = advanced.results;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -612,22 +610,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
fetchLidarLocalSurfaces({ signal: controller.signal }),
|
||||
fetchE29EvidenceCatalog({ signal: controller.signal }),
|
||||
fetchE30ReviewCatalog({ signal: controller.signal }),
|
||||
fetchAdvancedLaboratoryResults({ signal: controller.signal }),
|
||||
]).then(([e28, e29, e30, advanced]) => {
|
||||
]).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;
|
||||
const nextAdvanced = advanced.status === "fulfilled" ? advanced.value : null;
|
||||
setE28Model(nextE28);
|
||||
setE29Result(nextE29);
|
||||
setE30Result(nextE30);
|
||||
setAdvancedResults(nextAdvanced ?? EMPTY_ADVANCED_RESULTS);
|
||||
const failures = [
|
||||
e28.status === "rejected" ? "E28" : null,
|
||||
e29.status === "rejected" ? "E29" : null,
|
||||
e30.status === "rejected" ? "E30" : null,
|
||||
advanced.status === "rejected" ? "E31–E40" : null,
|
||||
].filter(Boolean);
|
||||
setEvidenceError(
|
||||
failures.length
|
||||
@@ -673,19 +667,15 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
label: "LAB E30 · evidence review A2",
|
||||
});
|
||||
}
|
||||
items.push(...advancedLaboratoryWorkOptions(
|
||||
advancedResults,
|
||||
sourceSessions,
|
||||
));
|
||||
items.push(...advancedLaboratoryWorkOptions(advanced.index));
|
||||
return items.sort(
|
||||
(left, right) => laboratoryWorkOrdinal(right.label) - laboratoryWorkOrdinal(left.label),
|
||||
);
|
||||
}, [
|
||||
advancedResults,
|
||||
advanced.index,
|
||||
e28Model,
|
||||
e29Result,
|
||||
e30Result,
|
||||
sourceSessions,
|
||||
]);
|
||||
const profiles = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
|
||||
@@ -726,6 +716,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
useEffect(() => {
|
||||
if (
|
||||
evidenceLoading
|
||||
|| advanced.indexLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) return;
|
||||
@@ -761,6 +752,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
}
|
||||
}, [
|
||||
evidenceLoading,
|
||||
advanced.indexLoading,
|
||||
profileId,
|
||||
profiles,
|
||||
publishedWorks,
|
||||
@@ -813,6 +805,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
|
||||
if (
|
||||
evidenceLoading
|
||||
|| advanced.indexLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) {
|
||||
@@ -831,7 +824,10 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Подтверждённых лабораторных работ нет</strong>
|
||||
<p>
|
||||
{evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||
{evidenceError
|
||||
?? advanced.indexError
|
||||
?? sessions.error
|
||||
?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -953,6 +949,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
result={e30Result}
|
||||
sourceSession={e30SourceSession}
|
||||
/>
|
||||
) : advanced.loadingWorkId === workId ? (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Подготавливаем выбранную лабораторную работу</strong>
|
||||
<p>Сервер проверяет только её доказательства и связанные артефакты.</p>
|
||||
</div>
|
||||
) : advanced.failedWorkId === workId ? (
|
||||
<div className="laboratory-result-pending">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Работа не прошла ревизию</strong>
|
||||
<p>{advanced.resultError}</p>
|
||||
</div>
|
||||
) : isAdvancedLaboratoryWorkId(workId) ? (
|
||||
<AdvancedLaboratoryResult
|
||||
props={props}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
advancedLaboratoryResultAvailable,
|
||||
emptyAdvancedLaboratoryResults,
|
||||
fetchAdvancedLaboratoryIndex,
|
||||
fetchAdvancedLaboratoryResult,
|
||||
isAdvancedLaboratoryWorkId,
|
||||
type AdvancedLaboratoryIndexItem,
|
||||
type AdvancedLaboratoryWorkId,
|
||||
} from "../../core/laboratory/advancedIndex";
|
||||
import type {
|
||||
AdvancedLaboratoryResults,
|
||||
} from "../../core/laboratory/advancedResults";
|
||||
|
||||
function mergeResults(
|
||||
current: AdvancedLaboratoryResults,
|
||||
next: AdvancedLaboratoryResults,
|
||||
): AdvancedLaboratoryResults {
|
||||
return {
|
||||
e31: next.e31 ?? current.e31,
|
||||
e32: next.e32 ?? current.e32,
|
||||
e33: next.e33 ?? current.e33,
|
||||
e34: next.e34 ?? current.e34,
|
||||
e35: next.e35 ?? current.e35,
|
||||
e37: next.e37 ?? current.e37,
|
||||
e38: next.e38 ?? current.e38,
|
||||
e39: next.e39 ?? current.e39,
|
||||
e40: next.e40 ?? current.e40,
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Выбранная лабораторная работа не прошла серверную проверку.";
|
||||
}
|
||||
|
||||
export function useAdvancedLaboratoryCatalog({
|
||||
selectedWorkId,
|
||||
onResultLoaded,
|
||||
}: {
|
||||
selectedWorkId: string;
|
||||
onResultLoaded?: (
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
result: AdvancedLaboratoryResults,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const [index, setIndex] = useState<readonly AdvancedLaboratoryIndexItem[]>([]);
|
||||
const [indexLoading, setIndexLoading] = useState(true);
|
||||
const [indexError, setIndexError] = useState<string | null>(null);
|
||||
const [results, setResults] = useState<AdvancedLaboratoryResults>(
|
||||
emptyAdvancedLaboratoryResults,
|
||||
);
|
||||
const [loadingWorkId, setLoadingWorkId] =
|
||||
useState<AdvancedLaboratoryWorkId | null>(null);
|
||||
const [failedWorkId, setFailedWorkId] =
|
||||
useState<AdvancedLaboratoryWorkId | null>(null);
|
||||
const [resultError, setResultError] = useState<string | null>(null);
|
||||
const onResultLoadedRef = useRef(onResultLoaded);
|
||||
onResultLoadedRef.current = onResultLoaded;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setIndexLoading(true);
|
||||
setIndexError(null);
|
||||
void fetchAdvancedLaboratoryIndex({ signal: controller.signal })
|
||||
.then((items) => {
|
||||
if (!controller.signal.aborted) setIndex(items);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setIndex([]);
|
||||
setIndexError(errorMessage(error));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIndexLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isAdvancedLaboratoryWorkId(selectedWorkId)
|
||||
|| advancedLaboratoryResultAvailable(selectedWorkId, results)
|
||||
) return;
|
||||
const controller = new AbortController();
|
||||
setLoadingWorkId(selectedWorkId);
|
||||
setFailedWorkId(null);
|
||||
setResultError(null);
|
||||
void fetchAdvancedLaboratoryResult(selectedWorkId, {
|
||||
signal: controller.signal,
|
||||
}).then(async (next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setResults((current) => mergeResults(current, next));
|
||||
await onResultLoadedRef.current?.(selectedWorkId, next);
|
||||
}).catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setFailedWorkId(selectedWorkId);
|
||||
setResultError(errorMessage(error));
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoadingWorkId(null);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [results, selectedWorkId]);
|
||||
|
||||
return {
|
||||
index,
|
||||
indexLoading,
|
||||
indexError,
|
||||
results,
|
||||
loadingWorkId,
|
||||
failedWorkId,
|
||||
resultError,
|
||||
} as const;
|
||||
}
|
||||
Reference in New Issue
Block a user