ЮИ - Добавить удаление сохраненных наборов автопрогонов с удалением файлов на бэке

This commit is contained in:
dctouch 2026-04-16 21:02:30 +03:00
parent f3255cb3b8
commit a3a61b3a0f
22 changed files with 1895 additions and 1996 deletions

View File

@ -93,6 +93,16 @@ function clampInt(value, min, max, fallback) {
return max; return max;
return rounded; return rounded;
} }
function isAutoGenMode(value) {
return value === "qwen_seed" || value === "codex_creative" || value === "saved_user_sessions";
}
function parseAutoGenTitle(value) {
const title = toStringSafe(value);
if (!title) {
return null;
}
return title.slice(0, 160);
}
function parseManualCaseDecision(value, fallback = "needs_dialog_policy_fix") { function parseManualCaseDecision(value, fallback = "needs_dialog_policy_fix") {
const normalized = toStringSafe(value); const normalized = toStringSafe(value);
if (!normalized) if (!normalized)
@ -151,15 +161,11 @@ function readAutoGenHistory() {
.map((item) => ({ .map((item) => ({
generation_id: toStringSafe(item.generation_id) ?? "", generation_id: toStringSafe(item.generation_id) ?? "",
created_at: toStringSafe(item.created_at) ?? new Date().toISOString(), created_at: toStringSafe(item.created_at) ?? new Date().toISOString(),
mode: toStringSafe(item.mode) ?? "codex_creative", mode: isAutoGenMode(toStringSafe(item.mode)) ? toStringSafe(item.mode) : "codex_creative",
title: parseAutoGenTitle(item.title),
count: clampInt(toNumberSafe(item.count), 1, 300, 20), count: clampInt(toNumberSafe(item.count), 1, 300, 20),
domain: toStringSafe(item.domain), domain: toStringSafe(item.domain),
questions: toArray(item.questions) questions: parseAssistantSessionQuestions(item.questions),
.map((q) => toStringSafe(q))
.filter((q) => q !== null)
.map((q) => sanitizeGeneratedQuestion(q))
.filter((q) => q.length > 0)
.slice(0, 500),
generated_by: toStringSafe(item.generated_by), generated_by: toStringSafe(item.generated_by),
saved_case_set_file: toStringSafe(item.saved_case_set_file), saved_case_set_file: toStringSafe(item.saved_case_set_file),
context: toRecord(item.context) context: toRecord(item.context)
@ -174,7 +180,10 @@ function readAutoGenHistory() {
autogen_personality_id: toStringSafe(toRecord(item.context)?.autogen_personality_id), autogen_personality_id: toStringSafe(toRecord(item.context)?.autogen_personality_id),
autogen_personality_prompt: toStringSafe(toRecord(item.context)?.autogen_personality_prompt) autogen_personality_prompt: toStringSafe(toRecord(item.context)?.autogen_personality_prompt)
? repairAutogenMojibake(String(toRecord(item.context)?.autogen_personality_prompt)) ? repairAutogenMojibake(String(toRecord(item.context)?.autogen_personality_prompt))
: null : null,
source_session_id: toStringSafe(toRecord(item.context)?.source_session_id),
saved_session_file: toStringSafe(toRecord(item.context)?.saved_session_file),
saved_case_set_kind: toStringSafe(toRecord(item.context)?.saved_case_set_kind)
} }
: null : null
})) }))
@ -1057,7 +1066,7 @@ function parseDecisionFilter(value) {
} }
function parseAutoGenMode(value) { function parseAutoGenMode(value) {
const normalized = toStringSafe(value)?.toLowerCase() ?? ""; const normalized = toStringSafe(value)?.toLowerCase() ?? "";
if (normalized === "qwen_seed" || normalized === "codex_creative") { if (normalized === "qwen_seed" || normalized === "codex_creative" || normalized === "saved_user_sessions") {
return normalized; return normalized;
} }
return "codex_creative"; return "codex_creative";
@ -1150,6 +1159,12 @@ function sanitizeGeneratedQuestion(value) {
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
} }
function parseAssistantSessionQuestions(value) {
return toArray(value)
.map((item) => sanitizeGeneratedQuestion(typeof item === "string" ? item : ""))
.filter((item) => item.length > 0)
.slice(0, 500);
}
const AUTOGEN_QUESTION_PLACEHOLDER_PATTERN = /^(?:questions?|вопросы?|список\s+вопросов)$/iu; const AUTOGEN_QUESTION_PLACEHOLDER_PATTERN = /^(?:questions?|вопросы?|список\s+вопросов)$/iu;
const AUTOGEN_QUESTION_TAIL_PATTERNS = [ const AUTOGEN_QUESTION_TAIL_PATTERNS = [
/^(?:без\s+воды|по\s+факту|и\s+коротко|коротко|прям(?:\s+)?сейчас|за\s+весь\s+период|по\s+делу)\??$/iu /^(?:без\s+воды|по\s+факту|и\s+коротко|коротко|прям(?:\s+)?сейчас|за\s+весь\s+период|по\s+делу)\??$/iu
@ -1413,6 +1428,18 @@ function buildAutogenCaseSetFileName(mode, generationId) {
].join(""); ].join("");
return `assistant_autogen_${mode}_${stamp}_${generationId}.json`; return `assistant_autogen_${mode}_${stamp}_${generationId}.json`;
} }
function buildSavedAssistantSessionSnapshotFileName(generationId) {
const now = new Date();
const stamp = [
now.getUTCFullYear(),
String(now.getUTCMonth() + 1).padStart(2, "0"),
String(now.getUTCDate()).padStart(2, "0"),
String(now.getUTCHours()).padStart(2, "0"),
String(now.getUTCMinutes()).padStart(2, "0"),
String(now.getUTCSeconds()).padStart(2, "0")
].join("");
return `assistant_saved_session_${stamp}_${generationId}.json`;
}
function buildAutogenCaseSetPayload(input) { function buildAutogenCaseSetPayload(input) {
const normalizedQuestions = Array.from(new Set(input.questions.map((item) => sanitizeGeneratedQuestion(item)).filter((item) => item.length > 0))); const normalizedQuestions = Array.from(new Set(input.questions.map((item) => sanitizeGeneratedQuestion(item)).filter((item) => item.length > 0)));
const cases = normalizedQuestions.map((question, index) => ({ const cases = normalizedQuestions.map((question, index) => ({
@ -1439,6 +1466,99 @@ function buildAutogenCaseSetPayload(input) {
cases cases
}; };
} }
function buildSavedSessionCaseSetPayload(input) {
const questions = parseAssistantSessionQuestions(input.questions);
const turns = questions.map((question) => ({
user_message: question
}));
const caseId = "SAVED-001";
return {
suite_id: `assistant_saved_session_${input.generationId}`,
suite_version: "0.1.0",
schema_version: "assistant_saved_session_suite_v0_1",
generated_at: new Date().toISOString(),
generation_id: input.generationId,
mode: "saved_user_sessions",
title: input.title,
scenario_count: turns.length > 0 ? 1 : 0,
case_ids: turns.length > 0 ? [caseId] : [],
cases: turns.length > 0
? [
{
case_id: caseId,
scenario_tag: "saved_user_sessions",
title: input.title,
question_type: turns.length > 1 ? "followup" : "direct",
broadness_level: "medium",
turns
}
]
: []
};
}
function ensureDirSync(targetDir) {
if (!fs_1.default.existsSync(targetDir)) {
fs_1.default.mkdirSync(targetDir, { recursive: true });
}
}
function writeJsonFile(targetPath, payload) {
ensureDirSync(path_1.default.dirname(targetPath));
fs_1.default.writeFileSync(targetPath, JSON.stringify(payload, null, 2), "utf-8");
}
function rewriteAutoGenCaseSetFile(record) {
const caseSetFile = toStringSafe(record.saved_case_set_file);
if (!caseSetFile) {
return null;
}
const targetPath = path_1.default.resolve(config_1.EVAL_CASES_DIR, caseSetFile);
const payload = record.mode === "saved_user_sessions"
? buildSavedSessionCaseSetPayload({
generationId: record.generation_id,
title: record.title,
questions: record.questions
})
: buildAutogenCaseSetPayload({
generationId: record.generation_id,
mode: record.mode,
domain: record.domain,
questions: record.questions
});
writeJsonFile(targetPath, payload);
return caseSetFile;
}
function writeSavedAssistantSessionSnapshot(input) {
const fileName = buildSavedAssistantSessionSnapshotFileName(input.generationId);
const targetPath = path_1.default.resolve(path_1.default.dirname(config_1.AUTORUN_GENERATOR_HISTORY_FILE), "saved_sessions", fileName);
writeJsonFile(targetPath, {
saved_at: new Date().toISOString(),
generation_id: input.generationId,
mode: "saved_user_sessions",
title: input.title,
source_session_id: input.sessionId,
questions: input.questions,
session: input.session
});
return fileName;
}
function resolveFileInsideDir(baseDir, fileName) {
const normalized = toStringSafe(fileName);
if (!normalized) {
return null;
}
const targetPath = path_1.default.resolve(baseDir, normalized);
const relative = path_1.default.relative(baseDir, targetPath);
if (relative.startsWith("..") || path_1.default.isAbsolute(relative)) {
return null;
}
return targetPath;
}
function safeDeleteFile(targetPath) {
if (!targetPath || !fs_1.default.existsSync(targetPath)) {
return null;
}
fs_1.default.unlinkSync(targetPath);
return targetPath;
}
function collectPostAnalysis(annotations, runMap, limitPerQueue) { function collectPostAnalysis(annotations, runMap, limitPerQueue) {
const byDecision = {}; const byDecision = {};
const byQueue = {}; const byQueue = {};
@ -1522,7 +1642,7 @@ function collectPostAnalysis(annotations, runMap, limitPerQueue) {
].slice(0, 60) ].slice(0, 60)
}; };
} }
function buildAutoRunsRouter(openaiClient = new openaiResponsesClient_1.OpenAIResponsesClient()) { function buildAutoRunsRouter(services, openaiClient = new openaiResponsesClient_1.OpenAIResponsesClient()) {
const router = (0, express_1.Router)(); const router = (0, express_1.Router)();
router.get("/api/autoruns/history", (req, res) => { router.get("/api/autoruns/history", (req, res) => {
const filters = parseFilters(req.query); const filters = parseFilters(req.query);
@ -1884,7 +2004,7 @@ function buildAutoRunsRouter(openaiClient = new openaiResponsesClient_1.OpenAIRe
try { try {
const limit = clampInt(toNumberSafe(req.query.limit), 1, 500, 120); const limit = clampInt(toNumberSafe(req.query.limit), 1, 500, 120);
const rawMode = toStringSafe(req.query.mode); const rawMode = toStringSafe(req.query.mode);
const includeAllModes = !rawMode || !["qwen_seed", "codex_creative"].includes(rawMode); const includeAllModes = !rawMode || !isAutoGenMode(rawMode);
const modeFilter = rawMode ?? "codex_creative"; const modeFilter = rawMode ?? "codex_creative";
const items = readAutoGenHistory() const items = readAutoGenHistory()
.filter((item) => (includeAllModes ? true : item.mode === modeFilter)) .filter((item) => (includeAllModes ? true : item.mode === modeFilter))
@ -1911,6 +2031,157 @@ function buildAutoRunsRouter(openaiClient = new openaiResponsesClient_1.OpenAIRe
next(error); next(error);
} }
}); });
router.post("/api/autoruns/autogen/save-assistant-session", (req, res, next) => {
try {
const body = toRecord(req.body);
if (!body) {
throw new http_1.ApiError("INVALID_AUTOGEN_SAVE_SESSION_PAYLOAD", "JSON body is required", 400);
}
const sessionId = toStringSafe(body.session_id);
const title = parseAutoGenTitle(body.title);
const generatedBy = parseAnnotationAuthor(body.generated_by);
const context = toRecord(body.context);
if (!sessionId) {
throw new http_1.ApiError("INVALID_AUTOGEN_SAVE_SESSION_PAYLOAD", "session_id is required", 400);
}
if (!title) {
throw new http_1.ApiError("INVALID_AUTOGEN_SAVE_SESSION_PAYLOAD", "title is required", 400);
}
const session = services.assistantService.getSession(sessionId);
if (!session) {
throw new http_1.ApiError("ASSISTANT_SESSION_NOT_FOUND", `Session not found: ${sessionId}`, 404);
}
const questions = session.items
.filter((item) => item.role === "user")
.map((item) => sanitizeGeneratedQuestion(item.text))
.filter((item) => item.length > 0);
if (questions.length === 0) {
throw new http_1.ApiError("ASSISTANT_SESSION_EMPTY", "Assistant session has no user questions to save.", 400);
}
const generationId = generateAutogenId();
const caseSetFile = buildAutogenCaseSetFileName("saved_user_sessions", generationId);
const caseSetPath = path_1.default.resolve(config_1.EVAL_CASES_DIR, caseSetFile);
writeJsonFile(caseSetPath, buildSavedSessionCaseSetPayload({
generationId,
title,
questions
}));
const snapshotFile = writeSavedAssistantSessionSnapshot({
generationId,
sessionId,
title,
session: session,
questions
});
const record = {
generation_id: generationId,
created_at: new Date().toISOString(),
mode: "saved_user_sessions",
title,
count: questions.length,
domain: null,
questions,
generated_by: generatedBy,
saved_case_set_file: caseSetFile,
context: {
llm_provider: toStringSafe(context?.llm_provider),
model: toStringSafe(context?.model),
assistant_prompt_version: toStringSafe(context?.assistant_prompt_version),
decomposition_prompt_version: toStringSafe(context?.decomposition_prompt_version),
prompt_fingerprint: toStringSafe(context?.prompt_fingerprint)
? repairAutogenMojibake(String(context?.prompt_fingerprint))
: null,
autogen_personality_id: null,
autogen_personality_prompt: null,
source_session_id: sessionId,
saved_session_file: snapshotFile,
saved_case_set_kind: "assistant_session_scenario"
}
};
const history = readAutoGenHistory();
history.unshift(record);
writeAutoGenHistory(history.slice(0, 500));
(0, http_1.ok)(res, {
ok: true,
generation: record
});
}
catch (error) {
next(error);
}
});
router.patch("/api/autoruns/autogen/history/:generation_id/questions", (req, res, next) => {
try {
const generationId = toStringSafe(req.params.generation_id);
const body = toRecord(req.body);
if (!generationId) {
throw new http_1.ApiError("INVALID_AUTOGEN_GENERATION_ID", "generation_id is required", 400);
}
if (!body) {
throw new http_1.ApiError("INVALID_AUTOGEN_QUESTIONS_PAYLOAD", "JSON body is required", 400);
}
const questions = parseAssistantSessionQuestions(body.questions);
if (questions.length === 0) {
throw new http_1.ApiError("INVALID_AUTOGEN_QUESTIONS_PAYLOAD", "questions must contain at least one item", 400);
}
const history = readAutoGenHistory();
const targetIndex = history.findIndex((item) => item.generation_id === generationId);
if (targetIndex < 0) {
throw new http_1.ApiError("AUTOGEN_GENERATION_NOT_FOUND", `Generation not found: ${generationId}`, 404);
}
const current = history[targetIndex];
const updated = {
...current,
count: questions.length,
questions
};
rewriteAutoGenCaseSetFile(updated);
history[targetIndex] = updated;
writeAutoGenHistory(history);
(0, http_1.ok)(res, {
ok: true,
generation: updated
});
}
catch (error) {
next(error);
}
});
router.delete("/api/autoruns/autogen/history/:generation_id", (req, res, next) => {
try {
const generationId = toStringSafe(req.params.generation_id);
if (!generationId) {
throw new http_1.ApiError("INVALID_AUTOGEN_GENERATION_ID", "generation_id is required", 400);
}
const history = readAutoGenHistory();
const targetIndex = history.findIndex((item) => item.generation_id === generationId);
if (targetIndex < 0) {
throw new http_1.ApiError("AUTOGEN_GENERATION_NOT_FOUND", `Generation not found: ${generationId}`, 404);
}
const target = history[targetIndex];
const deletedFiles = [];
const caseSetPath = resolveFileInsideDir(config_1.EVAL_CASES_DIR, target.saved_case_set_file);
const savedSessionPath = resolveFileInsideDir(path_1.default.resolve(path_1.default.dirname(config_1.AUTORUN_GENERATOR_HISTORY_FILE), "saved_sessions"), target.context?.saved_session_file ?? null);
const deletedCaseSet = safeDeleteFile(caseSetPath);
if (deletedCaseSet) {
deletedFiles.push(deletedCaseSet);
}
const deletedSavedSession = safeDeleteFile(savedSessionPath);
if (deletedSavedSession) {
deletedFiles.push(deletedSavedSession);
}
history.splice(targetIndex, 1);
writeAutoGenHistory(history);
(0, http_1.ok)(res, {
ok: true,
generation_id: generationId,
deleted_files: deletedFiles
});
}
catch (error) {
next(error);
}
});
router.post("/api/autoruns/autogen/generate", async (req, res, next) => { router.post("/api/autoruns/autogen/generate", async (req, res, next) => {
try { try {
const body = toRecord(req.body); const body = toRecord(req.body);
@ -1925,6 +2196,9 @@ function buildAutoRunsRouter(openaiClient = new openaiResponsesClient_1.OpenAIRe
const context = toRecord(body.context); const context = toRecord(body.context);
const llmConfig = parseAutogenLlmRuntimeConfig(body, context); const llmConfig = parseAutogenLlmRuntimeConfig(body, context);
const personalityPrompt = toStringSafe(context?.autogen_personality_prompt); const personalityPrompt = toStringSafe(context?.autogen_personality_prompt);
if (mode === "saved_user_sessions") {
throw new http_1.ApiError("AUTOGEN_MODE_NOT_SUPPORTED", "Use `/api/autoruns/autogen/save-assistant-session` to save user sessions.", 400);
}
let questions = []; let questions = [];
if (mode === "qwen_seed") { if (mode === "qwen_seed") {
if (!llmConfig) { if (!llmConfig) {
@ -1963,6 +2237,7 @@ function buildAutoRunsRouter(openaiClient = new openaiResponsesClient_1.OpenAIRe
generation_id: generationId, generation_id: generationId,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
mode, mode,
title: null,
count: questions.length, count: questions.length,
domain, domain,
questions, questions,
@ -1980,7 +2255,10 @@ function buildAutoRunsRouter(openaiClient = new openaiResponsesClient_1.OpenAIRe
autogen_personality_id: toStringSafe(context.autogen_personality_id), autogen_personality_id: toStringSafe(context.autogen_personality_id),
autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt) autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt)
? repairAutogenMojibake(String(context.autogen_personality_prompt)) ? repairAutogenMojibake(String(context.autogen_personality_prompt))
: null : null,
source_session_id: null,
saved_session_file: null,
saved_case_set_kind: "single_turn_list"
} }
: null : null
}; };

View File

@ -128,14 +128,23 @@ function splitQuestionCandidate(raw) {
} }
return normalizeRuntimeQuestionList(chunks); return normalizeRuntimeQuestionList(chunks);
} }
function normalizeRuntimeQuestions(value) { function normalizeRuntimeQuestions(value, options) {
const raw = toArray(value) const raw = toArray(value)
.map((item) => (typeof item === "string" ? item.trim() : "")) .map((item) => (typeof item === "string" ? item.trim() : ""))
.filter((item) => item.length > 0); .filter((item) => item.length > 0);
if (raw.length === 0) { if (raw.length === 0) {
return []; return [];
} }
const expanded = normalizeRuntimeQuestionList(raw.flatMap((item) => splitQuestionCandidate(item))); const splitCandidates = options?.splitCandidates ?? true;
const expanded = splitCandidates
? normalizeRuntimeQuestionList(raw.flatMap((item) => splitQuestionCandidate(item)))
: raw
.map((item) => normalizeQuestionChunk(item))
.filter((item) => Boolean(item));
const dedupe = options?.dedupe ?? true;
if (!dedupe) {
return expanded;
}
const deduped = []; const deduped = [];
const seen = new Set(); const seen = new Set();
for (const item of expanded) { for (const item of expanded) {
@ -272,6 +281,37 @@ function writeRuntimeAssistantSuiteFromQuestions(jobId, questions) {
fs_1.default.writeFileSync(path_1.default.resolve(config_1.EVAL_CASES_DIR, fileName), JSON.stringify(payload, null, 2), "utf-8"); fs_1.default.writeFileSync(path_1.default.resolve(config_1.EVAL_CASES_DIR, fileName), JSON.stringify(payload, null, 2), "utf-8");
return fileName; return fileName;
} }
function writeRuntimeAssistantScenarioSuiteFromQuestions(jobId, questions, title) {
if (!fs_1.default.existsSync(config_1.EVAL_CASES_DIR)) {
fs_1.default.mkdirSync(config_1.EVAL_CASES_DIR, { recursive: true });
}
const turns = questions.map((question) => ({
user_message: question
}));
const payload = {
suite_id: `assistant_saved_session_runtime_${jobId}`,
suite_version: "0.1.0",
schema_version: "assistant_saved_session_runtime_v0_1",
title: typeof title === "string" ? title.trim() || null : null,
scenario_count: turns.length > 0 ? 1 : 0,
case_ids: turns.length > 0 ? ["SAVED-001"] : [],
cases: turns.length > 0
? [
{
case_id: "SAVED-001",
scenario_tag: "saved_user_sessions_runtime",
title: typeof title === "string" ? title.trim() || null : null,
question_type: turns.length > 1 ? "followup" : "direct",
broadness_level: "medium",
turns
}
]
: []
};
const fileName = `assistant_saved_session_runtime_${jobId}.json`;
fs_1.default.writeFileSync(path_1.default.resolve(config_1.EVAL_CASES_DIR, fileName), JSON.stringify(payload, null, 2), "utf-8");
return fileName;
}
function readSessionConversation(runId, caseId) { function readSessionConversation(runId, caseId) {
const sessionId = `${runId}-${caseId}`; const sessionId = `${runId}-${caseId}`;
const filePath = path_1.default.resolve(config_1.ASSISTANT_SESSIONS_DIR, `${sessionId}.json`); const filePath = path_1.default.resolve(config_1.ASSISTANT_SESSIONS_DIR, `${sessionId}.json`);
@ -414,15 +454,19 @@ function buildEvalRouter(services) {
throw new http_1.ApiError("UNSUPPORTED_ASYNC_EVAL_TARGET", "Async eval currently supports assistant_stage1 only.", 400); throw new http_1.ApiError("UNSUPPORTED_ASYNC_EVAL_TARGET", "Async eval currently supports assistant_stage1 only.", 400);
} }
const questions = normalizeRuntimeQuestions(body.questions); const questions = normalizeRuntimeQuestions(body.questions);
const scenarioQuestions = normalizeRuntimeQuestions(body.scenarioQuestions, { dedupe: false, splitCandidates: false });
const scenarioTitle = toStringSafe(body.scenarioTitle);
const jobId = `job-${(0, nanoid_1.nanoid)(10)}`; const jobId = `job-${(0, nanoid_1.nanoid)(10)}`;
const runId = `assistant-stage1-${(0, nanoid_1.nanoid)(10)}`; const runId = `assistant-stage1-${(0, nanoid_1.nanoid)(10)}`;
const runtimeCaseSetFile = questions.length > 0 const runtimeCaseSetFile = scenarioQuestions.length > 0
? writeRuntimeAssistantScenarioSuiteFromQuestions(jobId, scenarioQuestions, scenarioTitle ?? undefined)
: questions.length > 0
? writeRuntimeAssistantSuiteFromQuestions(jobId, questions) ? writeRuntimeAssistantSuiteFromQuestions(jobId, questions)
: payload.caseSetFile : payload.caseSetFile
? payload.caseSetFile ? payload.caseSetFile
: undefined; : undefined;
if (!runtimeCaseSetFile) { if (!runtimeCaseSetFile) {
throw new http_1.ApiError("ASYNC_CASESET_REQUIRED", "Async assistant_stage1 run requires caseSetFile or explicit questions[] payload.", 400); throw new http_1.ApiError("ASYNC_CASESET_REQUIRED", "Async assistant_stage1 run requires caseSetFile, scenarioQuestions[] or explicit questions[] payload.", 400);
} }
const caseSeeds = readAssistantSuiteCaseSeeds(runtimeCaseSetFile); const caseSeeds = readAssistantSuiteCaseSeeds(runtimeCaseSetFile);
if (caseSeeds.length === 0) { if (caseSeeds.length === 0) {

View File

@ -64,7 +64,7 @@ function createApp() {
app.use((0, normalize_1.buildNormalizeRouter)(services)); app.use((0, normalize_1.buildNormalizeRouter)(services));
app.use((0, eval_1.buildEvalRouter)(services)); app.use((0, eval_1.buildEvalRouter)(services));
app.use((0, assistant_1.buildAssistantRouter)(services)); app.use((0, assistant_1.buildAssistantRouter)(services));
app.use((0, autoRuns_1.buildAutoRunsRouter)(openaiClient)); app.use((0, autoRuns_1.buildAutoRunsRouter)(services, openaiClient));
app.use((0, history_1.buildHistoryRouter)()); app.use((0, history_1.buildHistoryRouter)());
app.use((0, presets_1.buildPresetsRouter)()); app.use((0, presets_1.buildPresetsRouter)());
app.use((0, accountingAgent_1.buildAccountingAgentRouter)(services)); app.use((0, accountingAgent_1.buildAccountingAgentRouter)(services));

View File

@ -11,13 +11,14 @@ import {
MANUAL_CASE_DECISION_SCHEMA_FILE, MANUAL_CASE_DECISION_SCHEMA_FILE,
REPORTS_DIR REPORTS_DIR
} from "../config"; } from "../config";
import type { AppServices } from "../serverContext";
import { ApiError, ok } from "../utils/http"; import { ApiError, ok } from "../utils/http";
import { loadCapabilitiesRegistry, resolveNearestCapabilityGroup, type CapabilityGroup } from "../services/capabilitiesRegistry"; import { loadCapabilitiesRegistry, resolveNearestCapabilityGroup, type CapabilityGroup } from "../services/capabilitiesRegistry";
import { OpenAIResponsesClient } from "../services/openaiResponsesClient"; import { OpenAIResponsesClient } from "../services/openaiResponsesClient";
type AutoRunTarget = "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0" | "unknown"; type AutoRunTarget = "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0" | "unknown";
type AutoRunTrend = "up" | "down" | "flat"; type AutoRunTrend = "up" | "down" | "flat";
type AutoGenMode = "qwen_seed" | "codex_creative"; type AutoGenMode = "qwen_seed" | "codex_creative" | "saved_user_sessions";
type ManualCaseDecision = type ManualCaseDecision =
| "covered_ok" | "covered_ok"
| "covered_but_bad_answer" | "covered_but_bad_answer"
@ -175,6 +176,7 @@ interface AutoGenHistoryRecord {
generation_id: string; generation_id: string;
created_at: string; created_at: string;
mode: AutoGenMode; mode: AutoGenMode;
title: string | null;
count: number; count: number;
domain: string | null; domain: string | null;
questions: string[]; questions: string[];
@ -188,6 +190,9 @@ interface AutoGenHistoryRecord {
prompt_fingerprint: string | null; prompt_fingerprint: string | null;
autogen_personality_id: string | null; autogen_personality_id: string | null;
autogen_personality_prompt: string | null; autogen_personality_prompt: string | null;
source_session_id?: string | null;
saved_session_file?: string | null;
saved_case_set_kind?: string | null;
} | null; } | null;
} }
@ -269,6 +274,18 @@ function clampInt(value: number | null, min: number, max: number, fallback: numb
return rounded; return rounded;
} }
function isAutoGenMode(value: unknown): value is AutoGenMode {
return value === "qwen_seed" || value === "codex_creative" || value === "saved_user_sessions";
}
function parseAutoGenTitle(value: unknown): string | null {
const title = toStringSafe(value);
if (!title) {
return null;
}
return title.slice(0, 160);
}
function parseManualCaseDecision(value: unknown, fallback: ManualCaseDecision = "needs_dialog_policy_fix"): ManualCaseDecision { function parseManualCaseDecision(value: unknown, fallback: ManualCaseDecision = "needs_dialog_policy_fix"): ManualCaseDecision {
const normalized = toStringSafe(value); const normalized = toStringSafe(value);
if (!normalized) return fallback; if (!normalized) return fallback;
@ -326,15 +343,11 @@ function readAutoGenHistory(): AutoGenHistoryRecord[] {
.map((item) => ({ .map((item) => ({
generation_id: toStringSafe(item.generation_id) ?? "", generation_id: toStringSafe(item.generation_id) ?? "",
created_at: toStringSafe(item.created_at) ?? new Date().toISOString(), created_at: toStringSafe(item.created_at) ?? new Date().toISOString(),
mode: (toStringSafe(item.mode) as AutoGenMode | null) ?? "codex_creative", mode: isAutoGenMode(toStringSafe(item.mode)) ? (toStringSafe(item.mode) as AutoGenMode) : "codex_creative",
title: parseAutoGenTitle(item.title),
count: clampInt(toNumberSafe(item.count), 1, 300, 20), count: clampInt(toNumberSafe(item.count), 1, 300, 20),
domain: toStringSafe(item.domain), domain: toStringSafe(item.domain),
questions: toArray(item.questions) questions: parseAssistantSessionQuestions(item.questions),
.map((q) => toStringSafe(q))
.filter((q): q is string => q !== null)
.map((q) => sanitizeGeneratedQuestion(q))
.filter((q) => q.length > 0)
.slice(0, 500),
generated_by: toStringSafe(item.generated_by), generated_by: toStringSafe(item.generated_by),
saved_case_set_file: toStringSafe(item.saved_case_set_file), saved_case_set_file: toStringSafe(item.saved_case_set_file),
context: toRecord(item.context) context: toRecord(item.context)
@ -349,7 +362,10 @@ function readAutoGenHistory(): AutoGenHistoryRecord[] {
autogen_personality_id: toStringSafe(toRecord(item.context)?.autogen_personality_id), autogen_personality_id: toStringSafe(toRecord(item.context)?.autogen_personality_id),
autogen_personality_prompt: toStringSafe(toRecord(item.context)?.autogen_personality_prompt) autogen_personality_prompt: toStringSafe(toRecord(item.context)?.autogen_personality_prompt)
? repairAutogenMojibake(String(toRecord(item.context)?.autogen_personality_prompt)) ? repairAutogenMojibake(String(toRecord(item.context)?.autogen_personality_prompt))
: null : null,
source_session_id: toStringSafe(toRecord(item.context)?.source_session_id),
saved_session_file: toStringSafe(toRecord(item.context)?.saved_session_file),
saved_case_set_kind: toStringSafe(toRecord(item.context)?.saved_case_set_kind)
} }
: null : null
})) }))
@ -1314,7 +1330,7 @@ function parseDecisionFilter(value: unknown): ManualCaseDecision | "all" {
function parseAutoGenMode(value: unknown): AutoGenMode { function parseAutoGenMode(value: unknown): AutoGenMode {
const normalized = toStringSafe(value)?.toLowerCase() ?? ""; const normalized = toStringSafe(value)?.toLowerCase() ?? "";
if (normalized === "qwen_seed" || normalized === "codex_creative") { if (normalized === "qwen_seed" || normalized === "codex_creative" || normalized === "saved_user_sessions") {
return normalized; return normalized;
} }
return "codex_creative"; return "codex_creative";
@ -1416,6 +1432,13 @@ function sanitizeGeneratedQuestion(value: string): string {
.trim(); .trim();
} }
function parseAssistantSessionQuestions(value: unknown): string[] {
return toArray(value)
.map((item) => sanitizeGeneratedQuestion(typeof item === "string" ? item : ""))
.filter((item) => item.length > 0)
.slice(0, 500);
}
const AUTOGEN_QUESTION_PLACEHOLDER_PATTERN = /^(?:questions?|вопросы?|список\s+вопросов)$/iu; const AUTOGEN_QUESTION_PLACEHOLDER_PATTERN = /^(?:questions?|вопросы?|список\s+вопросов)$/iu;
const AUTOGEN_QUESTION_TAIL_PATTERNS: RegExp[] = [ const AUTOGEN_QUESTION_TAIL_PATTERNS: RegExp[] = [
/^(?:без\s+воды|по\s+факту|и\s+коротко|коротко|прям(?:\s+)?сейчас|за\s+весь\s+период|по\s+делу)\??$/iu /^(?:без\s+воды|по\s+факту|и\s+коротко|коротко|прям(?:\s+)?сейчас|за\s+весь\s+период|по\s+делу)\??$/iu
@ -1723,6 +1746,19 @@ function buildAutogenCaseSetFileName(mode: AutoGenMode, generationId: string): s
return `assistant_autogen_${mode}_${stamp}_${generationId}.json`; return `assistant_autogen_${mode}_${stamp}_${generationId}.json`;
} }
function buildSavedAssistantSessionSnapshotFileName(generationId: string): string {
const now = new Date();
const stamp = [
now.getUTCFullYear(),
String(now.getUTCMonth() + 1).padStart(2, "0"),
String(now.getUTCDate()).padStart(2, "0"),
String(now.getUTCHours()).padStart(2, "0"),
String(now.getUTCMinutes()).padStart(2, "0"),
String(now.getUTCSeconds()).padStart(2, "0")
].join("");
return `assistant_saved_session_${stamp}_${generationId}.json`;
}
function buildAutogenCaseSetPayload(input: { function buildAutogenCaseSetPayload(input: {
generationId: string; generationId: string;
mode: AutoGenMode; mode: AutoGenMode;
@ -1757,6 +1793,118 @@ function buildAutogenCaseSetPayload(input: {
}; };
} }
function buildSavedSessionCaseSetPayload(input: {
generationId: string;
title: string | null;
questions: string[];
}): Record<string, unknown> {
const questions = parseAssistantSessionQuestions(input.questions);
const turns = questions.map((question) => ({
user_message: question
}));
const caseId = "SAVED-001";
return {
suite_id: `assistant_saved_session_${input.generationId}`,
suite_version: "0.1.0",
schema_version: "assistant_saved_session_suite_v0_1",
generated_at: new Date().toISOString(),
generation_id: input.generationId,
mode: "saved_user_sessions",
title: input.title,
scenario_count: turns.length > 0 ? 1 : 0,
case_ids: turns.length > 0 ? [caseId] : [],
cases:
turns.length > 0
? [
{
case_id: caseId,
scenario_tag: "saved_user_sessions",
title: input.title,
question_type: turns.length > 1 ? "followup" : "direct",
broadness_level: "medium",
turns
}
]
: []
};
}
function ensureDirSync(targetDir: string): void {
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
}
function writeJsonFile(targetPath: string, payload: unknown): void {
ensureDirSync(path.dirname(targetPath));
fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2), "utf-8");
}
function rewriteAutoGenCaseSetFile(record: AutoGenHistoryRecord): string | null {
const caseSetFile = toStringSafe(record.saved_case_set_file);
if (!caseSetFile) {
return null;
}
const targetPath = path.resolve(EVAL_CASES_DIR, caseSetFile);
const payload =
record.mode === "saved_user_sessions"
? buildSavedSessionCaseSetPayload({
generationId: record.generation_id,
title: record.title,
questions: record.questions
})
: buildAutogenCaseSetPayload({
generationId: record.generation_id,
mode: record.mode,
domain: record.domain,
questions: record.questions
});
writeJsonFile(targetPath, payload);
return caseSetFile;
}
function writeSavedAssistantSessionSnapshot(input: {
generationId: string;
sessionId: string;
title: string | null;
session: Record<string, unknown>;
questions: string[];
}): string {
const fileName = buildSavedAssistantSessionSnapshotFileName(input.generationId);
const targetPath = path.resolve(path.dirname(AUTORUN_GENERATOR_HISTORY_FILE), "saved_sessions", fileName);
writeJsonFile(targetPath, {
saved_at: new Date().toISOString(),
generation_id: input.generationId,
mode: "saved_user_sessions",
title: input.title,
source_session_id: input.sessionId,
questions: input.questions,
session: input.session
});
return fileName;
}
function resolveFileInsideDir(baseDir: string, fileName: string | null | undefined): string | null {
const normalized = toStringSafe(fileName);
if (!normalized) {
return null;
}
const targetPath = path.resolve(baseDir, normalized);
const relative = path.relative(baseDir, targetPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return null;
}
return targetPath;
}
function safeDeleteFile(targetPath: string | null): string | null {
if (!targetPath || !fs.existsSync(targetPath)) {
return null;
}
fs.unlinkSync(targetPath);
return targetPath;
}
function collectPostAnalysis( function collectPostAnalysis(
annotations: AutoRunAnnotationRecord[], annotations: AutoRunAnnotationRecord[],
runMap: Map<string, IndexedRun>, runMap: Map<string, IndexedRun>,
@ -1854,7 +2002,7 @@ function collectPostAnalysis(
}; };
} }
export function buildAutoRunsRouter(openaiClient = new OpenAIResponsesClient()): Router { export function buildAutoRunsRouter(services: AppServices, openaiClient = new OpenAIResponsesClient()): Router {
const router = Router(); const router = Router();
router.get("/api/autoruns/history", (req, res) => { router.get("/api/autoruns/history", (req, res) => {
@ -2251,7 +2399,7 @@ export function buildAutoRunsRouter(openaiClient = new OpenAIResponsesClient()):
try { try {
const limit = clampInt(toNumberSafe((req.query as Record<string, unknown>).limit), 1, 500, 120); const limit = clampInt(toNumberSafe((req.query as Record<string, unknown>).limit), 1, 500, 120);
const rawMode = toStringSafe((req.query as Record<string, unknown>).mode); const rawMode = toStringSafe((req.query as Record<string, unknown>).mode);
const includeAllModes = !rawMode || !["qwen_seed", "codex_creative"].includes(rawMode); const includeAllModes = !rawMode || !isAutoGenMode(rawMode);
const modeFilter = (rawMode as AutoGenMode | null) ?? "codex_creative"; const modeFilter = (rawMode as AutoGenMode | null) ?? "codex_creative";
const items = readAutoGenHistory() const items = readAutoGenHistory()
.filter((item) => (includeAllModes ? true : item.mode === modeFilter)) .filter((item) => (includeAllModes ? true : item.mode === modeFilter))
@ -2279,6 +2427,182 @@ export function buildAutoRunsRouter(openaiClient = new OpenAIResponsesClient()):
} }
}); });
router.post("/api/autoruns/autogen/save-assistant-session", (req, res, next) => {
try {
const body = toRecord(req.body);
if (!body) {
throw new ApiError("INVALID_AUTOGEN_SAVE_SESSION_PAYLOAD", "JSON body is required", 400);
}
const sessionId = toStringSafe(body.session_id);
const title = parseAutoGenTitle(body.title);
const generatedBy = parseAnnotationAuthor(body.generated_by);
const context = toRecord(body.context);
if (!sessionId) {
throw new ApiError("INVALID_AUTOGEN_SAVE_SESSION_PAYLOAD", "session_id is required", 400);
}
if (!title) {
throw new ApiError("INVALID_AUTOGEN_SAVE_SESSION_PAYLOAD", "title is required", 400);
}
const session = services.assistantService.getSession(sessionId);
if (!session) {
throw new ApiError("ASSISTANT_SESSION_NOT_FOUND", `Session not found: ${sessionId}`, 404);
}
const questions = session.items
.filter((item: { role: string }) => item.role === "user")
.map((item: { text: string }) => sanitizeGeneratedQuestion(item.text))
.filter((item: string) => item.length > 0);
if (questions.length === 0) {
throw new ApiError("ASSISTANT_SESSION_EMPTY", "Assistant session has no user questions to save.", 400);
}
const generationId = generateAutogenId();
const caseSetFile = buildAutogenCaseSetFileName("saved_user_sessions", generationId);
const caseSetPath = path.resolve(EVAL_CASES_DIR, caseSetFile);
writeJsonFile(
caseSetPath,
buildSavedSessionCaseSetPayload({
generationId,
title,
questions
})
);
const snapshotFile = writeSavedAssistantSessionSnapshot({
generationId,
sessionId,
title,
session: session as unknown as Record<string, unknown>,
questions
});
const record: AutoGenHistoryRecord = {
generation_id: generationId,
created_at: new Date().toISOString(),
mode: "saved_user_sessions",
title,
count: questions.length,
domain: null,
questions,
generated_by: generatedBy,
saved_case_set_file: caseSetFile,
context: {
llm_provider: toStringSafe(context?.llm_provider),
model: toStringSafe(context?.model),
assistant_prompt_version: toStringSafe(context?.assistant_prompt_version),
decomposition_prompt_version: toStringSafe(context?.decomposition_prompt_version),
prompt_fingerprint: toStringSafe(context?.prompt_fingerprint)
? repairAutogenMojibake(String(context?.prompt_fingerprint))
: null,
autogen_personality_id: null,
autogen_personality_prompt: null,
source_session_id: sessionId,
saved_session_file: snapshotFile,
saved_case_set_kind: "assistant_session_scenario"
}
};
const history = readAutoGenHistory();
history.unshift(record);
writeAutoGenHistory(history.slice(0, 500));
ok(res, {
ok: true,
generation: record
});
} catch (error) {
next(error);
}
});
router.patch("/api/autoruns/autogen/history/:generation_id/questions", (req, res, next) => {
try {
const generationId = toStringSafe(req.params.generation_id);
const body = toRecord(req.body);
if (!generationId) {
throw new ApiError("INVALID_AUTOGEN_GENERATION_ID", "generation_id is required", 400);
}
if (!body) {
throw new ApiError("INVALID_AUTOGEN_QUESTIONS_PAYLOAD", "JSON body is required", 400);
}
const questions = parseAssistantSessionQuestions(body.questions);
if (questions.length === 0) {
throw new ApiError("INVALID_AUTOGEN_QUESTIONS_PAYLOAD", "questions must contain at least one item", 400);
}
const history = readAutoGenHistory();
const targetIndex = history.findIndex((item) => item.generation_id === generationId);
if (targetIndex < 0) {
throw new ApiError("AUTOGEN_GENERATION_NOT_FOUND", `Generation not found: ${generationId}`, 404);
}
const current = history[targetIndex];
const updated: AutoGenHistoryRecord = {
...current,
count: questions.length,
questions
};
rewriteAutoGenCaseSetFile(updated);
history[targetIndex] = updated;
writeAutoGenHistory(history);
ok(res, {
ok: true,
generation: updated
});
} catch (error) {
next(error);
}
});
router.delete("/api/autoruns/autogen/history/:generation_id", (req, res, next) => {
try {
const generationId = toStringSafe(req.params.generation_id);
if (!generationId) {
throw new ApiError("INVALID_AUTOGEN_GENERATION_ID", "generation_id is required", 400);
}
const history = readAutoGenHistory();
const targetIndex = history.findIndex((item) => item.generation_id === generationId);
if (targetIndex < 0) {
throw new ApiError("AUTOGEN_GENERATION_NOT_FOUND", `Generation not found: ${generationId}`, 404);
}
const target = history[targetIndex];
const deletedFiles: string[] = [];
const caseSetPath = resolveFileInsideDir(EVAL_CASES_DIR, target.saved_case_set_file);
const savedSessionPath = resolveFileInsideDir(
path.resolve(path.dirname(AUTORUN_GENERATOR_HISTORY_FILE), "saved_sessions"),
target.context?.saved_session_file ?? null
);
const deletedCaseSet = safeDeleteFile(caseSetPath);
if (deletedCaseSet) {
deletedFiles.push(deletedCaseSet);
}
const deletedSavedSession = safeDeleteFile(savedSessionPath);
if (deletedSavedSession) {
deletedFiles.push(deletedSavedSession);
}
history.splice(targetIndex, 1);
writeAutoGenHistory(history);
ok(res, {
ok: true,
generation_id: generationId,
deleted_files: deletedFiles
});
} catch (error) {
next(error);
}
});
router.post("/api/autoruns/autogen/generate", async (req, res, next) => { router.post("/api/autoruns/autogen/generate", async (req, res, next) => {
try { try {
const body = toRecord(req.body); const body = toRecord(req.body);
@ -2294,6 +2618,14 @@ export function buildAutoRunsRouter(openaiClient = new OpenAIResponsesClient()):
const llmConfig = parseAutogenLlmRuntimeConfig(body, context); const llmConfig = parseAutogenLlmRuntimeConfig(body, context);
const personalityPrompt = toStringSafe(context?.autogen_personality_prompt); const personalityPrompt = toStringSafe(context?.autogen_personality_prompt);
if (mode === "saved_user_sessions") {
throw new ApiError(
"AUTOGEN_MODE_NOT_SUPPORTED",
"Use `/api/autoruns/autogen/save-assistant-session` to save user sessions.",
400
);
}
let questions: string[] = []; let questions: string[] = [];
if (mode === "qwen_seed") { if (mode === "qwen_seed") {
if (!llmConfig) { if (!llmConfig) {
@ -2340,6 +2672,7 @@ export function buildAutoRunsRouter(openaiClient = new OpenAIResponsesClient()):
generation_id: generationId, generation_id: generationId,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
mode, mode,
title: null,
count: questions.length, count: questions.length,
domain, domain,
questions, questions,
@ -2357,7 +2690,10 @@ export function buildAutoRunsRouter(openaiClient = new OpenAIResponsesClient()):
autogen_personality_id: toStringSafe(context.autogen_personality_id), autogen_personality_id: toStringSafe(context.autogen_personality_id),
autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt) autogen_personality_prompt: toStringSafe(context.autogen_personality_prompt)
? repairAutogenMojibake(String(context.autogen_personality_prompt)) ? repairAutogenMojibake(String(context.autogen_personality_prompt))
: null : null,
source_session_id: null,
saved_session_file: null,
saved_case_set_kind: "single_turn_list"
} }
: null : null
}; };

View File

@ -176,7 +176,7 @@ function splitQuestionCandidate(raw: string): string[] {
return normalizeRuntimeQuestionList(chunks); return normalizeRuntimeQuestionList(chunks);
} }
function normalizeRuntimeQuestions(value: unknown): string[] { function normalizeRuntimeQuestions(value: unknown, options?: { dedupe?: boolean; splitCandidates?: boolean }): string[] {
const raw = toArray(value) const raw = toArray(value)
.map((item) => (typeof item === "string" ? item.trim() : "")) .map((item) => (typeof item === "string" ? item.trim() : ""))
.filter((item) => item.length > 0); .filter((item) => item.length > 0);
@ -184,7 +184,16 @@ function normalizeRuntimeQuestions(value: unknown): string[] {
return []; return [];
} }
const expanded = normalizeRuntimeQuestionList(raw.flatMap((item) => splitQuestionCandidate(item))); const splitCandidates = options?.splitCandidates ?? true;
const expanded = splitCandidates
? normalizeRuntimeQuestionList(raw.flatMap((item) => splitQuestionCandidate(item)))
: raw
.map((item) => normalizeQuestionChunk(item))
.filter((item): item is string => Boolean(item));
const dedupe = options?.dedupe ?? true;
if (!dedupe) {
return expanded;
}
const deduped: string[] = []; const deduped: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const item of expanded) { for (const item of expanded) {
@ -342,6 +351,39 @@ function writeRuntimeAssistantSuiteFromQuestions(jobId: string, questions: strin
return fileName; return fileName;
} }
function writeRuntimeAssistantScenarioSuiteFromQuestions(jobId: string, questions: string[], title?: string): string {
if (!fs.existsSync(EVAL_CASES_DIR)) {
fs.mkdirSync(EVAL_CASES_DIR, { recursive: true });
}
const turns = questions.map((question) => ({
user_message: question
}));
const payload = {
suite_id: `assistant_saved_session_runtime_${jobId}`,
suite_version: "0.1.0",
schema_version: "assistant_saved_session_runtime_v0_1",
title: typeof title === "string" ? title.trim() || null : null,
scenario_count: turns.length > 0 ? 1 : 0,
case_ids: turns.length > 0 ? ["SAVED-001"] : [],
cases:
turns.length > 0
? [
{
case_id: "SAVED-001",
scenario_tag: "saved_user_sessions_runtime",
title: typeof title === "string" ? title.trim() || null : null,
question_type: turns.length > 1 ? "followup" : "direct",
broadness_level: "medium",
turns
}
]
: []
};
const fileName = `assistant_saved_session_runtime_${jobId}.json`;
fs.writeFileSync(path.resolve(EVAL_CASES_DIR, fileName), JSON.stringify(payload, null, 2), "utf-8");
return fileName;
}
function readSessionConversation(runId: string, caseId: string): EvalAsyncCaseInfo["messages"] { function readSessionConversation(runId: string, caseId: string): EvalAsyncCaseInfo["messages"] {
const sessionId = `${runId}-${caseId}`; const sessionId = `${runId}-${caseId}`;
const filePath = path.resolve(ASSISTANT_SESSIONS_DIR, `${sessionId}.json`); const filePath = path.resolve(ASSISTANT_SESSIONS_DIR, `${sessionId}.json`);
@ -489,11 +531,15 @@ export function buildEvalRouter(services: AppServices): Router {
throw new ApiError("UNSUPPORTED_ASYNC_EVAL_TARGET", "Async eval currently supports assistant_stage1 only.", 400); throw new ApiError("UNSUPPORTED_ASYNC_EVAL_TARGET", "Async eval currently supports assistant_stage1 only.", 400);
} }
const questions = normalizeRuntimeQuestions(body.questions); const questions = normalizeRuntimeQuestions(body.questions);
const scenarioQuestions = normalizeRuntimeQuestions(body.scenarioQuestions, { dedupe: false, splitCandidates: false });
const scenarioTitle = toStringSafe(body.scenarioTitle);
const jobId = `job-${nanoid(10)}`; const jobId = `job-${nanoid(10)}`;
const runId = `assistant-stage1-${nanoid(10)}`; const runId = `assistant-stage1-${nanoid(10)}`;
const runtimeCaseSetFile = const runtimeCaseSetFile =
questions.length > 0 scenarioQuestions.length > 0
? writeRuntimeAssistantScenarioSuiteFromQuestions(jobId, scenarioQuestions, scenarioTitle ?? undefined)
: questions.length > 0
? writeRuntimeAssistantSuiteFromQuestions(jobId, questions) ? writeRuntimeAssistantSuiteFromQuestions(jobId, questions)
: payload.caseSetFile : payload.caseSetFile
? payload.caseSetFile ? payload.caseSetFile
@ -502,7 +548,7 @@ export function buildEvalRouter(services: AppServices): Router {
if (!runtimeCaseSetFile) { if (!runtimeCaseSetFile) {
throw new ApiError( throw new ApiError(
"ASYNC_CASESET_REQUIRED", "ASYNC_CASESET_REQUIRED",
"Async assistant_stage1 run requires caseSetFile or explicit questions[] payload.", "Async assistant_stage1 run requires caseSetFile, scenarioQuestions[] or explicit questions[] payload.",
400 400
); );
} }

View File

@ -76,7 +76,7 @@ export function createApp(): express.Express {
app.use(buildNormalizeRouter(services)); app.use(buildNormalizeRouter(services));
app.use(buildEvalRouter(services)); app.use(buildEvalRouter(services));
app.use(buildAssistantRouter(services)); app.use(buildAssistantRouter(services));
app.use(buildAutoRunsRouter(openaiClient)); app.use(buildAutoRunsRouter(services, openaiClient));
app.use(buildHistoryRouter()); app.use(buildHistoryRouter());
app.use(buildPresetsRouter()); app.use(buildPresetsRouter());
app.use(buildAccountingAgentRouter(services)); app.use(buildAccountingAgentRouter(services));

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,215 @@
{
"saved_at": "2026-04-16T17:51:50.474Z",
"generation_id": "gen-mo1s0m9z-ndf56a3",
"mode": "saved_user_sessions",
"title": "Ручная сессия 16.04.2026, 20:51:30",
"source_session_id": "asst-iIpNheKZpP",
"questions": [
"привет как дела"
],
"session": {
"session_id": "asst-iIpNheKZpP",
"updated_at": "2026-04-16T17:51:30.711Z",
"items": [
{
"message_id": "msg-R2if3yso9x",
"session_id": "asst-iIpNheKZpP",
"role": "user",
"text": "привет как дела",
"reply_type": null,
"created_at": "2026-04-16T17:51:19.402Z",
"trace_id": null,
"debug": null
},
{
"message_id": "msg-tRiU0RadHy",
"session_id": "asst-iIpNheKZpP",
"role": "assistant",
"text": "Привет! У меня всё хорошо, спасибо за вопрос. А у тебя как дела? 😊",
"reply_type": "factual_with_explanation",
"created_at": "2026-04-16T17:51:30.711Z",
"trace_id": "chat-2RCiQ9Whq9",
"debug": {
"trace_id": "chat-2RCiQ9Whq9",
"prompt_version": "living_chat_router_v1",
"schema_version": "living_chat_router_v1",
"fallback_type": "none",
"detected_mode": "chat",
"detected_mode_confidence": "high",
"execution_lane": "living_chat",
"living_router_mode": "chat",
"living_router_reason": "non_domain_query_indexed",
"living_chat_response_source": "llm_chat",
"living_chat_script_guard_applied": false,
"living_chat_script_guard_reason": null,
"living_chat_grounding_guard_applied": false,
"living_chat_grounding_guard_reason": null,
"living_chat_data_scope_probe_status": null,
"living_chat_data_scope_probe_channel": null,
"living_chat_data_scope_probe_org_count": 0,
"living_chat_data_scope_probe_organizations": [],
"living_chat_data_scope_probe_error": null,
"living_chat_selected_organization": null,
"assistant_known_organizations": [],
"assistant_active_organization": null,
"address_llm_predecompose_attempted": true,
"address_llm_predecompose_applied": false,
"address_llm_predecompose_reason": "no_usable_fragment",
"address_llm_predecompose_contract": {
"schema_version": "address_llm_predecompose_contract_v1",
"source_message": "привет как дела",
"canonical_message": "привет как дела",
"mode": "unsupported",
"mode_confidence": "low",
"query_shape": "UNKNOWN",
"query_shape_confidence": "low",
"intent": "unknown",
"intent_confidence": "low",
"entities": {
"account": null,
"counterparty": null,
"contract": null,
"document_type": null,
"document_ref": null,
"organization": null
},
"period": {
"scope": "unspecified",
"period_from": null,
"period_to": null,
"as_of_date": null,
"has_explicit_period": false
},
"semantics": {
"scope_kind": "none",
"anchor_kind": "none",
"anchor_value": null,
"date_scope_kind": "none",
"date_basis_hint": null,
"self_scope_detected": false,
"selected_object_scope_detected": false
},
"aggregation_profile": "unknown"
},
"address_semantic_extraction_contract": {
"schema_version": "address_semantic_extraction_contract_v1",
"source_message": "привет как дела",
"canonical_message": "привет как дела",
"canonical_rewrite_applied": false,
"extraction": {
"mode": "unsupported",
"mode_confidence": "low",
"query_shape": "UNKNOWN",
"query_shape_confidence": "low",
"intent": "unknown",
"intent_confidence": "low",
"aggregation_profile": "unknown"
},
"entities": {
"account": null,
"counterparty": null,
"contract": null,
"document_type": null,
"document_ref": null,
"organization": null
},
"period": {
"scope": "unspecified",
"period_from": null,
"period_to": null,
"as_of_date": null,
"has_explicit_period": false
},
"semantics": {
"scope_kind": "none",
"anchor_kind": "none",
"anchor_value": null,
"date_scope_kind": "none",
"date_basis_hint": null,
"self_scope_detected": false,
"selected_object_scope_detected": false
},
"guard_hints": {
"source_data_signal_detected": false,
"canonical_data_signal_detected": false,
"data_scope_meta_query_detected": false,
"deep_investigation_signal_detected": false,
"required_anchor_missing": false,
"unsupported_low_confidence": true,
"semantic_drift_suspected": false
},
"quality": "low",
"valid": false,
"apply_canonical_recommended": false,
"reason_codes": [
"unsupported_low_confidence_contract"
]
},
"orchestration_contract_v1": {
"schema_version": "assistant_orchestration_contract_v1",
"hard_meta_mode": "non_domain",
"address_mode": "unsupported",
"address_mode_confidence": "low",
"address_intent": "unknown",
"address_intent_confidence": "low",
"strong_data_signal_detected": false,
"data_retrieval_signal_detected": false,
"followup_context_detected": false,
"unsupported_address_intent_fallback_to_deep": false,
"final_decision": {
"run_address_lane": false,
"tool_gate_decision": "skip_address_lane",
"tool_gate_reason": "non_domain_query_indexed",
"living_mode": "chat",
"living_reason": "non_domain_query_indexed"
}
},
"tool_gate_decision": "skip_address_lane",
"tool_gate_reason": "non_domain_query_indexed",
"normalized": null,
"normalizer_output": null
}
}
],
"investigation_state": {
"schema_version": "investigation_state_v1",
"session_id": "asst-iIpNheKZpP",
"status": "idle",
"turn_index": 0,
"updated_at": "2026-04-16T17:51:19.401Z",
"question_id": null,
"question_scope_id": null,
"scope_origin": null,
"focus": {
"domain": null,
"period": null,
"primary_accounts": [],
"active_query_subject": null
},
"narrowing_status": "unknown",
"evidence_refs": [],
"open_uncertainties": [],
"last_answer_mode": null,
"followup_context": null,
"query_mode_hint": "direct_answer"
},
"address_navigation_state": {
"schema_version": "address_navigation_state_v1",
"session_id": "asst-iIpNheKZpP",
"updated_at": "2026-04-16T17:51:19.401Z",
"session_context": {
"active_result_set_id": null,
"active_focus_object": null,
"last_confirmed_route": null,
"date_scope": {
"as_of_date": null,
"period_from": null,
"period_to": null
},
"organization_scope": null
},
"result_sets": [],
"navigation_history": []
}
}
}

View File

@ -0,0 +1,27 @@
{
"suite_id": "assistant_saved_session_gen-mo1s0m9z-ndf56a3",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_suite_v0_1",
"generated_at": "2026-04-16T17:51:50.471Z",
"generation_id": "gen-mo1s0m9z-ndf56a3",
"mode": "saved_user_sessions",
"title": "Ручная сессия 16.04.2026, 20:51:30",
"scenario_count": 1,
"case_ids": [
"SAVED-001"
],
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "saved_user_sessions",
"title": "Ручная сессия 16.04.2026, 20:51:30",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "привет как дела"
}
]
}
]
}

View File

@ -0,0 +1,24 @@
{
"suite_id": "assistant_saved_session_runtime_job-qvlcP-qH8S",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_runtime_v0_1",
"title": "Ручная сессия 16.04.2026, 20:51:30",
"scenario_count": 1,
"case_ids": [
"SAVED-001"
],
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "saved_user_sessions_runtime",
"title": "Ручная сессия 16.04.2026, 20:51:30",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "привет как дела"
}
]
}
]
}

View File

@ -1,6 +1,6 @@
{ {
"schema_version": "shared_llm_connection_v1", "schema_version": "shared_llm_connection_v1",
"updated_at": "2026-04-15T06:12:46.714Z", "updated_at": "2026-04-16T17:54:57.636Z",
"connection": { "connection": {
"llmProvider": "local", "llmProvider": "local",
"model": "unsloth/qwen3-30b-a3b-instruct-2507", "model": "unsloth/qwen3-30b-a3b-instruct-2507",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title> <title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-VJV2AL7G.js"></script> <script type="module" crossorigin src="/assets/index-Bw40I8e3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaUiKcE3.css"> <link rel="stylesheet" crossorigin href="/assets/index-DkWsdP2H.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,22 +1,16 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { apiClient } from "./api/client"; import { apiClient } from "./api/client";
import { AssistantSamPanel } from "./components/AssistantSamPanel";
import { AutoRunsHistoryPanel } from "./components/AutoRunsHistoryPanel"; import { AutoRunsHistoryPanel } from "./components/AutoRunsHistoryPanel";
import { AssistantPanel } from "./components/AssistantPanel";
import { ConnectionPanel } from "./components/ConnectionPanel"; import { ConnectionPanel } from "./components/ConnectionPanel";
import { HistoryPanel } from "./components/HistoryPanel"; import { HistoryPanel } from "./components/HistoryPanel";
import { MetricsPanel } from "./components/MetricsPanel"; import { MetricsPanel } from "./components/MetricsPanel";
import { OutputPanel } from "./components/OutputPanel"; import { OutputPanel } from "./components/OutputPanel";
import { PanelFrame } from "./components/PanelFrame";
import { PromptPanel } from "./components/PromptPanel"; import { PromptPanel } from "./components/PromptPanel";
import { QueryPanel } from "./components/QueryPanel"; import { QueryPanel } from "./components/QueryPanel";
import { RuntimePanel } from "./components/RuntimePanel"; import { RuntimePanel } from "./components/RuntimePanel";
import { DEFAULT_CONNECTION, DEFAULT_PROMPTS, DEFAULT_QUERY } from "./state/defaults"; import { DEFAULT_CONNECTION, DEFAULT_PROMPTS, DEFAULT_QUERY } from "./state/defaults";
import { designConfig } from "../../../designconfig"; import { designConfig } from "../../../designconfig";
import type { import type {
AssistantConversationItem,
AssistantAnnotationRecord,
AssistantSelectionChip,
ConnectionState, ConnectionState,
HistoryItem, HistoryItem,
NormalizeResultState, NormalizeResultState,
@ -30,22 +24,10 @@ import type {
const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1"; const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1";
const AUTORUNS_LAYOUT_CONFIG_KEY = "ndc_autoruns_layout_config_v1"; const AUTORUNS_LAYOUT_CONFIG_KEY = "ndc_autoruns_layout_config_v1";
const AUTORUNS_SAVE_EVENT = "ndc-autoruns-save"; const AUTORUNS_SAVE_EVENT = "ndc-autoruns-save";
const ASSISTANT_STAGES = ["Анализ запроса", "Получение данных", "Подготовка ответа"];
const DEFAULT_UI_MODE: UiMode = "autoruns"; const DEFAULT_UI_MODE: UiMode = "autoruns";
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2"; const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1"; const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
const TAB_KEYS: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"]; const TAB_KEYS: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"];
const DEFAULT_ASSISTANT_ANNOTATION_AUTHOR = "manual_reviewer";
interface AssistantCommentModalState {
open: boolean;
messageIndex: number;
rating: number;
comment: string;
annotationAuthor: string;
saving: boolean;
error: string;
}
function withTs(message: string): string { function withTs(message: string): string {
return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`; return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`;
@ -61,25 +43,6 @@ function diffPrompts(current: PromptState, previous: PromptState | null): string
return `Changed fields: ${changed.length}. ${changed.join(" | ")}`; return `Changed fields: ${changed.length}. ${changed.join(" | ")}`;
} }
function buildAssistantFollowupMessage(inputValue: string, selectedChip: AssistantSelectionChip | null): string {
const trimmedInput = inputValue.trim();
if (!trimmedInput) {
return "";
}
if (!selectedChip) {
return trimmedInput;
}
const normalizedInput = trimmedInput.toLowerCase();
const selectionAnchor = selectedChip.anchor_text.trim();
const normalizedSelection = selectionAnchor.toLowerCase();
if (normalizedSelection && normalizedInput.includes(normalizedSelection)) {
return trimmedInput;
}
return `По выбранному объекту "${selectionAnchor}": ${trimmedInput}`;
}
export default function App() { export default function App() {
const [connection, setConnection] = useState<ConnectionState>(DEFAULT_CONNECTION); const [connection, setConnection] = useState<ConnectionState>(DEFAULT_CONNECTION);
const [prompts, setPrompts] = useState<PromptState>(DEFAULT_PROMPTS); const [prompts, setPrompts] = useState<PromptState>(DEFAULT_PROMPTS);
@ -123,11 +86,6 @@ export default function App() {
const [showAutorunsDecompositionMode, setShowAutorunsDecompositionMode] = useState(true); const [showAutorunsDecompositionMode, setShowAutorunsDecompositionMode] = useState(true);
const [showAutorunsProgressMode, setShowAutorunsProgressMode] = useState(true); const [showAutorunsProgressMode, setShowAutorunsProgressMode] = useState(true);
const [showAutorunsCommentsMode, setShowAutorunsCommentsMode] = useState(true); const [showAutorunsCommentsMode, setShowAutorunsCommentsMode] = useState(true);
const [showAssistantConnectionMode, setShowAssistantConnectionMode] = useState(true);
const [showAssistantPromptMode, setShowAssistantPromptMode] = useState(true);
const [showAssistantChatMode, setShowAssistantChatMode] = useState(true);
const [showAssistantCommentsMode, setShowAssistantCommentsMode] = useState(true);
const [showAssistantSamMode, setShowAssistantSamMode] = useState(true);
const [showDecompositionConnectionMode, setShowDecompositionConnectionMode] = useState(true); const [showDecompositionConnectionMode, setShowDecompositionConnectionMode] = useState(true);
const [showDecompositionPromptMode, setShowDecompositionPromptMode] = useState(true); const [showDecompositionPromptMode, setShowDecompositionPromptMode] = useState(true);
const [showDecompositionQueryMode, setShowDecompositionQueryMode] = useState(true); const [showDecompositionQueryMode, setShowDecompositionQueryMode] = useState(true);
@ -135,24 +93,6 @@ export default function App() {
const [showDecompositionMetricsMode, setShowDecompositionMetricsMode] = useState(true); const [showDecompositionMetricsMode, setShowDecompositionMetricsMode] = useState(true);
const [showDecompositionHistoryMode, setShowDecompositionHistoryMode] = useState(true); const [showDecompositionHistoryMode, setShowDecompositionHistoryMode] = useState(true);
const [showDecompositionRuntimeMode, setShowDecompositionRuntimeMode] = useState(true); const [showDecompositionRuntimeMode, setShowDecompositionRuntimeMode] = useState(true);
const [assistantSessionId, setAssistantSessionId] = useState("");
const [assistantConversation, setAssistantConversation] = useState<AssistantConversationItem[]>([]);
const [assistantInput, setAssistantInput] = useState("");
const [assistantSelectedChip, setAssistantSelectedChip] = useState<AssistantSelectionChip | null>(null);
const [assistantBusy, setAssistantBusy] = useState(false);
const [assistantStatus, setAssistantStatus] = useState("");
const [assistantError, setAssistantError] = useState("");
const [assistantAnnotations, setAssistantAnnotations] = useState<AssistantAnnotationRecord[]>([]);
const [assistantAnnotationsBusy, setAssistantAnnotationsBusy] = useState(false);
const [assistantCommentModal, setAssistantCommentModal] = useState<AssistantCommentModalState>({
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
});
const presetAutoloadDoneRef = useRef(false); const presetAutoloadDoneRef = useRef(false);
const skipPresetAutoloadRef = useRef(false); const skipPresetAutoloadRef = useRef(false);
const sharedConnectionSyncReadyRef = useRef(false); const sharedConnectionSyncReadyRef = useRef(false);
@ -184,16 +124,6 @@ export default function App() {
setAppLogs((prev) => [withTs(message), ...prev].slice(0, 300)); setAppLogs((prev) => [withTs(message), ...prev].slice(0, 300));
}; };
function startAssistantStatusTicker(): () => void {
let index = 0;
setAssistantStatus(ASSISTANT_STAGES[0]);
const timer = window.setInterval(() => {
index = Math.min(index + 1, ASSISTANT_STAGES.length - 1);
setAssistantStatus(ASSISTANT_STAGES[index]);
}, 650);
return () => window.clearInterval(timer);
}
useEffect(() => { useEffect(() => {
const bootstrapSharedConnection = async () => { const bootstrapSharedConnection = async () => {
const cached = localStorage.getItem(SESSION_CONFIG_KEY); const cached = localStorage.getItem(SESSION_CONFIG_KEY);
@ -239,7 +169,7 @@ export default function App() {
if (cachedAutorunsLayout) { if (cachedAutorunsLayout) {
try { try {
const parsed = JSON.parse(cachedAutorunsLayout) as { const parsed = JSON.parse(cachedAutorunsLayout) as {
uiMode?: UiMode; uiMode?: UiMode | "assistant";
activeTab?: TabKey; activeTab?: TabKey;
showAutorunsSettingsMode?: boolean; showAutorunsSettingsMode?: boolean;
showAutorunsAutoRunsMode?: boolean; showAutorunsAutoRunsMode?: boolean;
@ -247,11 +177,6 @@ export default function App() {
showAutorunsDecompositionMode?: boolean; showAutorunsDecompositionMode?: boolean;
showAutorunsProgressMode?: boolean; showAutorunsProgressMode?: boolean;
showAutorunsCommentsMode?: boolean; showAutorunsCommentsMode?: boolean;
showAssistantConnectionMode?: boolean;
showAssistantPromptMode?: boolean;
showAssistantChatMode?: boolean;
showAssistantCommentsMode?: boolean;
showAssistantSamMode?: boolean;
showDecompositionConnectionMode?: boolean; showDecompositionConnectionMode?: boolean;
showDecompositionPromptMode?: boolean; showDecompositionPromptMode?: boolean;
showDecompositionQueryMode?: boolean; showDecompositionQueryMode?: boolean;
@ -261,9 +186,7 @@ export default function App() {
showDecompositionRuntimeMode?: boolean; showDecompositionRuntimeMode?: boolean;
prompts?: PromptState; prompts?: PromptState;
}; };
if (parsed.uiMode === "decomposition") { if (parsed.uiMode === "assistant" || parsed.uiMode === "autoruns" || parsed.uiMode === "decomposition") {
setUiMode("decomposition");
} else if (parsed.uiMode === "assistant" || parsed.uiMode === "autoruns") {
setUiMode("autoruns"); setUiMode("autoruns");
} }
if (parsed.activeTab && TAB_KEYS.includes(parsed.activeTab)) { if (parsed.activeTab && TAB_KEYS.includes(parsed.activeTab)) {
@ -287,21 +210,6 @@ export default function App() {
if (typeof parsed.showAutorunsCommentsMode === "boolean") { if (typeof parsed.showAutorunsCommentsMode === "boolean") {
setShowAutorunsCommentsMode(parsed.showAutorunsCommentsMode); setShowAutorunsCommentsMode(parsed.showAutorunsCommentsMode);
} }
if (typeof parsed.showAssistantConnectionMode === "boolean") {
setShowAssistantConnectionMode(parsed.showAssistantConnectionMode);
}
if (typeof parsed.showAssistantPromptMode === "boolean") {
setShowAssistantPromptMode(parsed.showAssistantPromptMode);
}
if (typeof parsed.showAssistantChatMode === "boolean") {
setShowAssistantChatMode(parsed.showAssistantChatMode);
}
if (typeof parsed.showAssistantCommentsMode === "boolean") {
setShowAssistantCommentsMode(parsed.showAssistantCommentsMode);
}
if (typeof parsed.showAssistantSamMode === "boolean") {
setShowAssistantSamMode(parsed.showAssistantSamMode);
}
if (typeof parsed.showDecompositionConnectionMode === "boolean") { if (typeof parsed.showDecompositionConnectionMode === "boolean") {
setShowDecompositionConnectionMode(parsed.showDecompositionConnectionMode); setShowDecompositionConnectionMode(parsed.showDecompositionConnectionMode);
} }
@ -448,11 +356,6 @@ export default function App() {
showAutorunsDecompositionMode, showAutorunsDecompositionMode,
showAutorunsProgressMode, showAutorunsProgressMode,
showAutorunsCommentsMode, showAutorunsCommentsMode,
showAssistantConnectionMode,
showAssistantPromptMode,
showAssistantChatMode,
showAssistantCommentsMode,
showAssistantSamMode,
showDecompositionConnectionMode, showDecompositionConnectionMode,
showDecompositionPromptMode, showDecompositionPromptMode,
showDecompositionQueryMode, showDecompositionQueryMode,
@ -740,207 +643,6 @@ export default function App() {
} }
} }
const assistantAnnotationsByMessageId = useMemo(() => {
const map = new Map<string, AssistantAnnotationRecord>();
for (const item of assistantAnnotations) {
if (item.message_id) {
map.set(item.message_id, item);
}
}
return map;
}, [assistantAnnotations]);
const assistantCommentModalMessage =
assistantCommentModal.messageIndex >= 0 ? assistantConversation[assistantCommentModal.messageIndex] ?? null : null;
const assistantCommentModalQuestion = useMemo(() => {
if (assistantCommentModal.messageIndex < 0) return null;
for (let index = assistantCommentModal.messageIndex - 1; index >= 0; index -= 1) {
const candidate = assistantConversation[index];
if (candidate?.role === "user") {
return candidate;
}
}
return null;
}, [assistantCommentModal.messageIndex, assistantConversation]);
async function loadAssistantAnnotationsForSession(sessionId: string): Promise<void> {
if (!sessionId.trim()) {
setAssistantAnnotations([]);
return;
}
setAssistantAnnotationsBusy(true);
try {
const payload = await apiClient.loadAssistantAnnotations({
session_id: sessionId,
limit: 400
});
setAssistantAnnotations(payload.items ?? []);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Assistant annotations load error: ${message}`);
} finally {
setAssistantAnnotationsBusy(false);
}
}
function closeAssistantCommentModal(options?: { force?: boolean }) {
setAssistantCommentModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
};
});
}
function openAssistantCommentModal(item: AssistantConversationItem, index: number): void {
if (item.role !== "assistant") return;
const sessionIdFromState = assistantSessionId.trim();
const sessionIdFromItem = String(item.session_id ?? "").trim();
const resolvedSessionId = sessionIdFromState || sessionIdFromItem;
if (!resolvedSessionId) {
setAssistantError("Сначала получите ответ ассистента в активной сессии.");
return;
}
if (!sessionIdFromState && sessionIdFromItem) {
setAssistantSessionId(sessionIdFromItem);
}
const existing = assistantAnnotationsByMessageId.get(item.message_id) ?? null;
setAssistantCommentModal({
open: true,
messageIndex: index,
rating: existing?.rating ?? 3,
comment: existing?.comment ?? "",
annotationAuthor: existing?.annotation_author ?? DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
});
}
function canCommentAssistantMessage(item: AssistantConversationItem): boolean {
return item.role === "assistant";
}
function isAssistantMessageCommented(item: AssistantConversationItem): boolean {
return item.role === "assistant" && assistantAnnotationsByMessageId.has(item.message_id);
}
async function submitAssistantCommentModal(): Promise<void> {
if (!assistantSessionId.trim()) {
setAssistantCommentModal((prev) => ({ ...prev, error: "Сессия ассистента не найдена." }));
return;
}
if (assistantCommentModal.messageIndex < 0) {
return;
}
if (!assistantCommentModal.comment.trim()) {
setAssistantCommentModal((prev) => ({ ...prev, error: "Добавьте комментарий." }));
return;
}
setAssistantCommentModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.saveAssistantAnnotation({
session_id: assistantSessionId,
message_index: assistantCommentModal.messageIndex,
rating: assistantCommentModal.rating,
comment: assistantCommentModal.comment.trim(),
annotation_author: assistantCommentModal.annotationAuthor.trim() || undefined
});
setAssistantAnnotations((prev) => {
const next = [...prev];
const index = next.findIndex((item) => item.annotation_id === payload.annotation.annotation_id);
if (index >= 0) {
next[index] = payload.annotation;
} else {
next.unshift(payload.annotation);
}
return next.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
});
closeAssistantCommentModal({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantCommentModal((prev) => ({ ...prev, saving: false, error: message }));
}
}
function resetAssistantSession() {
setAssistantSessionId("");
setAssistantConversation([]);
setAssistantInput("");
setAssistantSelectedChip(null);
setAssistantStatus("");
setAssistantError("");
setAssistantAnnotations([]);
closeAssistantCommentModal({ force: true });
log("Assistant session reset.");
}
async function sendAssistantMessage() {
const userMessage = buildAssistantFollowupMessage(assistantInput, assistantSelectedChip);
if (!userMessage) {
return;
}
setAssistantBusy(true);
setAssistantError("");
setAssistantInput("");
setAssistantConversation((prev) => [
...prev,
{
message_id: `local-${Date.now()}`,
session_id: assistantSessionId || "pending",
role: "user",
text: userMessage,
reply_type: null,
created_at: new Date().toISOString(),
trace_id: null,
debug: null
}
]);
const stopTicker = startAssistantStatusTicker();
try {
const response = await apiClient.sendAssistantMessage({
connection,
prompts,
userMessage,
sessionId: assistantSessionId || undefined,
promptVersion: ASSISTANT_PROMPT_VERSION,
useMock
});
setAssistantSessionId(response.session_id);
setAssistantConversation(response.conversation);
setAssistantStatus("Ответ готов");
await loadAssistantAnnotationsForSession(response.session_id);
log(`Assistant reply received: trace=${response.debug.trace_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantError(message);
setAssistantStatus("Ошибка ассистента");
log(`Assistant error: ${message}`);
} finally {
stopTicker();
setAssistantBusy(false);
}
}
useEffect(() => {
if (!assistantSessionId.trim()) {
setAssistantAnnotations([]);
return;
}
void loadAssistantAnnotationsForSession(assistantSessionId);
}, [assistantSessionId]);
useEffect(() => { useEffect(() => {
if (!selectedRunId) { if (!selectedRunId) {
setRunTrace([]); setRunTrace([]);
@ -953,99 +655,17 @@ export default function App() {
}, [selectedRunId]); }, [selectedRunId]);
return ( return (
<main <main className="app-root app-root-autoruns">
className={`app-root ${
uiMode === "assistant" || uiMode === "decomposition" || uiMode === "autoruns" ? "app-root-autoruns" : ""
}`}
>
<header className="app-topbar"> <header className="app-topbar">
<div className="mode-switch-row"> <div className="mode-switch-row">
<button type="button" className={uiMode === "autoruns" ? "tab active" : "tab"} onClick={() => setUiMode("autoruns")}> <button type="button" className="tab active" onClick={() => setUiMode("autoruns")}>
Управление ассистентом Управление ассистентом
</button> </button>
<button type="button" className={uiMode === "decomposition" ? "tab active" : "tab"} onClick={() => setUiMode("decomposition")}>
Декомпозиция
</button>
<button type="button" className="tab" onClick={saveAutorunsLayout}> <button type="button" className="tab" onClick={saveAutorunsLayout}>
Сохранить Сохранить
</button> </button>
</div> </div>
{uiMode === "assistant" ? (
<div className="mode-switch-row mode-switch-row-right">
<button
type="button"
className={showAssistantConnectionMode ? "tab active" : "tab"}
onClick={() => setShowAssistantConnectionMode((prev) => !prev)}
>
LLM Connector
</button>
<button
type="button"
className={showAssistantPromptMode ? "tab active" : "tab"}
onClick={() => setShowAssistantPromptMode((prev) => !prev)}
>
Prompt Manager
</button>
<button type="button" className={showAssistantChatMode ? "tab active" : "tab"} onClick={() => setShowAssistantChatMode((prev) => !prev)}>
Режим ассистента
</button>
<button
type="button"
className={showAssistantCommentsMode ? "tab active" : "tab"}
onClick={() => setShowAssistantCommentsMode((prev) => !prev)}
>
Комментарии ассистента
</button>
<button type="button" className={showAssistantSamMode ? "tab active" : "tab"} onClick={() => setShowAssistantSamMode((prev) => !prev)}>
SAM
</button>
</div>
) : uiMode === "decomposition" ? (
<div className="mode-switch-row mode-switch-row-right">
<button
type="button"
className={showDecompositionConnectionMode ? "tab active" : "tab"}
onClick={() => setShowDecompositionConnectionMode((prev) => !prev)}
>
LLM
</button>
<button
type="button"
className={showDecompositionPromptMode ? "tab active" : "tab"}
onClick={() => setShowDecompositionPromptMode((prev) => !prev)}
>
Prompt
</button>
<button type="button" className={showDecompositionQueryMode ? "tab active" : "tab"} onClick={() => setShowDecompositionQueryMode((prev) => !prev)}>
Запрос
</button>
<button type="button" className={showDecompositionOutputMode ? "tab active" : "tab"} onClick={() => setShowDecompositionOutputMode((prev) => !prev)}>
Выход
</button>
<button
type="button"
className={showDecompositionMetricsMode ? "tab active" : "tab"}
onClick={() => setShowDecompositionMetricsMode((prev) => !prev)}
>
Метрики
</button>
<button
type="button"
className={showDecompositionHistoryMode ? "tab active" : "tab"}
onClick={() => setShowDecompositionHistoryMode((prev) => !prev)}
>
История
</button>
<button
type="button"
className={showDecompositionRuntimeMode ? "tab active" : "tab"}
onClick={() => setShowDecompositionRuntimeMode((prev) => !prev)}
>
NDC Run Monitor
</button>
</div>
) : uiMode === "autoruns" ? (
<div className="mode-switch-row mode-switch-row-right"> <div className="mode-switch-row mode-switch-row-right">
<button <button
type="button" type="button"
@ -1068,13 +688,6 @@ export default function App() {
> >
Режим ассистента Режим ассистента
</button> </button>
<button
type="button"
className={showAutorunsDecompositionMode ? "tab active" : "tab"}
onClick={() => setShowAutorunsDecompositionMode((prev) => !prev)}
>
Режим декомпозиции
</button>
<button <button
type="button" type="button"
className={showAutorunsProgressMode ? "tab active" : "tab"} className={showAutorunsProgressMode ? "tab active" : "tab"}
@ -1090,238 +703,8 @@ export default function App() {
Комментарии Комментарии
</button> </button>
</div> </div>
) : null}
</header> </header>
{uiMode === "assistant" ? (
<div className="layout-grid layout-grid-mode-columns">
<div className="mode-columns">
{showAssistantConnectionMode ? (
<div className="mode-col">
<ConnectionPanel
value={connection}
modelOptions={modelOptions}
modelsBusy={modelsBusy}
onChange={setConnection}
onReloadModels={reloadModels}
onSaveLocalConfig={saveLocalConfig}
onTestConnection={testConnection}
lastStatus={connectionStatus}
busy={busy || assistantBusy}
/>
</div>
) : null}
{showAssistantPromptMode ? (
<div className="mode-col mode-col-wide">
<PromptPanel
value={prompts}
onChange={setPrompts}
presets={presetList}
selectedPresetId={selectedPresetId}
onSelectPreset={setSelectedPresetId}
onLoadPreset={loadSelectedPreset}
onSavePreset={savePreset}
onResetDefaults={resetDefaults}
onDiffPrevious={diffWithPrevious}
presetName={presetName}
onPresetNameChange={setPresetName}
diffSummary={diffSummary}
/>
</div>
) : null}
{showAssistantChatMode ? (
<div className="mode-col mode-col-xwide">
<AssistantPanel
sessionId={assistantSessionId}
conversation={assistantConversation}
inputValue={assistantInput}
onInputChange={setAssistantInput}
selectedContextChip={assistantSelectedChip}
onSelectContextChip={setAssistantSelectedChip}
onClearContextChip={() => setAssistantSelectedChip(null)}
useMock={useMock}
onUseMockChange={setUseMock}
onSend={sendAssistantMessage}
onClear={resetAssistantSession}
busy={assistantBusy}
statusText={assistantStatus}
errorMessage={assistantError}
showCommentAction
onCommentAssistantMessage={openAssistantCommentModal}
isAssistantMessageCommented={isAssistantMessageCommented}
canCommentAssistantMessage={canCommentAssistantMessage}
/>
</div>
) : null}
{showAssistantCommentsMode ? (
<div className="mode-col">
<PanelFrame className="assistant-comments-frame" title="Комментарии ассистента">
<div className="assistant-comments-shell">
<div className="assistant-comments-toolbar">
<span className="muted">
{assistantSessionId ? `session: ${assistantSessionId}` : "Сессия не запущена"}
</span>
<button
type="button"
className="tab"
onClick={() => void loadAssistantAnnotationsForSession(assistantSessionId)}
disabled={!assistantSessionId || assistantAnnotationsBusy}
>
{assistantAnnotationsBusy ? "Обновляю..." : "Обновить"}
</button>
</div>
<div className="assistant-comments-list">
{!assistantSessionId ? <p className="muted">Появится после первого ответа ассистента.</p> : null}
{assistantSessionId && assistantAnnotations.length === 0 && !assistantAnnotationsBusy ? (
<p className="muted">Комментариев по этой сессии пока нет.</p>
) : null}
{assistantAnnotations.map((item) => (
<article key={item.annotation_id} className="assistant-comment-item">
<div className="assistant-comment-head">
<strong>{`${"●".repeat(Math.max(1, Math.min(5, Math.round(item.rating))))}${"○".repeat(Math.max(0, 5 - Math.round(item.rating)))}`}</strong>
<span>{new Date(item.updated_at).toLocaleString("ru-RU")}</span>
</div>
{item.context.question_text ? <p>Q: {item.context.question_text}</p> : null}
{item.context.answer_text ? <p>A: {item.context.answer_text}</p> : null}
<p>{item.comment}</p>
<div className="assistant-comment-meta">
{item.context.trace_id ? <span>{`trace=${item.context.trace_id}`}</span> : null}
{item.context.reply_type ? <span>{`reply_type=${item.context.reply_type}`}</span> : null}
</div>
</article>
))}
</div>
</div>
</PanelFrame>
</div>
) : null}
{showAssistantSamMode ? (
<div className="mode-col">
<AssistantSamPanel
sessionId={assistantSessionId}
conversation={assistantConversation}
statusText={assistantStatus}
errorMessage={assistantError}
useMock={useMock}
appLogs={appLogs}
/>
</div>
) : null}
{!showAssistantConnectionMode &&
!showAssistantPromptMode &&
!showAssistantChatMode &&
!showAssistantCommentsMode &&
!showAssistantSamMode ? (
<div className="mode-columns-empty">Все панели режима ассистента скрыты. Включите нужные блоки справа в шапке.</div>
) : null}
</div>
</div>
) : uiMode === "decomposition" ? (
<div className="layout-grid layout-grid-mode-columns">
<div className="mode-columns">
{showDecompositionConnectionMode ? (
<div className="mode-col">
<ConnectionPanel
value={connection}
modelOptions={modelOptions}
modelsBusy={modelsBusy}
onChange={setConnection}
onReloadModels={reloadModels}
onSaveLocalConfig={saveLocalConfig}
onTestConnection={testConnection}
lastStatus={connectionStatus}
busy={busy}
/>
</div>
) : null}
{showDecompositionPromptMode ? (
<div className="mode-col mode-col-wide">
<PromptPanel
value={prompts}
onChange={setPrompts}
presets={presetList}
selectedPresetId={selectedPresetId}
onSelectPreset={setSelectedPresetId}
onLoadPreset={loadSelectedPreset}
onSavePreset={savePreset}
onResetDefaults={resetDefaults}
onDiffPrevious={diffWithPrevious}
presetName={presetName}
onPresetNameChange={setPresetName}
diffSummary={diffSummary}
/>
</div>
) : null}
{showDecompositionQueryMode ? (
<div className="mode-col">
<QueryPanel
value={query}
onChange={setQuery}
onApplyBatchFormat={applyBatchFormat}
onNormalize={normalize}
busy={busy}
useMock={useMock}
onUseMockChange={setUseMock}
errorMessage={lastError}
/>
</div>
) : null}
{showDecompositionOutputMode ? (
<div className="mode-col mode-col-xwide">
<OutputPanel tab={activeTab} onTabChange={setActiveTab} result={result} appLogs={appLogs} />
</div>
) : null}
{showDecompositionMetricsMode ? (
<div className="mode-col">
<MetricsPanel result={result} />
</div>
) : null}
{showDecompositionHistoryMode ? (
<div className="mode-col">
<HistoryPanel items={historyItems} onRefresh={refreshHistory} onOpenTrace={openTrace} />
</div>
) : null}
{showDecompositionRuntimeMode ? (
<div className="mode-col mode-col-xwide">
<RuntimePanel
runs={runs}
selectedRunId={selectedRunId}
onSelectRun={setSelectedRunId}
onStartRun={startRun}
onFinishRun={finishRun}
onRefreshRuns={refreshRuns}
onRunEval={runEval}
onCopyEvalReport={copyEvalReport}
evalBusy={evalBusy}
traceItems={runTrace}
evalReport={evalReport}
/>
</div>
) : null}
{!showDecompositionConnectionMode &&
!showDecompositionPromptMode &&
!showDecompositionQueryMode &&
!showDecompositionOutputMode &&
!showDecompositionMetricsMode &&
!showDecompositionHistoryMode &&
!showDecompositionRuntimeMode ? (
<div className="mode-columns-empty">Все панели режима декомпозиции скрыты. Включите нужные блоки справа в шапке.</div>
) : null}
</div>
</div>
) : (
<div className="layout-grid layout-grid-autoruns"> <div className="layout-grid layout-grid-autoruns">
<AutoRunsHistoryPanel <AutoRunsHistoryPanel
connection={connection} connection={connection}
@ -1350,96 +733,11 @@ export default function App() {
showSettingsMode={showAutorunsSettingsMode} showSettingsMode={showAutorunsSettingsMode}
showAutoRunsMode={showAutorunsAutoRunsMode} showAutoRunsMode={showAutorunsAutoRunsMode}
showAssistantMode={showAutorunsAssistantMode} showAssistantMode={showAutorunsAssistantMode}
showDecompositionMode={showAutorunsDecompositionMode}
showProgressMode={showAutorunsProgressMode} showProgressMode={showAutorunsProgressMode}
showCommentsMode={showAutorunsCommentsMode} showCommentsMode={showAutorunsCommentsMode}
onLog={log} onLog={log}
/> />
</div> </div>
)}
{assistantCommentModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAssistantCommentModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Комментарий к ответу ассистента</h3>
<p className="muted">Эта разметка хранится отдельно от комментариев автопрогонов.</p>
{assistantCommentModalQuestion ? (
<details className="autoruns-prompt-details" open>
<summary>Вопрос пользователя</summary>
<p className="autoruns-comment-quote">{assistantCommentModalQuestion.text}</p>
</details>
) : null}
{assistantCommentModalMessage ? (
<details className="autoruns-prompt-details" open>
<summary>Ответ ассистента</summary>
<p className="autoruns-comment-quote">{assistantCommentModalMessage.text}</p>
</details>
) : null}
<div className="autoruns-rating-row" role="group" aria-label="Рейтинг ответа">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
className={assistantCommentModal.rating >= value ? "autoruns-rating-dot active" : "autoruns-rating-dot"}
onClick={() => setAssistantCommentModal((prev) => ({ ...prev, rating: value }))}
disabled={assistantCommentModal.saving}
aria-label={`Оценка ${value}`}
>
{assistantCommentModal.rating >= value ? "●" : "○"}
</button>
))}
</div>
<div className="autoruns-form-grid">
<label>
Автор комментария
<input
value={assistantCommentModal.annotationAuthor}
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, annotationAuthor: event.target.value }))}
placeholder="manual_reviewer"
disabled={assistantCommentModal.saving}
/>
</label>
</div>
<label>
Комментарий
<textarea
value={assistantCommentModal.comment}
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="Что именно не так в ответе и что проверить."
rows={4}
disabled={assistantCommentModal.saving}
/>
</label>
{assistantCommentModal.error ? <p className="error-text">{assistantCommentModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAssistantCommentModal()} disabled={assistantCommentModal.saving}>
{assistantCommentModal.saving ? "Сохраняю..." : "Готово"}
</button>
<button
type="button"
className="tab"
onClick={() => closeAssistantCommentModal()}
disabled={assistantCommentModal.saving}
>
Отмена
</button>
</div>
</div>
</div>
) : null}
</main> </main>
); );
} }

View File

@ -3,6 +3,7 @@ import type {
AsyncEvalRunStatusResponse, AsyncEvalRunStatusResponse,
AutoGenPersonalityCatalogResponse, AutoGenPersonalityCatalogResponse,
AutoGenHistoryResponse, AutoGenHistoryResponse,
AutoGenHistoryRecord,
AutoGenMode, AutoGenMode,
AutoRunAnnotationsResponse, AutoRunAnnotationsResponse,
AutoRunAnnotationRecord, AutoRunAnnotationRecord,
@ -230,6 +231,8 @@ export const apiClient = {
evalTarget?: "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0"; evalTarget?: "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0";
compareWithReportFile?: string; compareWithReportFile?: string;
questions?: string[]; questions?: string[];
scenarioQuestions?: string[];
scenarioTitle?: string;
analysisDate?: string; analysisDate?: string;
}): Promise<AsyncEvalRunStartResponse> { }): Promise<AsyncEvalRunStartResponse> {
return request("/eval/run-async/start", { return request("/eval/run-async/start", {
@ -256,6 +259,8 @@ export const apiClient = {
eval_target: input.evalTarget, eval_target: input.evalTarget,
compare_with_report_file: input.compareWithReportFile, compare_with_report_file: input.compareWithReportFile,
questions: input.questions, questions: input.questions,
scenarioQuestions: input.scenarioQuestions,
scenarioTitle: input.scenarioTitle,
analysis_date: input.analysisDate analysis_date: input.analysisDate
}) })
}); });
@ -342,6 +347,24 @@ export const apiClient = {
return request(`/assistant/session/${sessionId}`); return request(`/assistant/session/${sessionId}`);
}, },
async saveAutoRunAssistantSession(input: {
session_id: string;
title: string;
generated_by?: string;
context?: {
llm_provider?: string;
model?: string;
assistant_prompt_version?: string;
decomposition_prompt_version?: string;
prompt_fingerprint?: string;
};
}): Promise<{ ok: boolean; generation: AutoGenHistoryRecord }> {
return request("/autoruns/autogen/save-assistant-session", {
method: "POST",
body: JSON.stringify(input)
});
},
async loadAssistantAnnotations(input?: { async loadAssistantAnnotations(input?: {
session_id?: string; session_id?: string;
limit?: number; limit?: number;
@ -485,6 +508,28 @@ export const apiClient = {
return request("/autoruns/autogen/personality-catalog"); return request("/autoruns/autogen/personality-catalog");
}, },
async updateAutoRunAutogenQuestions(input: {
generation_id: string;
questions: string[];
}): Promise<{ ok: boolean; generation: AutoGenHistoryRecord }> {
return request(`/autoruns/autogen/history/${encodeURIComponent(input.generation_id)}/questions`, {
method: "PATCH",
body: JSON.stringify({
questions: input.questions
})
});
},
async deleteAutoRunAutogenHistoryRecord(generationId: string): Promise<{
ok: boolean;
generation_id: string;
deleted_files: string[];
}> {
return request(`/autoruns/autogen/history/${encodeURIComponent(generationId)}`, {
method: "DELETE"
});
},
async generateAutoRunQuestions(input: { async generateAutoRunQuestions(input: {
mode: AutoGenMode; mode: AutoGenMode;
count: number; count: number;
@ -508,7 +553,7 @@ export const apiClient = {
autogen_personality_id?: string; autogen_personality_id?: string;
autogen_personality_prompt?: string; autogen_personality_prompt?: string;
}; };
}): Promise<{ ok: boolean; generation: { generation_id: string; created_at: string; mode: AutoGenMode; count: number; domain: string | null; questions: string[]; generated_by: string | null; saved_case_set_file: string | null; context: Record<string, unknown> | null } }> { }): Promise<{ ok: boolean; generation: AutoGenHistoryRecord }> {
return request("/autoruns/autogen/generate", { return request("/autoruns/autogen/generate", {
method: "POST", method: "POST",
body: JSON.stringify(input) body: JSON.stringify(input)

View File

@ -16,9 +16,13 @@ interface AssistantPanelProps {
onUseMockChange: (value: boolean) => void; onUseMockChange: (value: boolean) => void;
onSend: () => Promise<void> | void; onSend: () => Promise<void> | void;
onClear: () => void; onClear: () => void;
onSaveSession?: () => void;
busy: boolean; busy: boolean;
saveBusy?: boolean;
saveDisabled?: boolean;
statusText: string; statusText: string;
errorMessage: string; errorMessage: string;
showSaveAction?: boolean;
showCommentAction?: boolean; showCommentAction?: boolean;
onCommentAssistantMessage?: (item: AssistantConversationItem, index: number) => void; onCommentAssistantMessage?: (item: AssistantConversationItem, index: number) => void;
isAssistantMessageCommented?: (item: AssistantConversationItem, index: number) => boolean; isAssistantMessageCommented?: (item: AssistantConversationItem, index: number) => boolean;
@ -295,9 +299,13 @@ export function AssistantPanel({
onUseMockChange, onUseMockChange,
onSend, onSend,
onClear, onClear,
onSaveSession,
busy, busy,
saveBusy = false,
saveDisabled = false,
statusText, statusText,
errorMessage, errorMessage,
showSaveAction = false,
showCommentAction = false, showCommentAction = false,
onCommentAssistantMessage, onCommentAssistantMessage,
isAssistantMessageCommented, isAssistantMessageCommented,
@ -383,6 +391,16 @@ export function AssistantPanel({
> >
Скопировать техчат Скопировать техчат
</button> </button>
{showSaveAction ? (
<button
type="button"
className="assistant-copy-btn"
onClick={() => onSaveSession?.()}
disabled={saveBusy || saveDisabled}
>
{saveBusy ? "Сохраняю..." : "Сохранить"}
</button>
) : null}
<button type="button" className="assistant-copy-btn" onClick={() => onClear()} disabled={busy && conversation.length === 0}> <button type="button" className="assistant-copy-btn" onClick={() => onClear()} disabled={busy && conversation.length === 0}>
Сбросить сессию Сбросить сессию
</button> </button>

View File

@ -63,7 +63,6 @@ interface AutoRunsHistoryPanelProps {
showSettingsMode: boolean; showSettingsMode: boolean;
showAutoRunsMode: boolean; showAutoRunsMode: boolean;
showAssistantMode: boolean; showAssistantMode: boolean;
showDecompositionMode: boolean;
showProgressMode: boolean; showProgressMode: boolean;
showCommentsMode: boolean; showCommentsMode: boolean;
onLog?: (message: string) => void; onLog?: (message: string) => void;
@ -112,6 +111,30 @@ interface AssistantLiveCommentModalState {
error: string; error: string;
} }
interface AssistantLiveSaveModalState {
open: boolean;
title: string;
saving: boolean;
error: string;
}
interface SavedSessionQuestionDeleteModalState {
open: boolean;
generationId: string;
questionIndex: number;
questionText: string;
saving: boolean;
error: string;
}
interface AutoGenDeleteModalState {
open: boolean;
generationId: string;
title: string;
saving: boolean;
error: string;
}
interface AutoGenSettingsState { interface AutoGenSettingsState {
mode: AutoGenMode; mode: AutoGenMode;
count: number; count: number;
@ -259,6 +282,19 @@ function formatDateTime(iso: string | null): string {
return new Date(parsed).toLocaleString("ru-RU"); return new Date(parsed).toLocaleString("ru-RU");
} }
function formatAutoGenModeLabel(mode: AutoGenMode): string {
if (mode === "saved_user_sessions") {
return "Пользовательские сессии";
}
return mode;
}
function buildSavedSessionDefaultTitle(items: AssistantConversationItem[]): string {
const lastMessage = items[items.length - 1];
const timestamp = formatDateTime(lastMessage?.created_at ?? new Date().toISOString());
return `Ручная сессия ${timestamp}`;
}
function toPercent(closed: number, total: number): number { function toPercent(closed: number, total: number): number {
if (total <= 0) return 0; if (total <= 0) return 0;
return Math.max(0, Math.min(100, Number(((closed / total) * 100).toFixed(1)))); return Math.max(0, Math.min(100, Number(((closed / total) * 100).toFixed(1))));
@ -282,10 +318,6 @@ function trendLabel(value: "up" | "down" | "flat"): string {
return "Без изменений"; return "Без изменений";
} }
function getSelectedCase(cases: AutoRunCaseSummary[], caseId: string): AutoRunCaseSummary | null {
return cases.find((item) => item.case_id === caseId) ?? null;
}
function renderRatingDots(rating: number): string { function renderRatingDots(rating: number): string {
const safe = Math.max(1, Math.min(5, Math.round(rating))); const safe = Math.max(1, Math.min(5, Math.round(rating)));
return `${"●".repeat(safe)}${"○".repeat(5 - safe)}`; return `${"●".repeat(safe)}${"○".repeat(5 - safe)}`;
@ -527,7 +559,6 @@ export function AutoRunsHistoryPanel({
showSettingsMode, showSettingsMode,
showAutoRunsMode, showAutoRunsMode,
showAssistantMode, showAssistantMode,
showDecompositionMode,
showProgressMode, showProgressMode,
showCommentsMode, showCommentsMode,
onLog onLog
@ -598,19 +629,44 @@ export function AutoRunsHistoryPanel({
saving: false, saving: false,
error: "" error: ""
}); });
const [assistantLiveSaveModal, setAssistantLiveSaveModal] = useState<AssistantLiveSaveModalState>({
open: false,
title: "",
saving: false,
error: ""
});
const [savedSessionQuestionDeleteModal, setSavedSessionQuestionDeleteModal] = useState<SavedSessionQuestionDeleteModalState>({
open: false,
generationId: "",
questionIndex: -1,
questionText: "",
saving: false,
error: ""
});
const [autoGenDeleteModal, setAutoGenDeleteModal] = useState<AutoGenDeleteModalState>({
open: false,
generationId: "",
title: "",
saving: false,
error: ""
});
const initialLoadDoneRef = useRef(false); const initialLoadDoneRef = useRef(false);
const asyncJobPollTimerRef = useRef<number | null>(null); const asyncJobPollTimerRef = useRef<number | null>(null);
const isSavedUserSessionsMode = autoGenSettings.mode === "saved_user_sessions";
const selectedPersonality = useMemo( const selectedPersonality = useMemo(
() => autogenPersonalities.find((item) => item.id === autoGenSettings.personalityId) ?? autogenPersonalities[0] ?? AUTOGEN_PERSONALITIES[0], () => autogenPersonalities.find((item) => item.id === autoGenSettings.personalityId) ?? autogenPersonalities[0] ?? AUTOGEN_PERSONALITIES[0],
[autoGenSettings.personalityId, autogenPersonalities] [autoGenSettings.personalityId, autogenPersonalities]
); );
const visibleAutoGenHistory = useMemo(
() => autoGenHistory.filter((item) => item.mode === autoGenSettings.mode),
[autoGenHistory, autoGenSettings.mode]
);
const selectedAutogenGeneration = useMemo( const selectedAutogenGeneration = useMemo(
() => autoGenHistory.find((item) => item.generation_id === selectedAutogenGenerationId) ?? autoGenHistory[0] ?? null, () => visibleAutoGenHistory.find((item) => item.generation_id === selectedAutogenGenerationId) ?? visibleAutoGenHistory[0] ?? null,
[autoGenHistory, selectedAutogenGenerationId] [selectedAutogenGenerationId, visibleAutoGenHistory]
); );
const activeCase = runDetail ? getSelectedCase(runDetail.cases, selectedCaseId) : null;
const visibleAnnotations = useMemo( const visibleAnnotations = useMemo(
() => (hideResolvedAnnotations ? annotations.filter((item) => !item.resolved) : annotations), () => (hideResolvedAnnotations ? annotations.filter((item) => !item.resolved) : annotations),
[annotations, hideResolvedAnnotations] [annotations, hideResolvedAnnotations]
@ -731,11 +787,56 @@ export function AutoRunsHistoryPanel({
}); });
}, []); }, []);
const copyRunIdToClipboard = useCallback( const closeAssistantLiveSaveModal = useCallback((options?: { force?: boolean }) => {
async (event: React.SyntheticEvent, runId: string) => { setAssistantLiveSaveModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
title: "",
saving: false,
error: ""
};
});
}, []);
const closeSavedSessionQuestionDeleteModal = useCallback((options?: { force?: boolean }) => {
setSavedSessionQuestionDeleteModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
generationId: "",
questionIndex: -1,
questionText: "",
saving: false,
error: ""
};
});
}, []);
const closeAutoGenDeleteModal = useCallback((options?: { force?: boolean }) => {
setAutoGenDeleteModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
generationId: "",
title: "",
saving: false,
error: ""
};
});
}, []);
const copyIdentifierToClipboard = useCallback(
async (event: React.SyntheticEvent, valueRaw: string, label: string) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
const value = String(runId ?? "").trim(); const value = String(valueRaw ?? "").trim();
if (!value) { if (!value) {
return; return;
} }
@ -753,11 +854,11 @@ export function AutoRunsHistoryPanel({
document.execCommand("copy"); document.execCommand("copy");
document.body.removeChild(textarea); document.body.removeChild(textarea);
} }
log(`run id copied: ${value}`); log(`${label} copied: ${value}`);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
setErrorText(`Копирование run id: ${message}`); setErrorText(`Копирование ${label}: ${message}`);
log(`copy run id error: ${message}`); log(`copy ${label} error: ${message}`);
} }
}, },
[log] [log]
@ -844,6 +945,80 @@ export function AutoRunsHistoryPanel({
prompts prompts
]); ]);
const openAssistantLiveSaveModal = useCallback(() => {
if (!assistantLiveSessionId.trim() || assistantLiveConversation.length === 0) {
setAssistantLiveError("Сначала получите хотя бы один ответ в живой сессии ассистента.");
return;
}
setAssistantLiveError("");
setAssistantLiveSaveModal({
open: true,
title: buildSavedSessionDefaultTitle(assistantLiveConversation),
saving: false,
error: ""
});
}, [assistantLiveConversation, assistantLiveSessionId]);
const submitAssistantLiveSaveModal = useCallback(async () => {
const sessionId = assistantLiveSessionId.trim();
const title = assistantLiveSaveModal.title.trim();
if (!sessionId) {
setAssistantLiveSaveModal((prev) => ({ ...prev, error: "Активная сессия ассистента не найдена." }));
return;
}
if (!title) {
setAssistantLiveSaveModal((prev) => ({ ...prev, error: "Укажите название сессии." }));
return;
}
setAssistantLiveSaveModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const promptFingerprint = [
prompts.systemPrompt,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.schemaNotes,
prompts.fewShotExamples
].join("||");
const payload = await apiClient.saveAutoRunAssistantSession({
session_id: sessionId,
title,
generated_by: autoGenSettings.generatedBy.trim() || undefined,
context: {
llm_provider: connection.llmProvider,
model: connection.model,
assistant_prompt_version: assistantPromptVersion,
decomposition_prompt_version: decompositionPromptVersion,
prompt_fingerprint: promptFingerprint
}
});
setAutoGenHistory((prev) => [payload.generation, ...prev.filter((item) => item.generation_id !== payload.generation.generation_id)]);
setAutoGenSettings((prev) => ({ ...prev, mode: "saved_user_sessions" }));
setSelectedAutogenGenerationId(payload.generation.generation_id);
closeAssistantLiveSaveModal({ force: true });
log(`Живая сессия сохранена в автопрогоны: ${payload.generation.generation_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantLiveSaveModal((prev) => ({ ...prev, saving: false, error: message }));
log(`Assistant live save error: ${message}`);
}
}, [
assistantLiveSaveModal.title,
assistantLiveSessionId,
assistantPromptVersion,
autoGenSettings.generatedBy,
closeAssistantLiveSaveModal,
connection.llmProvider,
connection.model,
decompositionPromptVersion,
log,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.fewShotExamples,
prompts.schemaNotes,
prompts.systemPrompt
]);
const commitLimitInput = useCallback( const commitLimitInput = useCallback(
(raw: string) => { (raw: string) => {
const normalized = raw.trim(); const normalized = raw.trim();
@ -1000,6 +1175,9 @@ export function AutoRunsHistoryPanel({
setAutoGenBusy(true); setAutoGenBusy(true);
setErrorText(""); setErrorText("");
try { try {
if (autoGenSettings.mode === "saved_user_sessions") {
throw new Error("Пользовательские сессии сохраняются из живого чата, а не генерируются автоматически.");
}
const activePersonalityPrompt = autoGenSettings.personalityPrompts[autoGenSettings.personalityId] ?? ""; const activePersonalityPrompt = autoGenSettings.personalityPrompts[autoGenSettings.personalityId] ?? "";
const promptFingerprint = [ const promptFingerprint = [
prompts.systemPrompt, prompts.systemPrompt,
@ -1283,16 +1461,19 @@ export function AutoRunsHistoryPanel({
const useMockForRun = filters.useMock === "true"; const useMockForRun = filters.useMock === "true";
const effectiveAnalysisDate = normalizeAnalysisDateInput(analysisDate); const effectiveAnalysisDate = normalizeAnalysisDateInput(analysisDate);
const useScenarioReplay = generation.mode === "saved_user_sessions";
const payload = await apiClient.startEvalRunAsync({ const payload = await apiClient.startEvalRunAsync({
connection, connection,
prompts, prompts,
promptVersion: assistantPromptVersion, promptVersion: assistantPromptVersion,
mode: "single-pass-strict", mode: "single-pass-strict",
caseSetFile: generation.saved_case_set_file ?? undefined, caseSetFile: useScenarioReplay ? undefined : generation.saved_case_set_file ?? undefined,
useMock: useMockForRun, useMock: useMockForRun,
evalTarget: "assistant_stage1", evalTarget: "assistant_stage1",
questions: questionsForRun, questions: useScenarioReplay ? undefined : questionsForRun,
analysisDate: effectiveAnalysisDate || undefined scenarioQuestions: useScenarioReplay ? questionsForRun : undefined,
scenarioTitle: useScenarioReplay ? generation.title ?? undefined : undefined,
analysisDate: useScenarioReplay ? undefined : effectiveAnalysisDate || undefined
}); });
const liveJob = payload.job; const liveJob = payload.job;
@ -1307,7 +1488,11 @@ export function AutoRunsHistoryPanel({
log( log(
`Запущен async-прогон job=${liveJob.job_id}, run_id=${liveJob.run_id}, вопросов=${questionsForRun.length}` + `Запущен 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") (useScenarioReplay
? ", replay_mode=saved_user_session_scenario"
: effectiveAnalysisDate
? `, analysis_date=${effectiveAnalysisDate}`
: ", analysis_date=current_state")
); );
void pollAsyncJobStatus(liveJob.job_id); void pollAsyncJobStatus(liveJob.job_id);
} catch (error) { } catch (error) {
@ -1506,6 +1691,97 @@ export function AutoRunsHistoryPanel({
closeAssistantLiveCommentModal closeAssistantLiveCommentModal
]); ]);
const requestDeleteSavedSessionQuestion = useCallback(
(questionIndex: number) => {
if (!selectedAutogenGeneration || selectedAutogenGeneration.mode !== "saved_user_sessions") {
return;
}
const questionText = editableGeneratedQuestions[questionIndex] ?? "";
setSavedSessionQuestionDeleteModal({
open: true,
generationId: selectedAutogenGeneration.generation_id,
questionIndex,
questionText,
saving: false,
error: ""
});
},
[editableGeneratedQuestions, selectedAutogenGeneration]
);
const submitSavedSessionQuestionDelete = useCallback(async () => {
const generationId = savedSessionQuestionDeleteModal.generationId;
const questionIndex = savedSessionQuestionDeleteModal.questionIndex;
if (!generationId || questionIndex < 0) {
return;
}
const nextQuestions = editableGeneratedQuestions.filter((_, index) => index !== questionIndex);
if (nextQuestions.length === 0) {
setSavedSessionQuestionDeleteModal((prev) => ({
...prev,
error: "Нельзя удалить последний вопрос из сохраненной сессии."
}));
return;
}
setSavedSessionQuestionDeleteModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.updateAutoRunAutogenQuestions({
generation_id: generationId,
questions: nextQuestions
});
setAutoGenHistory((prev) =>
prev.map((item) => (item.generation_id === generationId ? payload.generation : item))
);
setEditableGeneratedQuestions(payload.generation.questions);
closeSavedSessionQuestionDeleteModal({ force: true });
log(`Обновлена сохраненная сессия: ${generationId}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setSavedSessionQuestionDeleteModal((prev) => ({ ...prev, saving: false, error: message }));
log(`Saved session question delete error: ${message}`);
}
}, [
closeSavedSessionQuestionDeleteModal,
editableGeneratedQuestions,
log,
savedSessionQuestionDeleteModal.generationId,
savedSessionQuestionDeleteModal.questionIndex
]);
const openAutoGenDeleteModal = useCallback((item: AutoGenHistoryRecord) => {
setAutoGenDeleteModal({
open: true,
generationId: item.generation_id,
title: item.title ?? `${formatAutoGenModeLabel(item.mode)} ${formatDateTime(item.created_at)}`,
saving: false,
error: ""
});
}, []);
const submitAutoGenDeleteModal = useCallback(async () => {
const generationId = autoGenDeleteModal.generationId.trim();
if (!generationId) {
return;
}
setAutoGenDeleteModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.deleteAutoRunAutogenHistoryRecord(generationId);
setAutoGenHistory((prev) => prev.filter((item) => item.generation_id !== payload.generation_id));
closeAutoGenDeleteModal({ force: true });
log(
`Удален набор автопрогона: ${payload.generation_id}` +
(payload.deleted_files.length > 0 ? `, files=${payload.deleted_files.length}` : "")
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAutoGenDeleteModal((prev) => ({ ...prev, saving: false, error: message }));
log(`Autogen record delete error: ${message}`);
}
}, [autoGenDeleteModal.generationId, closeAutoGenDeleteModal, log]);
const applyLocalAnnotationPatch = useCallback((annotation: AutoRunAnnotationRecord) => { const applyLocalAnnotationPatch = useCallback((annotation: AutoRunAnnotationRecord) => {
setAnnotations((prev) => setAnnotations((prev) =>
prev.map((item) => prev.map((item) =>
@ -1598,11 +1874,11 @@ export function AutoRunsHistoryPanel({
useEffect(() => { useEffect(() => {
setSelectedAutogenGenerationId((prev) => { setSelectedAutogenGenerationId((prev) => {
if (autoGenHistory.length === 0) return ""; if (visibleAutoGenHistory.length === 0) return "";
if (prev && autoGenHistory.some((item) => item.generation_id === prev)) return prev; if (prev && visibleAutoGenHistory.some((item) => item.generation_id === prev)) return prev;
return autoGenHistory[0].generation_id; return visibleAutoGenHistory[0].generation_id;
}); });
}, [autoGenHistory]); }, [visibleAutoGenHistory]);
useEffect(() => { useEffect(() => {
if (!selectedAutogenGeneration) { if (!selectedAutogenGeneration) {
@ -1610,7 +1886,7 @@ export function AutoRunsHistoryPanel({
return; return;
} }
setEditableGeneratedQuestions([...selectedAutogenGeneration.questions]); setEditableGeneratedQuestions([...selectedAutogenGeneration.questions]);
}, [selectedAutogenGeneration?.generation_id]); }, [selectedAutogenGeneration]);
useEffect(() => { useEffect(() => {
setLimitInput(String(filters.limit)); setLimitInput(String(filters.limit));
@ -1708,7 +1984,9 @@ export function AutoRunsHistoryPanel({
return { return {
...prev, ...prev,
mode: mode:
parsed.autoGenSettings?.mode === "codex_creative" || parsed.autoGenSettings?.mode === "qwen_seed" parsed.autoGenSettings?.mode === "codex_creative" ||
parsed.autoGenSettings?.mode === "qwen_seed" ||
parsed.autoGenSettings?.mode === "saved_user_sessions"
? parsed.autoGenSettings.mode ? parsed.autoGenSettings.mode
: prev.mode, : prev.mode,
count: count:
@ -1949,18 +2227,21 @@ export function AutoRunsHistoryPanel({
</div> </div>
</div> </div>
<h4>Автогенерация вопросов</h4> <h4>Автопрогоны</h4>
<div className="autoruns-form-grid"> <div className="autoruns-form-grid">
<label> <label>
Режим генерации Режимы
<select <select
value={autoGenSettings.mode} value={autoGenSettings.mode}
onChange={(event) => setAutoGenSettings((prev) => ({ ...prev, mode: event.target.value as AutoGenMode }))} onChange={(event) => setAutoGenSettings((prev) => ({ ...prev, mode: event.target.value as AutoGenMode }))}
> >
<option value="codex_creative">codex_creative</option> <option value="codex_creative">codex_creative</option>
<option value="qwen_seed">qwen_seed</option> <option value="qwen_seed">qwen_seed</option>
<option value="saved_user_sessions">Пользовательские сессии</option>
</select> </select>
</label> </label>
{!isSavedUserSessionsMode ? (
<>
<label> <label>
Кол-во Кол-во
<input <input
@ -2036,8 +2317,11 @@ export function AutoRunsHistoryPanel({
/> />
Сохранять кейс-сет в `eval_cases` Сохранять кейс-сет в `eval_cases`
</label> </label>
</>
) : null}
</div> </div>
{!isSavedUserSessionsMode ? (
<div className="autoruns-form-grid"> <div className="autoruns-form-grid">
<label> <label>
Дата анализа (срез) Дата анализа (срез)
@ -2053,36 +2337,45 @@ export function AutoRunsHistoryPanel({
</button> </button>
</div> </div>
</div> </div>
) : null}
<div className="button-row"> <div className="button-row">
{!isSavedUserSessionsMode ? (
<>
<button type="button" disabled={autoGenBusy} onClick={() => void generateAutogenBatch()}> <button type="button" disabled={autoGenBusy} onClick={() => void generateAutogenBatch()}>
{autoGenBusy ? "Генерирую..." : "Сгенерировать пачку"} {autoGenBusy ? "Генерирую..." : "Сгенерировать пачку"}
</button> </button>
<button type="button" className="tab" disabled={autogenHistoryBusy} onClick={() => void loadAutoGenHistory()}> <button type="button" className="tab" disabled={autogenHistoryBusy} onClick={() => void loadAutoGenHistory()}>
{autogenHistoryBusy ? "Обновляю..." : "Обновить историю"} {autogenHistoryBusy ? "Обновляю..." : "Обновить историю"}
</button> </button>
</>
) : null}
<button <button
type="button" type="button"
className="autoruns-run-launch-btn" className="autoruns-run-launch-btn"
disabled={autogenRunBusy || editableGeneratedQuestions.length === 0} disabled={autogenRunBusy || editableGeneratedQuestions.length === 0 || !selectedAutogenGeneration}
onClick={() => void runAutogenCampaign()} onClick={() => void runAutogenCampaign()}
> >
{autogenRunBusy ? "Запускаю..." : "Запустить прогоны"} {autogenRunBusy ? "Запускаю..." : "Запустить прогон"}
</button> </button>
</div> </div>
<div className="autoruns-form-grid"> <div className="autoruns-form-grid">
<label className="full-width"> <label className="full-width">
Кейс-сет для запуска {isSavedUserSessionsMode ? "Сохраненная сессия" : "Кейс-сет для запуска"}
<select <select
value={selectedAutogenGenerationId} value={selectedAutogenGenerationId}
onChange={(event) => setSelectedAutogenGenerationId(event.target.value)} onChange={(event) => setSelectedAutogenGenerationId(event.target.value)}
disabled={autoGenHistory.length === 0} disabled={visibleAutoGenHistory.length === 0}
> >
{autoGenHistory.length === 0 ? <option value="">нет генераций</option> : null} {visibleAutoGenHistory.length === 0 ? (
{autoGenHistory.map((item) => ( <option value="">
{isSavedUserSessionsMode ? "нет сохраненных сессий" : "нет генераций"}
</option>
) : null}
{visibleAutoGenHistory.map((item) => (
<option key={item.generation_id} value={item.generation_id}> <option key={item.generation_id} value={item.generation_id}>
{formatDateTime(item.created_at)} | {item.mode} | {item.count} | {item.saved_case_set_file ?? "без файла"} {formatDateTime(item.created_at)} | {item.title ?? formatAutoGenModeLabel(item.mode)} | {item.count}
</option> </option>
))} ))}
</select> </select>
@ -2101,7 +2394,11 @@ export function AutoRunsHistoryPanel({
</button> </button>
</div> </div>
{editableGeneratedQuestions.length === 0 ? ( {editableGeneratedQuestions.length === 0 ? (
<p className="muted">Список вопросов пуст. Сгенерируйте пачку или восстановите из выбранной генерации.</p> <p className="muted">
{isSavedUserSessionsMode
? "Список вопросов пуст. Сначала сохраните живую пользовательскую сессию."
: "Список вопросов пуст. Сгенерируйте пачку или восстановите из выбранной генерации."}
</p>
) : ( ) : (
<div className="autoruns-generated-questions-list"> <div className="autoruns-generated-questions-list">
{editableGeneratedQuestions.map((question, index) => ( {editableGeneratedQuestions.map((question, index) => (
@ -2110,41 +2407,93 @@ export function AutoRunsHistoryPanel({
<button <button
type="button" type="button"
className="autoruns-remove-question-btn" className="autoruns-remove-question-btn"
onClick={() => onClick={() => {
setEditableGeneratedQuestions((prev) => prev.filter((_, itemIndex) => itemIndex !== index)) if (isSavedUserSessionsMode) {
requestDeleteSavedSessionQuestion(index);
return;
} }
setEditableGeneratedQuestions((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
}}
title="Удалить вопрос из запуска" title="Удалить вопрос из запуска"
aria-label="Удалить вопрос из запуска" aria-label="Удалить вопрос из запуска"
> >
+ ×
</button> </button>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
<p className="muted">Запуск выполняет `assistant_stage1` eval по выбранному кейс-сету.</p> <p className="muted">
{isSavedUserSessionsMode
? "Запуск воспроизводит сохраненную пользовательскую сессию как один последовательный multi-turn сценарий assistant_stage1."
: "Запуск выполняет `assistant_stage1` eval по выбранному кейс-сету."}
</p>
<div className="autoruns-autogen-list"> <div className="autoruns-autogen-list">
{autogenHistoryBusy ? <p className="muted">Загружаю историю автогенераций...</p> : null} {autogenHistoryBusy ? (
{!autogenHistoryBusy && autoGenHistory.length === 0 ? <p className="muted">История автогенераций пока пустая.</p> : null} <p className="muted">
{autoGenHistory.slice(0, 30).map((item) => ( {isSavedUserSessionsMode ? "Загружаю сохраненные пользовательские сессии..." : "Загружаю историю автогенераций..."}
</p>
) : null}
{!autogenHistoryBusy && visibleAutoGenHistory.length === 0 ? (
<p className="muted">
{isSavedUserSessionsMode ? "Сохраненные пользовательские сессии пока пусты." : "История автогенераций пока пустая."}
</p>
) : null}
{visibleAutoGenHistory.slice(0, 30).map((item) => (
<article <article
key={item.generation_id} key={item.generation_id}
className={selectedAutogenGenerationId === item.generation_id ? "autoruns-autogen-item selected" : "autoruns-autogen-item"} className={selectedAutogenGenerationId === item.generation_id ? "autoruns-autogen-item selected" : "autoruns-autogen-item"}
onClick={() => setSelectedAutogenGenerationId(item.generation_id)} onClick={() => setSelectedAutogenGenerationId(item.generation_id)}
> >
<header> <header>
<strong>{formatDateTime(item.created_at)}</strong> <strong>{item.title ?? formatDateTime(item.created_at)}</strong>
<span>{item.mode}</span> <div className="autoruns-autogen-card-actions">
<span>{formatDateTime(item.created_at)}</span>
<button
type="button"
className="autoruns-autogen-delete-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openAutoGenDeleteModal(item);
}}
title="Удалить сохраненный набор"
aria-label={`Удалить набор ${item.generation_id}`}
>
×
</button>
</div>
</header> </header>
<div className="autoruns-run-meta"> <div className="autoruns-run-meta autoruns-run-id-row">
id={item.generation_id} | count={item.count} <span>{item.generation_id}</span>
<span
role="button"
tabIndex={0}
className="autoruns-copy-run-id-btn"
onClick={(event) => void copyIdentifierToClipboard(event, item.generation_id, "set id")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void copyIdentifierToClipboard(event, item.generation_id, "set id");
}
}}
title="Скопировать id набора"
aria-label={`Скопировать id набора ${item.generation_id}`}
>
<CopyOutlineIcon />
</span>
</div> </div>
<div className="autoruns-run-meta"> <div className="autoruns-run-meta">
домен={item.domain ?? "общий"} режим={formatAutoGenModeLabel(item.mode)} | count={item.count}
</div>
{item.domain || item.generated_by ? (
<div className="autoruns-run-meta">
{item.domain ? `домен=${item.domain}` : "домен=общий"}
{item.generated_by ? ` | автор=${item.generated_by}` : ""} {item.generated_by ? ` | автор=${item.generated_by}` : ""}
</div> </div>
) : null}
{item.saved_case_set_file ? <div className="autoruns-run-meta">кейс-сет={item.saved_case_set_file}</div> : null} {item.saved_case_set_file ? <div className="autoruns-run-meta">кейс-сет={item.saved_case_set_file}</div> : null}
{(item.questions ?? []).length > 0 ? <p>{item.questions[0]}</p> : null} {(item.questions ?? []).length > 0 ? <p>{item.questions[0]}</p> : null}
</article> </article>
@ -2219,11 +2568,11 @@ export function AutoRunsHistoryPanel({
role="button" role="button"
tabIndex={0} tabIndex={0}
className="autoruns-copy-run-id-btn" className="autoruns-copy-run-id-btn"
onClick={(event) => void copyRunIdToClipboard(event, run.run_id)} onClick={(event) => void copyIdentifierToClipboard(event, run.run_id, "run id")}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); event.preventDefault();
void copyRunIdToClipboard(event, run.run_id); void copyIdentifierToClipboard(event, run.run_id, "run id");
} }
}} }}
title="Скопировать run id" title="Скопировать run id"
@ -2424,9 +2773,13 @@ export function AutoRunsHistoryPanel({
onUseMockChange={setAssistantLiveUseMock} onUseMockChange={setAssistantLiveUseMock}
onSend={sendAssistantLiveMessage} onSend={sendAssistantLiveMessage}
onClear={resetAssistantLiveSession} onClear={resetAssistantLiveSession}
onSaveSession={openAssistantLiveSaveModal}
busy={assistantLiveBusy} busy={assistantLiveBusy}
saveBusy={assistantLiveSaveModal.saving}
saveDisabled={!assistantLiveSessionId.trim() || assistantLiveConversation.length === 0 || assistantLiveBusy}
statusText={assistantLiveStatus} statusText={assistantLiveStatus}
errorMessage={assistantLiveError} errorMessage={assistantLiveError}
showSaveAction
showCommentAction showCommentAction
onCommentAssistantMessage={openAssistantLiveCommentModal} onCommentAssistantMessage={openAssistantLiveCommentModal}
isAssistantMessageCommented={isAssistantLiveMessageCommented} isAssistantMessageCommented={isAssistantLiveMessageCommented}
@ -2435,42 +2788,6 @@ export function AutoRunsHistoryPanel({
</div> </div>
) : null} ) : null}
{showDecompositionMode ? (
<section className="autoruns-col">
<div className="autoruns-col-header">
<h3>Режим декомпозиции</h3>
</div>
<div className="autoruns-meta-list">
<div>
<span>кейс:</span>
<strong>{activeCase?.case_id ?? "нет данных"}</strong>
</div>
<div>
<span>домен:</span>
<strong>{activeCase?.domain ?? "нет данных"}</strong>
</div>
<div>
<span>класс запроса:</span>
<strong>{activeCase?.query_class ?? "нет данных"}</strong>
</div>
<div>
<span>trace:</span>
<strong>{activeCase?.trace_id ?? "нет данных"}</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 ? ( {showProgressMode ? (
<section className="autoruns-col"> <section className="autoruns-col">
<div className="autoruns-col-header"> <div className="autoruns-col-header">
@ -2746,6 +3063,104 @@ export function AutoRunsHistoryPanel({
) : null} ) : null}
</div> </div>
{assistantLiveSaveModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAssistantLiveSaveModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Сохранить ручную сессию</h3>
<p className="muted">Технический чат будет сохранен в автопрогоны как пользовательская multi-turn сессия.</p>
<label>
Название
<input
value={assistantLiveSaveModal.title}
onChange={(event) => setAssistantLiveSaveModal((prev) => ({ ...prev, title: event.target.value }))}
placeholder="Например: НДС и склад на март 2020"
disabled={assistantLiveSaveModal.saving}
/>
</label>
{assistantLiveSaveModal.error ? <p className="error-text">{assistantLiveSaveModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAssistantLiveSaveModal()} disabled={assistantLiveSaveModal.saving}>
{assistantLiveSaveModal.saving ? "Сохраняю..." : "Сохранить"}
</button>
<button type="button" className="tab" onClick={() => closeAssistantLiveSaveModal()} disabled={assistantLiveSaveModal.saving}>
Отмена
</button>
</div>
</div>
</div>
) : null}
{savedSessionQuestionDeleteModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeSavedSessionQuestionDeleteModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Удалить вопрос</h3>
<p className="muted">Действительно удалить вопрос из сохраненной пользовательской сессии?</p>
<p className="autoruns-comment-quote">{savedSessionQuestionDeleteModal.questionText}</p>
{savedSessionQuestionDeleteModal.error ? <p className="error-text">{savedSessionQuestionDeleteModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitSavedSessionQuestionDelete()} disabled={savedSessionQuestionDeleteModal.saving}>
{savedSessionQuestionDeleteModal.saving ? "Удаляю..." : "Да"}
</button>
<button
type="button"
className="tab"
onClick={() => closeSavedSessionQuestionDeleteModal()}
disabled={savedSessionQuestionDeleteModal.saving}
>
Нет
</button>
</div>
</div>
</div>
) : null}
{autoGenDeleteModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAutoGenDeleteModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Удалить сохраненный набор</h3>
<p className="muted">Будет удалена карточка истории и связанный файл кейс-сета на бэке.</p>
<p className="autoruns-comment-quote">{autoGenDeleteModal.title}</p>
{autoGenDeleteModal.error ? <p className="error-text">{autoGenDeleteModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAutoGenDeleteModal()} disabled={autoGenDeleteModal.saving}>
{autoGenDeleteModal.saving ? "Удаляю..." : "Да"}
</button>
<button type="button" className="tab" onClick={() => closeAutoGenDeleteModal()} disabled={autoGenDeleteModal.saving}>
Нет
</button>
</div>
</div>
</div>
) : null}
{assistantLiveCommentModal.open ? ( {assistantLiveCommentModal.open ? (
<div <div
className="autoruns-comment-modal-backdrop" className="autoruns-comment-modal-backdrop"

View File

@ -66,11 +66,11 @@ export interface RuntimeRun {
updatedAt: string; updatedAt: string;
} }
export type UiMode = "assistant" | "decomposition" | "autoruns"; export type UiMode = "decomposition" | "autoruns";
export type AutoRunTarget = "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0" | "unknown"; export type AutoRunTarget = "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0" | "unknown";
export type AutoRunTrend = "up" | "down" | "flat"; export type AutoRunTrend = "up" | "down" | "flat";
export type AutoGenMode = "qwen_seed" | "codex_creative"; export type AutoGenMode = "qwen_seed" | "codex_creative" | "saved_user_sessions";
export type ManualCaseDecision = export type ManualCaseDecision =
| "covered_ok" | "covered_ok"
| "covered_but_bad_answer" | "covered_but_bad_answer"
@ -317,6 +317,7 @@ export interface AutoGenHistoryRecord {
generation_id: string; generation_id: string;
created_at: string; created_at: string;
mode: AutoGenMode; mode: AutoGenMode;
title: string | null;
count: number; count: number;
domain: string | null; domain: string | null;
questions: string[]; questions: string[];
@ -330,6 +331,9 @@ export interface AutoGenHistoryRecord {
prompt_fingerprint: string | null; prompt_fingerprint: string | null;
autogen_personality_id: string | null; autogen_personality_id: string | null;
autogen_personality_prompt: string | null; autogen_personality_prompt: string | null;
source_session_id?: string | null;
saved_session_file?: string | null;
saved_case_set_kind?: string | null;
} | null; } | null;
} }

View File

@ -1566,14 +1566,13 @@ button:disabled {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transform: rotate(45deg);
box-shadow: none; box-shadow: none;
transition: color 0.15s ease; transition: color 0.15s ease;
} }
.autoruns-remove-question-btn:hover { .autoruns-remove-question-btn:hover {
background: transparent; background: transparent;
color: rgb(var(--rgb-active-text)); color: rgb(var(--rgb-active));
box-shadow: none; box-shadow: none;
} }
@ -1772,6 +1771,37 @@ button:disabled {
background: rgb(var(--rgb-active)); background: rgb(var(--rgb-active));
} }
.autoruns-autogen-card-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.autoruns-autogen-delete-btn {
border: none;
border-radius: 0;
width: 22px;
height: 22px;
min-width: 22px;
padding: 0;
background: transparent;
color: rgb(var(--rgb-text-main));
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: 700;
line-height: 1;
box-shadow: none;
transition: color 0.15s ease;
}
.autoruns-autogen-delete-btn:hover {
background: transparent;
color: rgb(var(--rgb-active));
box-shadow: none;
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
:root { :root {
--mode-column-width: 400px; --mode-column-width: 400px;