"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.__evalRouteAsyncTestUtils = exports.__evalRouteTestUtils = void 0; exports.buildEvalRouter = buildEvalRouter; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const nanoid_1 = require("nanoid"); const express_1 = require("express"); const config_1 = require("../config"); const http_1 = require("../utils/http"); const addressTextRepair_1 = require("../services/addressTextRepair"); const ASYNC_JOBS = new Map(); const MAX_ASYNC_JOBS = 80; function toRecord(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } return value; } function toStringSafe(value) { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function toArray(value) { return Array.isArray(value) ? value : []; } function normalizeQuestionChunk(value) { return (0, addressTextRepair_1.repairAddressMojibakeText)(String(value ?? "")) .replace(/\r/g, " ") .replace(/\t/g, " ") .replace(/\s+/g, " ") .trim(); } const RUNTIME_QUESTION_PLACEHOLDER_PATTERN = /^(?:questions?|вопросы?|список\s+вопросов)$/iu; const RUNTIME_QUESTION_TAIL_PATTERNS = [ /^(?:без\s+воды|по\s+факту|и\s+коротко|коротко|прям(?:\s+)?сейчас|за\s+весь\s+период|по\s+делу)\??$/iu ]; function stripQuestionSuffix(value) { return normalizeQuestionChunk(value).replace(/[?!.:,;]+$/u, "").trim(); } function isRuntimeQuestionPlaceholder(value) { const core = stripQuestionSuffix(value).toLowerCase(); return core.length > 0 && RUNTIME_QUESTION_PLACEHOLDER_PATTERN.test(core); } function isLikelyRuntimeQuestionTail(value) { const core = stripQuestionSuffix(value).toLowerCase(); if (!core) { return false; } if (isRuntimeQuestionPlaceholder(core)) { return true; } return RUNTIME_QUESTION_TAIL_PATTERNS.some((pattern) => pattern.test(core)); } function mergeRuntimeQuestionTail(baseQuestion, tail) { const base = stripQuestionSuffix(baseQuestion); const suffix = stripQuestionSuffix(tail); if (!base) { return suffix ? `${suffix}?` : ""; } if (!suffix) { return `${base}?`; } return `${base} ${suffix}?` .replace(/\s+/g, " ") .trim(); } function normalizeRuntimeQuestionList(items) { const normalized = []; for (const item of items) { const chunk = normalizeQuestionChunk(item); if (!chunk) { continue; } if (isRuntimeQuestionPlaceholder(chunk)) { continue; } if (isLikelyRuntimeQuestionTail(chunk) && normalized.length > 0) { const merged = mergeRuntimeQuestionTail(normalized[normalized.length - 1], chunk); if (merged) { normalized[normalized.length - 1] = merged; } continue; } normalized.push(chunk); } return normalized.filter((item) => item.length > 0); } function splitQuestionCandidate(raw) { const normalized = (0, addressTextRepair_1.repairAddressMojibakeText)(String(raw ?? "")).replace(/\r/g, "\n").trim(); if (!normalized) { return []; } const byLines = normalized .split(/\n+/g) .map((line) => line.replace(/^\s*(?:[-*•]|\d{1,3}[).:]?)\s*/, "").trim()) .filter((line) => line.length > 0); const source = byLines.length > 1 ? byLines : [normalized]; const chunks = []; for (const line of source) { const normalizedLine = normalizeQuestionChunk(line); if (!normalizedLine || isRuntimeQuestionPlaceholder(normalizedLine)) { continue; } const questionLike = Array.from(line.matchAll(/[^?]+(?:\?|$)/g)) .map((match) => normalizeQuestionChunk(match[0])) .filter((item) => item.length > 0); if (questionLike.length > 1) { const canSafelySplit = questionLike.every((item) => !isRuntimeQuestionPlaceholder(item) && !isLikelyRuntimeQuestionTail(item) && normalizeQuestionChunk(item).length >= 18); if (canSafelySplit) { for (const item of questionLike) { chunks.push(item.endsWith("?") ? item : `${item}?`); } } else { chunks.push(normalizedLine); } continue; } chunks.push(normalizedLine); } return normalizeRuntimeQuestionList(chunks); } function normalizeRuntimeQuestions(value, options) { const raw = toArray(value) .map((item) => (typeof item === "string" ? item.trim() : "")) .filter((item) => item.length > 0); if (raw.length === 0) { return []; } 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 seen = new Set(); for (const item of expanded) { const normalized = normalizeQuestionChunk(item); if (!normalized) continue; if (seen.has(normalized)) continue; seen.add(normalized); deduped.push(normalized); } return deduped; } exports.__evalRouteTestUtils = { splitQuestionCandidate, normalizeRuntimeQuestions }; exports.__evalRouteAsyncTestUtils = { readReportedCaseIds, syncJobWithSessions }; function normalizeCaseIds(value) { if (!Array.isArray(value)) { return undefined; } const normalized = value .map((item) => (typeof item === "string" ? item.trim() : "")) .filter((item) => item.length > 0); return normalized.length > 0 ? normalized : undefined; } function normalizeAnalysisDate(value) { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) { return undefined; } const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { return undefined; } const candidate = new Date(Date.UTC(year, month - 1, day)); if (candidate.getUTCFullYear() !== year || candidate.getUTCMonth() + 1 !== month || candidate.getUTCDate() !== day) { return undefined; } return `${match[1]}-${match[2]}-${match[3]}`; } function buildEvalPayloadFromBody(body) { const analysisDate = normalizeAnalysisDate(body.analysis_date) ?? normalizeAnalysisDate(body.analysisDate); return { normalizeConfig: (body.normalizeConfig ?? {}), caseIds: normalizeCaseIds(body.caseIds), useMock: Boolean(body.useMock), mode: body.mode ?? "standard", caseSetFile: typeof body.caseSetFile === "string" ? body.caseSetFile : undefined, rawQuestions: typeof body.rawQuestions === "string" ? body.rawQuestions : undefined, evalTarget: body.eval_target ?? "normalizer", compareWithReportFile: typeof body.compare_with_report_file === "string" ? body.compare_with_report_file : typeof body.comparisonBaselineReportFile === "string" ? body.comparisonBaselineReportFile : undefined, analysisDate }; } function resolveReadablePath(inputPath) { if (path_1.default.isAbsolute(inputPath)) { return inputPath; } const candidates = [ path_1.default.resolve(config_1.EVAL_CASES_DIR, inputPath), path_1.default.resolve(config_1.EVAL_DATASETS_DIR, inputPath), path_1.default.resolve(inputPath) ]; for (const candidate of candidates) { if (fs_1.default.existsSync(candidate)) { return candidate; } } return candidates[0]; } function readAssistantSuiteCaseSeeds(inputPath) { const filePath = resolveReadablePath(inputPath); const raw = fs_1.default.readFileSync(filePath, "utf-8").replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); const record = toRecord(parsed); const cases = toArray(record?.cases); return cases .map((item) => toRecord(item)) .filter((item) => item !== null) .map((item) => { const caseId = toStringSafe(item.case_id); const turns = toArray(item.turns); if (!caseId || turns.length === 0) { return null; } return { case_id: caseId, turns_total: turns.length }; }) .filter((item) => item !== null); } function writeRuntimeAssistantSuiteFromQuestions(jobId, questions) { if (!fs_1.default.existsSync(config_1.EVAL_CASES_DIR)) { fs_1.default.mkdirSync(config_1.EVAL_CASES_DIR, { recursive: true }); } const cases = questions.map((question, index) => { const caseId = `AUTO-${String(index + 1).padStart(3, "0")}`; return { case_id: caseId, scenario_tag: "autogen_runtime", question_type: "direct", broadness_level: "medium", turns: [{ user_message: question }] }; }); const payload = { suite_id: `assistant_autogen_runtime_${jobId}`, suite_version: "0.1.0", schema_version: "assistant_autogen_runtime_v0_1", scenario_count: cases.length, case_ids: cases.map((item) => item.case_id), cases }; const fileName = `assistant_autogen_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 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) { const sessionId = `${runId}-${caseId}`; const filePath = path_1.default.resolve(config_1.ASSISTANT_SESSIONS_DIR, `${sessionId}.json`); if (!fs_1.default.existsSync(filePath)) { return []; } try { const parsed = JSON.parse(fs_1.default.readFileSync(filePath, "utf-8")); const record = toRecord(parsed); const conversation = toArray(record?.conversation) .map((item) => toRecord(item)) .filter((item) => item !== null); return conversation.map((item, index) => ({ message_id: toStringSafe(item.message_id), role: toStringSafe(item.role) ?? "unknown", text: toStringSafe(item.text) ?? "", created_at: toStringSafe(item.created_at), trace_id: toStringSafe(item.trace_id), reply_type: toStringSafe(item.reply_type), message_index: index, case_id: caseId, case_message_index: index })); } catch { return []; } } function readReportedCaseIds(job) { const output = new Set(); const reportRecord = toRecord(job.report); const reportResults = toArray(reportRecord?.results) .map((item) => toRecord(item)) .filter((item) => item !== null); for (const result of reportResults) { const caseId = toStringSafe(result.case_id); if (!caseId) continue; output.add(caseId); } return output; } function isTerminalCaseStatus(status) { return status === "completed" || status === "failed" || status === "canceled"; } function syncJobWithSessions(job) { if (!job.run_id || !job.eval_target.startsWith("assistant_")) { return; } const reportCaseIds = readReportedCaseIds(job); let completed = 0; let hasRunning = false; for (const item of job.cases) { const messages = readSessionConversation(job.run_id, item.case_id); item.messages = messages; const assistantMessages = messages.filter((entry) => entry.role === "assistant").length; const userMessages = messages.filter((entry) => entry.role === "user").length; const reportMarkedDone = reportCaseIds.has(item.case_id); if ((assistantMessages >= item.turns_total && item.turns_total > 0) || reportMarkedDone) { item.status = "completed"; completed += 1; continue; } if (isTerminalCaseStatus(item.status) && isTerminalCaseStatus(job.status)) { completed += 1; continue; } if (userMessages > 0 || messages.length > 0) { item.status = "running"; hasRunning = true; continue; } item.status = "queued"; } job.completed_cases = completed; if (job.status === "running" && !hasRunning && completed === job.total_cases && job.total_cases > 0) { job.status = "completed"; } } function trimAsyncJobsStore() { if (ASYNC_JOBS.size <= MAX_ASYNC_JOBS) return; const sorted = Array.from(ASYNC_JOBS.values()).sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at)); for (const item of sorted) { if (ASYNC_JOBS.size <= MAX_ASYNC_JOBS) break; ASYNC_JOBS.delete(item.job_id); } } function snapshotJob(job) { return { job_id: job.job_id, status: job.status, created_at: job.created_at, updated_at: job.updated_at, eval_target: job.eval_target, run_id: job.run_id, case_set_file: job.case_set_file, analysis_date: job.analysis_date, total_cases: job.total_cases, completed_cases: job.completed_cases, error: job.error, cases: job.cases, report_summary: job.report ? { run_id: toStringSafe(job.report.run_id), run_timestamp: toStringSafe(job.report.run_timestamp) ?? toStringSafe(job.report.timestamp), score_index: typeof job.report.score_index === "number" ? Number(job.report.score_index) : toRecord(job.report.metrics) && typeof toRecord(job.report.metrics)?.score_index === "number" ? Number(toRecord(job.report.metrics)?.score_index) : null, cases_total: typeof job.report.cases_total === "number" ? Number(job.report.cases_total) : null, analysis_date: toStringSafe(job.report.analysis_date) ?? job.analysis_date } : null }; } function buildEvalRouter(services) { const router = (0, express_1.Router)(); router.post("/api/eval/run", async (req, res, next) => { try { const body = (req.body ?? {}); const payload = buildEvalPayloadFromBody(body); const report = await services.evalService.run(payload); (0, http_1.ok)(res, { ok: true, report }); } catch (error) { next(error); } }); router.post("/api/eval/run-async/start", async (req, res, next) => { try { const body = (req.body ?? {}); const payload = buildEvalPayloadFromBody(body); if (payload.evalTarget !== "assistant_stage1") { throw new http_1.ApiError("UNSUPPORTED_ASYNC_EVAL_TARGET", "Async eval currently supports assistant_stage1 only.", 400); } const questions = normalizeRuntimeQuestions(body.questions); const scenarioQuestions = normalizeRuntimeQuestions(body.scenarioQuestions, { dedupe: false, splitCandidates: false }); const scenarioTitleRaw = toStringSafe(body.scenarioTitle); const scenarioTitle = scenarioTitleRaw ? (0, addressTextRepair_1.repairAddressMojibakeText)(scenarioTitleRaw) : null; const jobId = `job-${(0, nanoid_1.nanoid)(10)}`; const runId = `assistant-stage1-${(0, nanoid_1.nanoid)(10)}`; const runtimeCaseSetFile = scenarioQuestions.length > 0 ? writeRuntimeAssistantScenarioSuiteFromQuestions(jobId, scenarioQuestions, scenarioTitle ?? undefined) : questions.length > 0 ? writeRuntimeAssistantSuiteFromQuestions(jobId, questions) : payload.caseSetFile ? payload.caseSetFile : undefined; if (!runtimeCaseSetFile) { throw new http_1.ApiError("ASYNC_CASESET_REQUIRED", "Async assistant_stage1 run requires caseSetFile, scenarioQuestions[] or explicit questions[] payload.", 400); } const caseSeeds = readAssistantSuiteCaseSeeds(runtimeCaseSetFile); if (caseSeeds.length === 0) { throw new http_1.ApiError("ASYNC_CASESET_EMPTY", "No runnable cases found in selected case-set.", 400); } const nowIso = new Date().toISOString(); const abortController = new AbortController(); const job = { job_id: jobId, status: "queued", created_at: nowIso, updated_at: nowIso, eval_target: payload.evalTarget, run_id: runId, case_set_file: runtimeCaseSetFile, analysis_date: payload.analysisDate ?? null, total_cases: caseSeeds.length, completed_cases: 0, cases: caseSeeds.map((item) => ({ case_id: item.case_id, turns_total: item.turns_total, status: "queued", messages: [] })), error: null, report: null, abort_controller: abortController }; ASYNC_JOBS.set(job.job_id, job); trimAsyncJobsStore(); setImmediate(() => { void (async () => { const target = ASYNC_JOBS.get(job.job_id); if (!target) return; if (target.status === "canceled") { return; } target.status = "running"; target.updated_at = new Date().toISOString(); try { const report = await services.evalService.run({ ...payload, caseSetFile: runtimeCaseSetFile, runId, abortSignal: abortController.signal }); const latestAfterRun = ASYNC_JOBS.get(job.job_id); if (!latestAfterRun) { return; } if (latestAfterRun.status === "canceled") { latestAfterRun.updated_at = new Date().toISOString(); return; } latestAfterRun.report = report; syncJobWithSessions(latestAfterRun); latestAfterRun.completed_cases = latestAfterRun.total_cases; latestAfterRun.status = "completed"; latestAfterRun.updated_at = new Date().toISOString(); latestAfterRun.abort_controller = null; } catch (error) { const latestAfterError = ASYNC_JOBS.get(job.job_id); if (!latestAfterError) { return; } if (latestAfterError.status === "canceled") { latestAfterError.updated_at = new Date().toISOString(); latestAfterError.abort_controller = null; return; } syncJobWithSessions(latestAfterError); latestAfterError.status = "failed"; latestAfterError.error = error instanceof Error ? error.message : String(error); latestAfterError.updated_at = new Date().toISOString(); latestAfterError.abort_controller = null; } })(); }); (0, http_1.ok)(res, { ok: true, job: snapshotJob(job) }); } catch (error) { next(error); } }); router.get("/api/eval/run-async/:job_id", (req, res, next) => { try { const jobId = String(req.params.job_id ?? "").trim(); if (!jobId) { throw new http_1.ApiError("INVALID_ASYNC_JOB_ID", "job_id is required.", 400); } const job = ASYNC_JOBS.get(jobId); if (!job) { throw new http_1.ApiError("ASYNC_JOB_NOT_FOUND", `Async eval job not found: ${jobId}`, 404); } syncJobWithSessions(job); job.updated_at = new Date().toISOString(); (0, http_1.ok)(res, { ok: true, job: snapshotJob(job) }); } catch (error) { next(error); } }); router.post("/api/eval/run-async/:job_id/cancel", (req, res, next) => { try { const jobId = String(req.params.job_id ?? "").trim(); if (!jobId) { throw new http_1.ApiError("INVALID_ASYNC_JOB_ID", "job_id is required.", 400); } const job = ASYNC_JOBS.get(jobId); if (!job) { throw new http_1.ApiError("ASYNC_JOB_NOT_FOUND", `Async eval job not found: ${jobId}`, 404); } if (!isTerminalCaseStatus(job.status)) { job.status = "canceled"; job.error = "Остановлено оператором."; job.updated_at = new Date().toISOString(); job.abort_controller?.abort(); job.abort_controller = null; job.cases = job.cases.map((item) => item.status === "completed" ? item : { ...item, status: "canceled" }); syncJobWithSessions(job); } (0, http_1.ok)(res, { ok: true, job: snapshotJob(job) }); } catch (error) { next(error); } }); return router; }