NODEDC_1C/llm_normalizer/frontend/src/components/AutoRunsHistoryPanel.tsx

665 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}