ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов 2.12.23: декомпозиция deep-turn пайплайна ассистента в runtime-адаптеры

This commit is contained in:
2026-04-10 19:22:40 +03:00
parent 19dbaff741
commit 80d108e506
116 changed files with 13949 additions and 2039 deletions
@@ -75,15 +75,16 @@ export function AssistantPanel({
errorMessage
}: AssistantPanelProps) {
const listRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(true);
const copyResetTimerRef = useRef<number | null>(null);
const [copyState, setCopyState] = useState<"idle" | "success" | "error">("idle");
const [copyModeLabel, setCopyModeLabel] = useState<"чат" | "тех">("чат");
useEffect(() => {
if (listRef.current) {
if (listRef.current && stickToBottomRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [conversation, statusText]);
}, [conversation]);
useEffect(() => {
return () => {
@@ -111,85 +112,93 @@ export function AssistantPanel({
}, 2200);
}
return (
<PanelFrame
title="Режим ассистента"
subtitle="Диалоговый слой поверх normalizer, маршрутизации и factual retrieval."
actions={
<div className="assistant-panel-actions">
<button
type="button"
className="assistant-copy-btn"
onClick={() => {
void handleCopyConversation("default");
}}
disabled={conversation.length === 0}
title="Экспорт только user-facing чата"
>
Скопировать чат
</button>
<button
type="button"
className="assistant-copy-btn"
onClick={() => {
void handleCopyConversation("technical");
}}
disabled={conversation.length === 0}
title="Технический экспорт с debug payload"
>
Скопировать техчат
</button>
{copyState === "success" ? <span className="assistant-copy-feedback success">Скопировано ({copyModeLabel})</span> : null}
{copyState === "error" ? <span className="assistant-copy-feedback error">Ошибка копирования</span> : null}
<span className="status-chip">{sessionId ? `session: ${sessionId}` : "новая сессия"}</span>
</div>
}
>
<div ref={listRef} className="assistant-chat-list">
{conversation.length === 0 ? (
<div className="assistant-empty muted">Диалог пуст. Отправьте первый вопрос, чтобы запустить контур ассистента.</div>
) : null}
{conversation.map((item) => (
<article key={item.message_id} className={`assistant-msg ${item.role}`}>
<header className="assistant-msg-head">
<strong>{roleLabel(item.role)}</strong>
<span>{shortTime(item.created_at)}</span>
</header>
<div className="assistant-msg-body">{item.text}</div>
{item.role === "assistant" && item.debug ? (
<details className="assistant-debug">
<summary>Показать технический разбор</summary>
<JsonView value={item.debug} />
</details>
) : null}
</article>
))}
</div>
function handleChatScroll(): void {
if (!listRef.current) return;
const node = listRef.current;
const distanceToBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
stickToBottomRef.current = distanceToBottom < 16;
}
<div className="assistant-compose">
<label className="full-width">
Сообщение
<textarea
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
rows={4}
placeholder="Введите вопрос к данным компании..."
/>
</label>
<div className="button-row">
<label className="checkbox-row">
<input type="checkbox" checked={useMock} onChange={(event) => onUseMockChange(event.target.checked)} />
Mock-режим
return (
<PanelFrame className="assistant-panel-frame" title="Режим ассистента">
<div className="assistant-live-shell">
<div className="assistant-toolbar">
<div className="assistant-toolbar-actions">
<button
type="button"
className="assistant-copy-btn"
onClick={() => {
void handleCopyConversation("default");
}}
disabled={conversation.length === 0}
title="Экспорт только user-facing чата"
>
Скопировать чат
</button>
<button
type="button"
className="assistant-copy-btn"
onClick={() => {
void handleCopyConversation("technical");
}}
disabled={conversation.length === 0}
title="Технический экспорт с debug payload"
>
Скопировать техчат
</button>
<button type="button" className="assistant-copy-btn" onClick={() => onClear()} disabled={busy && conversation.length === 0}>
Сбросить сессию
</button>
</div>
<div className="assistant-toolbar-meta">
{sessionId ? <span className="status-chip">{`session: ${sessionId}`}</span> : null}
<div className="assistant-toolbar-meta-right">
{statusText ? <span className="assistant-live-status">{statusText}</span> : null}
{copyState === "success" ? <span className="assistant-copy-feedback success">Скопировано ({copyModeLabel})</span> : null}
{copyState === "error" ? <span className="assistant-copy-feedback error">Ошибка копирования</span> : null}
</div>
</div>
{errorMessage ? <p className="error-text assistant-toolbar-error">{errorMessage}</p> : null}
</div>
<div ref={listRef} className="assistant-chat-list" onScroll={handleChatScroll}>
{conversation.map((item) => (
<article key={item.message_id} className={`assistant-msg ${item.role}`}>
<header className="assistant-msg-head">
<strong>{roleLabel(item.role)}</strong>
<span>{shortTime(item.created_at)}</span>
</header>
<div className="assistant-msg-body">{item.text}</div>
{item.role === "assistant" && item.debug ? (
<details className="assistant-debug">
<summary>Показать технический разбор</summary>
<JsonView value={item.debug} />
</details>
) : null}
</article>
))}
</div>
<div className="assistant-compose">
<label className="full-width">
Сообщение
<textarea
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
rows={4}
placeholder="Введите вопрос к данным компании..."
/>
</label>
<button type="button" onClick={() => onSend()} disabled={busy || !inputValue.trim()}>
{busy ? "Выполняю..." : "Отправить"}
</button>
<button type="button" onClick={() => onClear()} disabled={busy && conversation.length === 0}>
Сбросить сессию
</button>
<div className="button-row assistant-send-row">
<label className="checkbox-row">
<input type="checkbox" checked={useMock} onChange={(event) => onUseMockChange(event.target.checked)} />
Mock-режим
</label>
<button type="button" className="assistant-send-btn" onClick={() => onSend()} disabled={busy || !inputValue.trim()}>
{busy ? "Выполняю..." : "Отправить"}
</button>
</div>
</div>
{statusText ? <p className="diff-summary">{statusText}</p> : null}
{errorMessage ? <p className="error-text">{errorMessage}</p> : null}
</div>
</PanelFrame>
);
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "../api/client";
import type {
AssistantConversationItem,
AsyncEvalRunJob,
AutoGenHistoryRecord,
AutoGenMode,
@@ -18,6 +19,7 @@ import type {
ManualCaseDecision,
PromptState
} from "../state/types";
import { AssistantPanel } from "./AssistantPanel";
import { JsonView } from "./JsonView";
import { PanelFrame } from "./PanelFrame";
@@ -91,6 +93,7 @@ const LIVE_RUN_ID_PREFIX = "__live__:";
const AUTORUNS_UI_CONFIG_KEY = "ndc_autoruns_ui_config_v1";
const AUTORUNS_SAVE_EVENT = "ndc-autoruns-save";
const ASSISTANT_STAGES = ["Анализ запроса", "Получение данных", "Подготовка ответа"];
const AUTOGEN_PERSONALITIES: AutoGenPersonalityDefinition[] = [
{
@@ -113,6 +116,7 @@ function buildDefaultPersonalityPrompts(
interface AutoRunsUiConfig {
filters?: Partial<AutoRunsFilters>;
analysisDate?: string;
autoGenSettings?: {
mode?: AutoGenMode;
count?: number;
@@ -134,6 +138,11 @@ const DEFAULT_AUTOGEN_SETTINGS: AutoGenSettingsState = {
generatedBy: "manual_reviewer"
};
function normalizeAnalysisDateInput(value: string): string {
const normalized = String(value ?? "").trim();
return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : "";
}
function dateToInputValue(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -246,6 +255,7 @@ function buildLiveRunSummary(job: AsyncEvalRunJob): AutoRunSummary {
llm_provider: null,
model: null,
use_mock: null,
analysis_date: job.report_summary?.analysis_date ?? job.analysis_date ?? null,
prompt_version: null,
schema_version: null,
suite_id: job.case_set_file,
@@ -314,7 +324,8 @@ function buildLiveRunDetail(job: AsyncEvalRunJob, requestedCaseId: string): {
run_id: job.report_summary.run_id,
run_timestamp: job.report_summary.run_timestamp,
score_index: job.report_summary.score_index,
cases_total: job.report_summary.cases_total
cases_total: job.report_summary.cases_total,
analysis_date: job.report_summary.analysis_date ?? job.analysis_date ?? null
}
: {}
};
@@ -411,6 +422,7 @@ export function AutoRunsHistoryPanel({
...DEFAULT_FILTERS,
fromLocal: defaultFromDateValue()
});
const [analysisDate, setAnalysisDate] = useState("");
const [history, setHistory] = useState<AutoRunHistoryResponse | null>(null);
const [runDetail, setRunDetail] = useState<AutoRunDetailResponse | null>(null);
const [dialog, setDialog] = useState<AutoRunDialogResponse | null>(null);
@@ -439,6 +451,13 @@ export function AutoRunsHistoryPanel({
const [annotationsBusy, setAnnotationsBusy] = useState(false);
const [annotationResolutionBusyId, setAnnotationResolutionBusyId] = useState("");
const [errorText, setErrorText] = useState("");
const [assistantLiveSessionId, setAssistantLiveSessionId] = useState("");
const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]);
const [assistantLiveInput, setAssistantLiveInput] = useState("");
const [assistantLiveUseMock, setAssistantLiveUseMock] = useState(false);
const [assistantLiveBusy, setAssistantLiveBusy] = useState(false);
const [assistantLiveStatus, setAssistantLiveStatus] = useState("");
const [assistantLiveError, setAssistantLiveError] = useState("");
const [limitInput, setLimitInput] = useState(String(DEFAULT_FILTERS.limit));
const [autogenCountInput, setAutogenCountInput] = useState(String(DEFAULT_AUTOGEN_SETTINGS.count));
const [commentModal, setCommentModal] = useState<CommentModalState>({
@@ -465,8 +484,6 @@ export function AutoRunsHistoryPanel({
[autoGenHistory, selectedAutogenGenerationId]
);
const activeRunSummary: AutoRunSummary | null =
history?.items.find((item) => item.run_id === selectedRunId) ?? runDetail?.run ?? null;
const activeCase = runDetail ? getSelectedCase(runDetail.cases, selectedCaseId) : null;
const visibleAnnotations = useMemo(
() => (hideResolvedAnnotations ? annotations.filter((item) => !item.resolved) : annotations),
@@ -509,6 +526,81 @@ export function AutoRunsHistoryPanel({
[onLog]
);
function startAssistantLiveStatusTicker(): () => void {
let index = 0;
setAssistantLiveStatus(ASSISTANT_STAGES[0]);
const timer = window.setInterval(() => {
index = Math.min(index + 1, ASSISTANT_STAGES.length - 1);
setAssistantLiveStatus(ASSISTANT_STAGES[index]);
}, 650);
return () => window.clearInterval(timer);
}
const resetAssistantLiveSession = useCallback(() => {
setAssistantLiveSessionId("");
setAssistantLiveConversation([]);
setAssistantLiveInput("");
setAssistantLiveStatus("");
setAssistantLiveError("");
log("Live-чат ассистента в истории автопрогонов сброшен.");
}, [log]);
const sendAssistantLiveMessage = useCallback(async () => {
const userMessage = assistantLiveInput.trim();
if (!userMessage) {
return;
}
setAssistantLiveBusy(true);
setAssistantLiveError("");
setAssistantLiveInput("");
setAssistantLiveConversation((prev) => [
...prev,
{
message_id: `autoruns-live-${Date.now()}`,
session_id: assistantLiveSessionId || "pending",
role: "user",
text: userMessage,
reply_type: null,
created_at: new Date().toISOString(),
trace_id: null,
debug: null
}
]);
const stopTicker = startAssistantLiveStatusTicker();
try {
const response = await apiClient.sendAssistantMessage({
connection,
prompts,
userMessage,
sessionId: assistantLiveSessionId || undefined,
promptVersion: assistantPromptVersion,
useMock: assistantLiveUseMock
});
setAssistantLiveSessionId(response.session_id);
setAssistantLiveConversation(response.conversation);
setAssistantLiveStatus("Ответ готов");
log(`Live-ответ ассистента получен: trace=${response.debug.trace_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantLiveError(message);
setAssistantLiveStatus("Ошибка ассистента");
log(`Live-чат ассистента: ошибка отправки сообщения: ${message}`);
} finally {
stopTicker();
setAssistantLiveBusy(false);
}
}, [
assistantLiveInput,
assistantLiveSessionId,
assistantLiveUseMock,
assistantPromptVersion,
connection,
log,
prompts
]);
const commitLimitInput = useCallback(
(raw: string) => {
const normalized = raw.trim();
@@ -933,6 +1025,7 @@ export function AutoRunsHistoryPanel({
}
const useMockForRun = filters.useMock === "true";
const effectiveAnalysisDate = normalizeAnalysisDateInput(analysisDate);
const payload = await apiClient.startEvalRunAsync({
connection,
prompts,
@@ -941,7 +1034,8 @@ export function AutoRunsHistoryPanel({
caseSetFile: generation.saved_case_set_file ?? undefined,
useMock: useMockForRun,
evalTarget: "assistant_stage1",
questions: questionsForRun
questions: questionsForRun,
analysisDate: effectiveAnalysisDate || undefined
});
const liveJob = payload.job;
@@ -955,7 +1049,8 @@ export function AutoRunsHistoryPanel({
log(
`Запущен async-прогон job=${liveJob.job_id}, run_id=${liveJob.run_id}, вопросов=${questionsForRun.length}` +
(generation.saved_case_set_file ? `, base_case_set=${generation.saved_case_set_file}` : "")
(generation.saved_case_set_file ? `, base_case_set=${generation.saved_case_set_file}` : "") +
(effectiveAnalysisDate ? `, analysis_date=${effectiveAnalysisDate}` : ", analysis_date=current_state")
);
void pollAsyncJobStatus(liveJob.job_id);
} catch (error) {
@@ -965,6 +1060,7 @@ export function AutoRunsHistoryPanel({
setAutogenRunBusy(false);
}
}, [
analysisDate,
assistantPromptVersion,
connection,
editableGeneratedQuestions,
@@ -1235,6 +1331,9 @@ export function AutoRunsHistoryPanel({
limit: typeof savedFilters.limit === "number" ? Math.max(1, Math.min(500, savedFilters.limit)) : prev.limit
}));
}
if (typeof parsed.analysisDate === "string") {
setAnalysisDate(normalizeAnalysisDateInput(parsed.analysisDate));
}
if (parsed.autoGenSettings) {
setAutoGenSettings((prev) => {
const nextPrompts: Record<string, string> = {
@@ -1290,6 +1389,7 @@ export function AutoRunsHistoryPanel({
const saveUiConfig = useCallback(() => {
const payload: AutoRunsUiConfig = {
filters,
analysisDate,
autoGenSettings: {
mode: autoGenSettings.mode,
count: autoGenSettings.count,
@@ -1302,7 +1402,7 @@ export function AutoRunsHistoryPanel({
hideResolvedAnnotations
};
localStorage.setItem(AUTORUNS_UI_CONFIG_KEY, JSON.stringify(payload));
}, [annotationDecisionFilter, autoGenSettings, filters, hideResolvedAnnotations]);
}, [analysisDate, annotationDecisionFilter, autoGenSettings, filters, hideResolvedAnnotations]);
useEffect(() => {
const onSave = () => {
@@ -1542,6 +1642,25 @@ export function AutoRunsHistoryPanel({
</label>
</div>
<div className="autoruns-form-grid">
<label>
Дата анализа (срез)
<input
type="date"
value={analysisDate}
onChange={(event) => setAnalysisDate(normalizeAnalysisDateInput(event.target.value))}
/>
</label>
<div className="button-row">
<button type="button" className="tab" disabled={!analysisDate} onClick={() => setAnalysisDate("")}>
Сбросить дату среза
</button>
</div>
</div>
<p className="muted">
Если дата среза задана, автопрогон анализирует данные на эту дату. Если поле пустое, используется текущее состояние.
</p>
<div className="button-row">
<button type="button" disabled={autoGenBusy} onClick={() => void generateAutogenBatch()}>
{autoGenBusy ? "Генерирую..." : "Сгенерировать пачку"}
@@ -1704,6 +1823,7 @@ export function AutoRunsHistoryPanel({
<div className="autoruns-run-meta">
режим={run.mode ?? "нет данных"} | mock={String(run.use_mock)}
</div>
<div className="autoruns-run-meta">analysis_date={run.analysis_date ?? "current_state"}</div>
{run.llm_provider || run.model ? (
<div className="autoruns-run-meta">
llm={run.llm_provider ?? "нет данных"} | модель={run.model ?? "нет данных"}
@@ -1879,39 +1999,21 @@ export function AutoRunsHistoryPanel({
</section>
{showAssistantMode ? (
<section className="autoruns-col">
<div className="autoruns-col-header">
<h3>Режим ассистента</h3>
</div>
<div className="autoruns-meta-list">
<div>
<span>источник:</span>
<strong>{dialog?.source ?? "нет данных"}</strong>
</div>
<div>
<span>сессия:</span>
<strong>{dialog?.session_id ?? "нет данных"}</strong>
</div>
<div>
<span>контур прогона:</span>
<strong>{activeRunSummary?.eval_target ?? "нет данных"}</strong>
</div>
<div>
<span>оценка прогона:</span>
<strong>{formatScore(activeRunSummary?.score_index ?? null)}</strong>
</div>
<div>
<span>комментарии:</span>
<strong>{runDetail?.annotations_summary?.total ?? 0}</strong>
</div>
</div>
<h4>Пакет режима ассистента</h4>
<JsonView value={dialog?.assistant_mode ?? { note: "assistant_mode недоступен" }} />
<h4 style={{ marginTop: 12 }}>Проверки кейса</h4>
<JsonView value={activeCase?.checks ?? { note: "checks недоступен" }} />
<h4 style={{ marginTop: 12 }}>Сабскор метрик</h4>
<JsonView value={activeCase?.metric_subscores ?? { note: "metric_subscores недоступен" }} />
</section>
<div className="autoruns-col autoruns-assistant-live-col">
<AssistantPanel
sessionId={assistantLiveSessionId}
conversation={assistantLiveConversation}
inputValue={assistantLiveInput}
onInputChange={setAssistantLiveInput}
useMock={assistantLiveUseMock}
onUseMockChange={setAssistantLiveUseMock}
onSend={sendAssistantLiveMessage}
onClear={resetAssistantLiveSession}
busy={assistantLiveBusy}
statusText={assistantLiveStatus}
errorMessage={assistantLiveError}
/>
</div>
) : null}
{showDecompositionMode ? (