АДРЕСНЫЙ РЕЖИМ - авторан история - базовая версия

This commit is contained in:
2026-04-09 12:34:10 +03:00
parent df29798fa2
commit edfa09c9af
31 changed files with 5261 additions and 2392 deletions
+22 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { apiClient } from "./api/client";
import { AutoRunsHistoryPanel } from "./components/AutoRunsHistoryPanel";
import { AssistantPanel } from "./components/AssistantPanel";
import { ConnectionPanel } from "./components/ConnectionPanel";
import { HistoryPanel } from "./components/HistoryPanel";
@@ -22,7 +23,7 @@ import type {
} from "./state/types";
const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1";
const ASSISTANT_STAGES = ["Разбираю запрос", "Ищу данные", "Собираю ответ"];
const ASSISTANT_STAGES = ["Analyzing request", "Fetching data", "Composing answer"];
const DEFAULT_UI_MODE: UiMode = "assistant";
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
@@ -508,12 +509,12 @@ export default function App() {
});
setAssistantSessionId(response.session_id);
setAssistantConversation(response.conversation);
setAssistantStatus("Ответ готов");
setAssistantStatus("Reply is ready");
log(`Assistant reply received: trace=${response.debug.trace_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantError(message);
setAssistantStatus("Ошибка ассистента");
setAssistantStatus("Assistant error");
log(`Assistant error: ${message}`);
} finally {
stopTicker();
@@ -536,15 +537,18 @@ export default function App() {
<main className="app-root">
<div className="hero">
<h1>NDC AI First Layer</h1>
<p>Два режима в одном интерфейсе: диагностика декомпозиции и диалоговый ассистент на общем backend-контуре.</p>
<p>Three modes in one UI: assistant, decomposition diagnostics, and auto-run history with regression visibility.</p>
</div>
<div className="mode-switch-row">
<button type="button" className={uiMode === "assistant" ? "tab active" : "tab"} onClick={() => setUiMode("assistant")}>
Ассистент
Assistant
</button>
<button type="button" className={uiMode === "decomposition" ? "tab active" : "tab"} onClick={() => setUiMode("decomposition")}>
Декомпозиция
Decomposition
</button>
<button type="button" className={uiMode === "autoruns" ? "tab active" : "tab"} onClick={() => setUiMode("autoruns")}>
AutoRun History
</button>
</div>
@@ -595,7 +599,7 @@ export default function App() {
errorMessage={assistantError}
/>
</div>
) : (
) : uiMode === "decomposition" ? (
<div className="layout-grid">
<ConnectionPanel
value={connection}
@@ -655,7 +659,18 @@ export default function App() {
evalReport={evalReport}
/>
</div>
) : (
<div className="layout-grid">
<AutoRunsHistoryPanel
connection={connection}
prompts={prompts}
assistantPromptVersion={ASSISTANT_PROMPT_VERSION}
decompositionPromptVersion={AUTOLOAD_PROMPT_VERSION}
onLog={log}
/>
</div>
)}
</main>
);
}
+34
View File
@@ -1,4 +1,7 @@
import type {
AutoRunDetailResponse,
AutoRunDialogResponse,
AutoRunHistoryResponse,
AssistantMessageResultState,
AssistantConversationItem,
ConnectionState,
@@ -247,5 +250,36 @@ export const apiClient = {
async loadAssistantSession(sessionId: string): Promise<{ ok: boolean; session: { items: AssistantConversationItem[] } }> {
return request(`/assistant/session/${sessionId}`);
},
async loadAutoRunsHistory(input?: {
from?: string;
to?: string;
target?: string;
mode?: string;
use_mock?: "any" | "true" | "false";
prompt_contains?: string;
limit?: number;
scan_limit?: number;
}): Promise<AutoRunHistoryResponse> {
const params = new URLSearchParams();
if (input?.from) params.set("from", input.from);
if (input?.to) params.set("to", input.to);
if (input?.target) params.set("target", input.target);
if (input?.mode) params.set("mode", input.mode);
if (input?.use_mock) params.set("use_mock", input.use_mock);
if (input?.prompt_contains) params.set("prompt_contains", input.prompt_contains);
if (typeof input?.limit === "number") params.set("limit", String(input.limit));
if (typeof input?.scan_limit === "number") params.set("scan_limit", String(input.scan_limit));
const query = params.toString();
return request(`/autoruns/history${query ? `?${query}` : ""}`);
},
async loadAutoRunDetail(runId: string): Promise<AutoRunDetailResponse> {
return request(`/autoruns/history/${encodeURIComponent(runId)}`);
},
async loadAutoRunCaseDialog(runId: string, caseId: string): Promise<AutoRunDialogResponse> {
return request(`/autoruns/history/${encodeURIComponent(runId)}/case/${encodeURIComponent(caseId)}/dialog`);
}
};
@@ -0,0 +1,664 @@
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>
);
}
+113 -1
View File
@@ -66,7 +66,119 @@ export interface RuntimeRun {
updatedAt: string;
}
export type UiMode = "assistant" | "decomposition";
export type UiMode = "assistant" | "decomposition" | "autoruns";
export type AutoRunTarget = "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0" | "unknown";
export type AutoRunTrend = "up" | "down" | "flat";
export interface AutoRunDomainCoverage {
domain: string;
total_cases: number;
closed_cases: number;
}
export interface AutoRunSummary {
run_id: string;
eval_target: AutoRunTarget;
run_timestamp: string;
mode: string | null;
llm_provider: string | null;
model: string | null;
use_mock: boolean | null;
prompt_version: string | null;
schema_version: string | null;
suite_id: string | null;
cases_total: number;
requests_total: number | null;
report_path: string;
score_index: number | null;
blocking_failures: number;
quality_failures: number;
closed_cases: number;
open_cases: number;
domain_coverage: AutoRunDomainCoverage[];
}
export interface AutoRunCaseSummary {
case_id: string;
domain: string | null;
query_class: string | null;
status: "closed" | "open" | "unknown";
score_index: number | null;
trace_id: string | null;
reply_type: string | null;
session_id: string;
dialog_available: boolean;
checks: Record<string, unknown> | null;
metric_subscores: Record<string, unknown> | null;
}
export interface AutoRunCoverage {
closed_cases: number;
open_cases: number;
domain_coverage: AutoRunDomainCoverage[];
}
export interface AutoRunHistoryStats {
runs_total: number;
by_target: Record<string, number>;
blocking_runs: number;
quality_gap_runs: number;
avg_score_index: number | null;
latest_score_index: number | null;
previous_score_index: number | null;
trend: AutoRunTrend;
domain_coverage: AutoRunDomainCoverage[];
}
export interface AutoRunHistoryResponse {
ok: boolean;
generated_at: string;
filters_applied: {
from: string | null;
to: string | null;
target: AutoRunTarget | "all";
use_mock: boolean | null;
prompt_contains: string;
mode: string;
limit: number;
scan_limit: number;
};
available: {
targets: AutoRunTarget[];
modes: string[];
prompt_versions: string[];
};
items: AutoRunSummary[];
stats: AutoRunHistoryStats;
}
export interface AutoRunDetailResponse {
ok: boolean;
run: AutoRunSummary;
coverage: AutoRunCoverage;
cases: AutoRunCaseSummary[];
report: Record<string, unknown>;
}
export interface AutoRunDialogMessage {
role: string;
text: string;
created_at: string | null;
trace_id: string | null;
reply_type: string | null;
}
export interface AutoRunDialogResponse {
ok: boolean;
run_id: string;
case_id: string;
source: "assistant_session" | "report_fallback" | "none";
session_id: string;
messages: AutoRunDialogMessage[];
decomposition: string[];
assistant_mode: Record<string, unknown> | null;
}
export type AssistantFallbackType = "none" | "out_of_scope" | "clarification" | "partial" | "unknown";
export type AssistantReplyType =
+259
View File
@@ -454,10 +454,263 @@ button:disabled {
font-size: 0.84rem;
}
.autoruns-columns {
display: grid;
gap: 12px;
}
.autoruns-col {
border: 1px solid rgba(157, 255, 190, 0.2);
border-radius: 14px;
background: rgba(8, 13, 10, 0.72);
padding: 12px;
min-height: 220px;
}
.autoruns-col h3 {
margin: 0 0 10px;
font-size: 0.95rem;
}
.autoruns-col h4 {
margin: 12px 0 8px;
font-size: 0.82rem;
color: var(--text-muted);
}
.autoruns-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.autoruns-meta-list {
display: grid;
gap: 8px;
}
.autoruns-meta-list > div {
display: flex;
justify-content: space-between;
gap: 8px;
border: 1px solid rgba(157, 255, 190, 0.14);
border-radius: 10px;
background: rgba(10, 18, 13, 0.65);
padding: 8px 9px;
font-size: 0.79rem;
}
.autoruns-meta-list span {
color: var(--text-muted);
}
.autoruns-prompt-details summary {
cursor: pointer;
color: var(--lime-main);
font-size: 0.8rem;
margin-bottom: 8px;
}
.autoruns-prompt-details textarea {
min-height: 68px;
}
.autoruns-stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.autoruns-stats-grid > div {
border: 1px solid rgba(157, 255, 190, 0.2);
border-radius: 10px;
background: rgba(10, 18, 13, 0.7);
padding: 8px;
display: grid;
gap: 3px;
}
.autoruns-stats-grid span {
color: var(--text-muted);
font-size: 0.74rem;
}
.autoruns-stats-grid strong {
color: var(--lime-main);
font-size: 0.84rem;
}
.autoruns-run-list {
display: grid;
gap: 8px;
max-height: 760px;
overflow: auto;
padding-right: 2px;
}
.autoruns-run-item {
width: 100%;
text-align: left;
border-radius: 12px;
border: 1px solid rgba(157, 255, 190, 0.23);
background: rgba(11, 18, 14, 0.75);
color: var(--text-main);
padding: 10px;
display: grid;
gap: 5px;
}
.autoruns-run-item.selected {
border-color: var(--line-strong);
}
.autoruns-run-head,
.autoruns-run-foot {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.76rem;
}
.autoruns-run-meta {
color: var(--text-muted);
font-size: 0.75rem;
word-break: break-word;
}
.autoruns-dialog-toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.autoruns-case-list {
margin-top: 8px;
display: grid;
gap: 6px;
max-height: 170px;
overflow: auto;
}
.autoruns-case-item {
width: 100%;
text-align: left;
border-radius: 10px;
border: 1px solid rgba(157, 255, 190, 0.22);
background: rgba(9, 14, 11, 0.72);
color: var(--text-main);
padding: 7px 8px;
display: flex;
justify-content: space-between;
gap: 6px;
font-size: 0.76rem;
}
.autoruns-case-item.selected {
border-color: var(--line-strong);
}
.autoruns-dialog-view {
margin-top: 10px;
border: 1px solid rgba(157, 255, 190, 0.2);
border-radius: 12px;
background: rgba(5, 8, 6, 0.7);
padding: 10px;
max-height: 570px;
overflow: auto;
display: grid;
gap: 8px;
}
.autoruns-msg {
border: 1px solid rgba(157, 255, 190, 0.22);
border-radius: 12px;
background: rgba(11, 18, 14, 0.8);
padding: 8px 10px;
display: grid;
gap: 6px;
}
.autoruns-msg header,
.autoruns-msg footer {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.74rem;
color: var(--text-muted);
}
.autoruns-msg p {
margin: 0;
white-space: pre-wrap;
line-height: 1.35;
font-size: 0.84rem;
}
.autoruns-msg.assistant {
margin-right: 12%;
}
.autoruns-msg.user {
margin-left: 12%;
border-color: rgba(95, 179, 255, 0.35);
background: rgba(10, 18, 24, 0.75);
}
.autoruns-decomposition-list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 7px;
font-size: 0.8rem;
}
.autoruns-coverage-list {
display: grid;
gap: 8px;
}
.autoruns-coverage-item {
border: 1px solid rgba(157, 255, 190, 0.2);
border-radius: 10px;
background: rgba(11, 18, 14, 0.68);
padding: 8px;
}
.autoruns-coverage-head {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.76rem;
margin-bottom: 5px;
}
.autoruns-coverage-head span {
color: var(--text-muted);
}
.autoruns-coverage-bar {
height: 7px;
border-radius: 999px;
background: rgba(157, 255, 190, 0.14);
overflow: hidden;
}
.autoruns-coverage-bar > div {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #6ee0ff, #8fffad);
}
@media (max-width: 1200px) {
.metrics-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.autoruns-columns {
grid-template-columns: 1fr !important;
}
}
@media (max-width: 920px) {
@@ -469,6 +722,12 @@ button:disabled {
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.autoruns-form-grid,
.autoruns-dialog-toolbar,
.autoruns-stats-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {