665 lines
25 KiB
TypeScript
665 lines
25 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { apiClient } from "../api/client";
|
||
import type {
|
||
AutoRunCaseSummary,
|
||
AutoRunDetailResponse,
|
||
AutoRunDialogResponse,
|
||
AutoRunDomainCoverage,
|
||
AutoRunHistoryResponse,
|
||
AutoRunSummary,
|
||
ConnectionState,
|
||
PromptState
|
||
} from "../state/types";
|
||
import { JsonView } from "./JsonView";
|
||
import { PanelFrame } from "./PanelFrame";
|
||
|
||
interface AutoRunsHistoryPanelProps {
|
||
connection: ConnectionState;
|
||
prompts: PromptState;
|
||
assistantPromptVersion: string;
|
||
decompositionPromptVersion: string;
|
||
onLog?: (message: string) => void;
|
||
}
|
||
|
||
type UseMockFilter = "any" | "true" | "false";
|
||
|
||
interface AutoRunsFilters {
|
||
fromLocal: string;
|
||
toLocal: string;
|
||
target: string;
|
||
mode: string;
|
||
useMock: UseMockFilter;
|
||
promptContains: string;
|
||
limit: number;
|
||
}
|
||
|
||
const DEFAULT_FILTERS: AutoRunsFilters = {
|
||
fromLocal: "",
|
||
toLocal: "",
|
||
target: "all",
|
||
mode: "all",
|
||
useMock: "any",
|
||
promptContains: "",
|
||
limit: 120
|
||
};
|
||
|
||
function dateToInputValue(date: Date): string {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
const hour = String(date.getHours()).padStart(2, "0");
|
||
const minute = String(date.getMinutes()).padStart(2, "0");
|
||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
||
}
|
||
|
||
function defaultFromDateValue(): string {
|
||
const date = new Date();
|
||
date.setDate(date.getDate() - 14);
|
||
return dateToInputValue(date);
|
||
}
|
||
|
||
function localInputToIso(value: string): string | undefined {
|
||
if (!value.trim()) return undefined;
|
||
const parsed = Date.parse(value);
|
||
if (!Number.isFinite(parsed)) return undefined;
|
||
return new Date(parsed).toISOString();
|
||
}
|
||
|
||
function formatDateTime(iso: string): string {
|
||
const parsed = Date.parse(iso);
|
||
if (!Number.isFinite(parsed)) return iso;
|
||
return new Date(parsed).toLocaleString("ru-RU");
|
||
}
|
||
|
||
function toPercent(closed: number, total: number): number {
|
||
if (total <= 0) return 0;
|
||
return Math.max(0, Math.min(100, Number(((closed / total) * 100).toFixed(1))));
|
||
}
|
||
|
||
function formatScore(value: number | null): string {
|
||
if (typeof value !== "number") return "n/a";
|
||
return `${value.toFixed(1)}%`;
|
||
}
|
||
|
||
function formatShortTarget(value: string): string {
|
||
if (value === "assistant_stage1") return "assistant/s1";
|
||
if (value === "assistant_stage2") return "assistant/s2";
|
||
if (value === "assistant_p0") return "assistant/p0";
|
||
return value;
|
||
}
|
||
|
||
function trendLabel(value: "up" | "down" | "flat"): string {
|
||
if (value === "up") return "Рост";
|
||
if (value === "down") return "Регресс";
|
||
return "Без изменений";
|
||
}
|
||
|
||
function getSelectedCase(cases: AutoRunCaseSummary[], caseId: string): AutoRunCaseSummary | null {
|
||
return cases.find((item) => item.case_id === caseId) ?? null;
|
||
}
|
||
|
||
function renderCoverageRows(items: AutoRunDomainCoverage[]) {
|
||
if (items.length === 0) {
|
||
return <p className="muted">Покрытие доменов пока не сформировано.</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="autoruns-coverage-list">
|
||
{items.map((item) => {
|
||
const percent = toPercent(item.closed_cases, item.total_cases);
|
||
return (
|
||
<div key={item.domain} className="autoruns-coverage-item">
|
||
<div className="autoruns-coverage-head">
|
||
<strong>{item.domain}</strong>
|
||
<span>
|
||
{item.closed_cases}/{item.total_cases} ({percent}%)
|
||
</span>
|
||
</div>
|
||
<div className="autoruns-coverage-bar">
|
||
<div style={{ width: `${percent}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AutoRunsHistoryPanel({
|
||
connection,
|
||
prompts,
|
||
assistantPromptVersion,
|
||
decompositionPromptVersion,
|
||
onLog
|
||
}: AutoRunsHistoryPanelProps) {
|
||
const [filters, setFilters] = useState<AutoRunsFilters>({
|
||
...DEFAULT_FILTERS,
|
||
fromLocal: defaultFromDateValue()
|
||
});
|
||
const [history, setHistory] = useState<AutoRunHistoryResponse | null>(null);
|
||
const [runDetail, setRunDetail] = useState<AutoRunDetailResponse | null>(null);
|
||
const [dialog, setDialog] = useState<AutoRunDialogResponse | null>(null);
|
||
const [selectedRunId, setSelectedRunId] = useState("");
|
||
const [selectedCaseId, setSelectedCaseId] = useState("");
|
||
const [historyBusy, setHistoryBusy] = useState(false);
|
||
const [detailBusy, setDetailBusy] = useState(false);
|
||
const [dialogBusy, setDialogBusy] = useState(false);
|
||
const [errorText, setErrorText] = useState("");
|
||
const [showAssistantMode, setShowAssistantMode] = useState(true);
|
||
const [showDecompositionMode, setShowDecompositionMode] = useState(true);
|
||
const [showProgressMode, setShowProgressMode] = useState(true);
|
||
|
||
const activeRunSummary: AutoRunSummary | null =
|
||
history?.items.find((item) => item.run_id === selectedRunId) ?? null;
|
||
const activeCase = runDetail ? getSelectedCase(runDetail.cases, selectedCaseId) : null;
|
||
|
||
const log = useCallback(
|
||
(message: string) => {
|
||
onLog?.(`[autoruns] ${message}`);
|
||
},
|
||
[onLog]
|
||
);
|
||
|
||
const loadCaseDialog = useCallback(
|
||
async (runId: string, caseId: string) => {
|
||
setDialogBusy(true);
|
||
try {
|
||
const payload = await apiClient.loadAutoRunCaseDialog(runId, caseId);
|
||
setDialog(payload);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setErrorText(`Диалог кейса: ${message}`);
|
||
log(`Dialog load error for ${runId}/${caseId}: ${message}`);
|
||
setDialog(null);
|
||
} finally {
|
||
setDialogBusy(false);
|
||
}
|
||
},
|
||
[log]
|
||
);
|
||
|
||
const loadRunDetail = useCallback(
|
||
async (runId: string, preferredCaseId?: string) => {
|
||
setDetailBusy(true);
|
||
try {
|
||
const payload = await apiClient.loadAutoRunDetail(runId);
|
||
setRunDetail(payload);
|
||
const nextCaseId =
|
||
(preferredCaseId && payload.cases.some((item) => item.case_id === preferredCaseId) ? preferredCaseId : "") ||
|
||
payload.cases[0]?.case_id ||
|
||
"";
|
||
setSelectedCaseId(nextCaseId);
|
||
if (nextCaseId) {
|
||
await loadCaseDialog(runId, nextCaseId);
|
||
} else {
|
||
setDialog(null);
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setErrorText(`Детализация прогона: ${message}`);
|
||
log(`Run detail load error for ${runId}: ${message}`);
|
||
setRunDetail(null);
|
||
setDialog(null);
|
||
} finally {
|
||
setDetailBusy(false);
|
||
}
|
||
},
|
||
[loadCaseDialog, log]
|
||
);
|
||
|
||
const loadHistory = useCallback(
|
||
async (options?: { keepSelection?: boolean; preferredRunId?: string; preferredCaseId?: string }) => {
|
||
setHistoryBusy(true);
|
||
setErrorText("");
|
||
try {
|
||
const payload = await apiClient.loadAutoRunsHistory({
|
||
from: localInputToIso(filters.fromLocal),
|
||
to: localInputToIso(filters.toLocal),
|
||
target: filters.target,
|
||
mode: filters.mode,
|
||
use_mock: filters.useMock,
|
||
prompt_contains: filters.promptContains.trim() || undefined,
|
||
limit: filters.limit
|
||
});
|
||
setHistory(payload);
|
||
const hasRuns = payload.items.length > 0;
|
||
if (!hasRuns) {
|
||
setSelectedRunId("");
|
||
setSelectedCaseId("");
|
||
setRunDetail(null);
|
||
setDialog(null);
|
||
return;
|
||
}
|
||
|
||
const keepSelection = options?.keepSelection ?? true;
|
||
const preferredRunId = options?.preferredRunId ?? "";
|
||
const preferredCaseId = options?.preferredCaseId ?? "";
|
||
const nextRunId =
|
||
keepSelection && preferredRunId && payload.items.some((item) => item.run_id === preferredRunId)
|
||
? preferredRunId
|
||
: payload.items[0].run_id;
|
||
setSelectedRunId(nextRunId);
|
||
await loadRunDetail(nextRunId, keepSelection ? preferredCaseId : undefined);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setErrorText(`История прогонов: ${message}`);
|
||
log(`History load error: ${message}`);
|
||
} finally {
|
||
setHistoryBusy(false);
|
||
}
|
||
},
|
||
[filters.fromLocal, filters.limit, filters.mode, filters.promptContains, filters.target, filters.toLocal, filters.useMock, loadRunDetail, log]
|
||
);
|
||
|
||
useEffect(() => {
|
||
void loadHistory({ keepSelection: false });
|
||
}, [loadHistory]);
|
||
|
||
const dynamicColumns = useMemo(() => {
|
||
const columns = ["minmax(290px, 340px)", "minmax(300px, 360px)", "minmax(420px, 1fr)"];
|
||
if (showAssistantMode) columns.push("minmax(280px, 320px)");
|
||
if (showDecompositionMode) columns.push("minmax(280px, 320px)");
|
||
if (showProgressMode) columns.push("minmax(280px, 320px)");
|
||
return columns.join(" ");
|
||
}, [showAssistantMode, showDecompositionMode, showProgressMode]);
|
||
|
||
return (
|
||
<PanelFrame
|
||
title="История автопрогонов"
|
||
subtitle="Центральный экран диагностики: фильтры, список прогонов, диалог по кейсу, режимы ассистента/декомпозиции и тренд качества."
|
||
actions={
|
||
<div className="assistant-panel-actions">
|
||
<button type="button" className={showAssistantMode ? "tab active" : "tab"} onClick={() => setShowAssistantMode((prev) => !prev)}>
|
||
Режим ассистента
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showDecompositionMode ? "tab active" : "tab"}
|
||
onClick={() => setShowDecompositionMode((prev) => !prev)}
|
||
>
|
||
Режим декомпозиции
|
||
</button>
|
||
<button type="button" className={showProgressMode ? "tab active" : "tab"} onClick={() => setShowProgressMode((prev) => !prev)}>
|
||
Прогресс/регресс
|
||
</button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="autoruns-columns" style={{ gridTemplateColumns: dynamicColumns }}>
|
||
<section className="autoruns-col">
|
||
<h3>Настройки выборки</h3>
|
||
<div className="autoruns-form-grid">
|
||
<label>
|
||
Дата с
|
||
<input
|
||
type="datetime-local"
|
||
value={filters.fromLocal}
|
||
onChange={(event) => setFilters((prev) => ({ ...prev, fromLocal: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Дата по
|
||
<input type="datetime-local" value={filters.toLocal} onChange={(event) => setFilters((prev) => ({ ...prev, toLocal: event.target.value }))} />
|
||
</label>
|
||
<label>
|
||
Целевой контур
|
||
<select value={filters.target} onChange={(event) => setFilters((prev) => ({ ...prev, target: event.target.value }))}>
|
||
<option value="all">all</option>
|
||
{(history?.available.targets ?? []).map((item) => (
|
||
<option key={item} value={item}>
|
||
{item}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Режим
|
||
<select value={filters.mode} onChange={(event) => setFilters((prev) => ({ ...prev, mode: event.target.value }))}>
|
||
<option value="all">all</option>
|
||
{(history?.available.modes ?? []).map((item) => (
|
||
<option key={item} value={item}>
|
||
{item}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
use_mock
|
||
<select value={filters.useMock} onChange={(event) => setFilters((prev) => ({ ...prev, useMock: event.target.value as UseMockFilter }))}>
|
||
<option value="any">any</option>
|
||
<option value="true">true</option>
|
||
<option value="false">false</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Лимит
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={500}
|
||
value={filters.limit}
|
||
onChange={(event) => setFilters((prev) => ({ ...prev, limit: Number(event.target.value || 120) }))}
|
||
/>
|
||
</label>
|
||
<label className="full-width">
|
||
Версия промпта содержит
|
||
<input
|
||
value={filters.promptContains}
|
||
onChange={(event) => setFilters((prev) => ({ ...prev, promptContains: event.target.value }))}
|
||
placeholder="normalizer_v2_0_2 / address_query_runtime_v1"
|
||
list="autoruns-prompt-versions"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<datalist id="autoruns-prompt-versions">
|
||
{(history?.available.prompt_versions ?? []).map((item) => (
|
||
<option key={item} value={item} />
|
||
))}
|
||
</datalist>
|
||
<div className="button-row">
|
||
<button type="button" disabled={historyBusy} onClick={() => void loadHistory({ keepSelection: false })}>
|
||
{historyBusy ? "Обновляю..." : "Применить"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="tab"
|
||
onClick={() => {
|
||
setFilters({
|
||
...DEFAULT_FILTERS,
|
||
fromLocal: defaultFromDateValue()
|
||
});
|
||
setErrorText("");
|
||
}}
|
||
>
|
||
Сбросить фильтры
|
||
</button>
|
||
</div>
|
||
|
||
<h4>Контур генерации</h4>
|
||
<div className="autoruns-meta-list">
|
||
<div>
|
||
<span>Провайдер:</span>
|
||
<strong>{connection.llmProvider}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Модель:</span>
|
||
<strong>{connection.model || "n/a"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Prompt assistant:</span>
|
||
<strong>{assistantPromptVersion}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Prompt decomposition:</span>
|
||
<strong>{decompositionPromptVersion}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<details className="autoruns-prompt-details">
|
||
<summary>Дублирование главного промпта (read-only)</summary>
|
||
<label>
|
||
System
|
||
<textarea readOnly value={prompts.systemPrompt} />
|
||
</label>
|
||
<label>
|
||
Developer
|
||
<textarea readOnly value={prompts.developerPrompt} />
|
||
</label>
|
||
<label>
|
||
Domain
|
||
<textarea readOnly value={prompts.domainPrompt} />
|
||
</label>
|
||
<label>
|
||
Schema notes
|
||
<textarea readOnly value={prompts.schemaNotes} />
|
||
</label>
|
||
<label>
|
||
Few-shot
|
||
<textarea readOnly value={prompts.fewShotExamples} />
|
||
</label>
|
||
</details>
|
||
|
||
{errorText ? <p className="error-text">{errorText}</p> : null}
|
||
</section>
|
||
|
||
<section className="autoruns-col">
|
||
<h3>Выдача прогонов</h3>
|
||
<div className="autoruns-stats-grid">
|
||
<div>
|
||
<span>Всего</span>
|
||
<strong>{history?.stats.runs_total ?? 0}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Средний score</span>
|
||
<strong>{formatScore(history?.stats.avg_score_index ?? null)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Тренд</span>
|
||
<strong>{history ? trendLabel(history.stats.trend) : "n/a"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Блокеры</span>
|
||
<strong>{history?.stats.blocking_runs ?? 0}</strong>
|
||
</div>
|
||
</div>
|
||
<div className="autoruns-run-list">
|
||
{(history?.items ?? []).map((run) => (
|
||
<button
|
||
key={run.run_id}
|
||
type="button"
|
||
className={selectedRunId === run.run_id ? "autoruns-run-item selected" : "autoruns-run-item"}
|
||
onClick={() => {
|
||
setSelectedRunId(run.run_id);
|
||
void loadRunDetail(run.run_id);
|
||
}}
|
||
>
|
||
<div className="autoruns-run-head">
|
||
<strong>{formatDateTime(run.run_timestamp)}</strong>
|
||
<span>{formatShortTarget(run.eval_target)}</span>
|
||
</div>
|
||
<div className="autoruns-run-meta">{run.run_id}</div>
|
||
<div className="autoruns-run-meta">
|
||
mode={run.mode ?? "n/a"} | mock={String(run.use_mock)}
|
||
</div>
|
||
{run.llm_provider || run.model ? (
|
||
<div className="autoruns-run-meta">
|
||
llm={run.llm_provider ?? "n/a"} | model={run.model ?? "n/a"}
|
||
</div>
|
||
) : null}
|
||
<div className="autoruns-run-meta">prompt={run.prompt_version ?? "n/a"}</div>
|
||
<div className="autoruns-run-foot">
|
||
<span>score: {formatScore(run.score_index)}</span>
|
||
<span>
|
||
closed/open: {run.closed_cases}/{run.open_cases}
|
||
</span>
|
||
</div>
|
||
<div className="autoruns-run-foot">
|
||
<span>blocking: {run.blocking_failures}</span>
|
||
<span>quality: {run.quality_failures}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
{(history?.items.length ?? 0) === 0 ? <p className="muted">За выбранный диапазон прогонов нет.</p> : null}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="autoruns-col">
|
||
<h3>Диалог прогона</h3>
|
||
<div className="autoruns-dialog-toolbar">
|
||
<label>
|
||
Прогон
|
||
<select
|
||
value={selectedRunId}
|
||
onChange={(event) => {
|
||
const nextRunId = event.target.value;
|
||
setSelectedRunId(nextRunId);
|
||
void loadRunDetail(nextRunId);
|
||
}}
|
||
>
|
||
{(history?.items ?? []).map((item) => (
|
||
<option key={item.run_id} value={item.run_id}>
|
||
{formatDateTime(item.run_timestamp)} | {item.run_id}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Кейc
|
||
<select
|
||
value={selectedCaseId}
|
||
onChange={(event) => {
|
||
const nextCaseId = event.target.value;
|
||
setSelectedCaseId(nextCaseId);
|
||
if (selectedRunId && nextCaseId) {
|
||
void loadCaseDialog(selectedRunId, nextCaseId);
|
||
}
|
||
}}
|
||
>
|
||
{(runDetail?.cases ?? []).map((item) => (
|
||
<option key={item.case_id} value={item.case_id}>
|
||
{item.case_id} | {item.status}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="autoruns-case-list">
|
||
{(runDetail?.cases ?? []).map((item) => (
|
||
<button
|
||
key={item.case_id}
|
||
type="button"
|
||
className={selectedCaseId === item.case_id ? "autoruns-case-item selected" : "autoruns-case-item"}
|
||
onClick={() => {
|
||
setSelectedCaseId(item.case_id);
|
||
if (selectedRunId) {
|
||
void loadCaseDialog(selectedRunId, item.case_id);
|
||
}
|
||
}}
|
||
>
|
||
<span>{item.case_id}</span>
|
||
<span>{item.status}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="autoruns-dialog-view">
|
||
{dialogBusy || detailBusy ? <p className="muted">Загружаю диалог...</p> : null}
|
||
{!dialogBusy && !detailBusy && (dialog?.messages.length ?? 0) === 0 ? <p className="muted">Диалог для этого кейса не найден.</p> : null}
|
||
{(dialog?.messages ?? []).map((item, index) => {
|
||
const role = item.role === "assistant" ? "assistant" : "user";
|
||
return (
|
||
<article key={`${role}-${index}`} className={`autoruns-msg ${role}`}>
|
||
<header>
|
||
<strong>{role === "assistant" ? "Система" : "Модель/вопрос"}</strong>
|
||
<span>{item.created_at ? formatDateTime(item.created_at) : "n/a"}</span>
|
||
</header>
|
||
<p>{item.text}</p>
|
||
{(item.trace_id || item.reply_type) && (
|
||
<footer>
|
||
{item.trace_id ? <span>trace={item.trace_id}</span> : null}
|
||
{item.reply_type ? <span>reply_type={item.reply_type}</span> : null}
|
||
</footer>
|
||
)}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
{showAssistantMode ? (
|
||
<section className="autoruns-col">
|
||
<h3>Режим ассистента</h3>
|
||
<div className="autoruns-meta-list">
|
||
<div>
|
||
<span>source:</span>
|
||
<strong>{dialog?.source ?? "n/a"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>session:</span>
|
||
<strong>{dialog?.session_id ?? "n/a"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>run target:</span>
|
||
<strong>{activeRunSummary?.eval_target ?? "n/a"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>run score:</span>
|
||
<strong>{formatScore(activeRunSummary?.score_index ?? null)}</strong>
|
||
</div>
|
||
</div>
|
||
<h4>Assistant mode payload</h4>
|
||
<JsonView value={dialog?.assistant_mode ?? { note: "assistant_mode unavailable" }} />
|
||
<h4 style={{ marginTop: 12 }}>Case checks</h4>
|
||
<JsonView value={activeCase?.checks ?? { note: "checks unavailable" }} />
|
||
<h4 style={{ marginTop: 12 }}>Metric subscores</h4>
|
||
<JsonView value={activeCase?.metric_subscores ?? { note: "metric_subscores unavailable" }} />
|
||
</section>
|
||
) : null}
|
||
|
||
{showDecompositionMode ? (
|
||
<section className="autoruns-col">
|
||
<h3>Режим декомпозиции</h3>
|
||
<div className="autoruns-meta-list">
|
||
<div>
|
||
<span>Case:</span>
|
||
<strong>{activeCase?.case_id ?? "n/a"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Domain:</span>
|
||
<strong>{activeCase?.domain ?? "n/a"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Query class:</span>
|
||
<strong>{activeCase?.query_class ?? "n/a"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Trace:</span>
|
||
<strong>{activeCase?.trace_id ?? "n/a"}</strong>
|
||
</div>
|
||
</div>
|
||
<h4>Шаги декомпозиции</h4>
|
||
{(dialog?.decomposition.length ?? 0) > 0 ? (
|
||
<ol className="autoruns-decomposition-list">
|
||
{(dialog?.decomposition ?? []).map((item, index) => (
|
||
<li key={`${index}-${item.slice(0, 24)}`}>{item}</li>
|
||
))}
|
||
</ol>
|
||
) : (
|
||
<p className="muted">В логах кейса нет явной декомпозиции.</p>
|
||
)}
|
||
</section>
|
||
) : null}
|
||
|
||
{showProgressMode ? (
|
||
<section className="autoruns-col">
|
||
<h3>Прогресс / регресс</h3>
|
||
<div className="autoruns-stats-grid">
|
||
<div>
|
||
<span>Latest score</span>
|
||
<strong>{formatScore(history?.stats.latest_score_index ?? null)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Previous</span>
|
||
<strong>{formatScore(history?.stats.previous_score_index ?? null)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Trend</span>
|
||
<strong>{history ? trendLabel(history.stats.trend) : "n/a"}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Quality gaps</span>
|
||
<strong>{history?.stats.quality_gap_runs ?? 0}</strong>
|
||
</div>
|
||
</div>
|
||
<h4>Покрытие доменов (история)</h4>
|
||
{renderCoverageRows(history?.stats.domain_coverage ?? [])}
|
||
<h4 style={{ marginTop: 14 }}>Покрытие доменов (выбранный прогон)</h4>
|
||
{renderCoverageRows(runDetail?.coverage.domain_coverage ?? [])}
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
</PanelFrame>
|
||
);
|
||
}
|