АРЧ ЮИ - остановка прогона через гуй

This commit is contained in:
2026-04-18 18:14:30 +03:00
parent 9710d81676
commit 12550606bb
39 changed files with 1783 additions and 93 deletions
+69 -13
View File
@@ -8,7 +8,7 @@ import { ApiError, ok } from "../utils/http";
import type { EvalRunMode, NormalizeRequestPayload } from "../types/normalizer";
import type { EvalTarget } from "../types/assistantEval";
type EvalAsyncStatus = "queued" | "running" | "completed" | "failed";
type EvalAsyncStatus = "queued" | "running" | "completed" | "failed" | "canceled";
interface EvalAsyncCaseInfo {
case_id: string;
@@ -41,6 +41,7 @@ interface EvalAsyncJob {
cases: EvalAsyncCaseInfo[];
error: string | null;
report: Record<string, unknown> | null;
abort_controller?: AbortController | null;
}
const ASYNC_JOBS = new Map<string, EvalAsyncJob>();
@@ -428,7 +429,7 @@ function readReportedCaseIds(job: EvalAsyncJob): Set<string> {
}
function isTerminalCaseStatus(status: EvalAsyncStatus): boolean {
return status === "completed" || status === "failed";
return status === "completed" || status === "failed" || status === "canceled";
}
function syncJobWithSessions(job: EvalAsyncJob): void {
@@ -559,6 +560,7 @@ export function buildEvalRouter(services: AppServices): Router {
}
const nowIso = new Date().toISOString();
const abortController = new AbortController();
const job: EvalAsyncJob = {
job_id: jobId,
status: "queued",
@@ -577,7 +579,8 @@ export function buildEvalRouter(services: AppServices): Router {
messages: []
})),
error: null,
report: null
report: null,
abort_controller: abortController
};
ASYNC_JOBS.set(job.job_id, job);
trimAsyncJobsStore();
@@ -586,24 +589,47 @@ export function buildEvalRouter(services: AppServices): Router {
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
runId,
abortSignal: abortController.signal
});
target.report = report;
syncJobWithSessions(target);
target.completed_cases = target.total_cases;
target.status = "completed";
target.updated_at = new Date().toISOString();
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) {
syncJobWithSessions(target);
target.status = "failed";
target.error = error instanceof Error ? error.message : String(error);
target.updated_at = new Date().toISOString();
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;
}
})();
});
@@ -638,5 +664,35 @@ export function buildEvalRouter(services: AppServices): Router {
}
});
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 ApiError("INVALID_ASYNC_JOB_ID", "job_id is required.", 400);
}
const job = ASYNC_JOBS.get(jobId);
if (!job) {
throw new 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);
}
ok(res, {
ok: true,
job: snapshotJob(job)
});
} catch (error) {
next(error);
}
});
return router;
}
@@ -25,6 +25,7 @@ export interface RunAssistantLivingChatLlmRuntimeInput {
apiKey?: unknown;
model?: unknown;
baseUrl?: unknown;
abortSignal?: AbortSignal;
temperature?: number;
maxOutputTokens?: number;
};
@@ -35,6 +36,7 @@ export interface RunAssistantLivingChatLlmRuntimeInput {
apiKey: string;
model: string;
baseUrl: unknown;
abortSignal?: AbortSignal;
temperature: number;
maxOutputTokens: number;
},
@@ -104,6 +106,7 @@ export async function runAssistantLivingChatLlmRuntime(
apiKey: String(input.payload.apiKey ?? input.defaultApiKey ?? ""),
model: String(input.payload.model ?? input.defaultModel),
baseUrl: input.payload.baseUrl ?? input.defaultBaseUrl,
abortSignal: input.payload.abortSignal,
temperature,
maxOutputTokens
},
@@ -144,6 +144,10 @@ function hasPriorAssistantTurn(items: unknown[]): boolean {
return items.some((item) => item && typeof item === "object" && (item as { role?: string }).role === "assistant");
}
function buildDeterministicSmalltalkLeadReply(): string {
return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e.";
}
function findLastAddressDebug(items: unknown[]): Record<string, unknown> | null {
if (!Array.isArray(items)) {
return null;
@@ -392,9 +396,11 @@ export async function runAssistantLivingChatRuntime(
}
const proactiveOffer = input.buildAssistantProactiveOrganizationOfferReply(proactiveScopeProbe);
if (proactiveOffer) {
chatText = [chatText, proactiveOffer].filter((part) => String(part ?? "").trim().length > 0).join(" ");
chatText = [buildDeterministicSmalltalkLeadReply(), proactiveOffer]
.filter((part) => String(part ?? "").trim().length > 0)
.join(" ");
livingChatProactiveScopeOfferApplied = true;
livingChatSource = "llm_chat_with_proactive_scope_offer";
livingChatSource = "deterministic_smalltalk_with_proactive_scope_offer";
if (!dataScopeProbe) {
dataScopeProbe = proactiveScopeProbe;
}
@@ -68,7 +68,7 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
}
function hasDataRetrievalRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase());
const lower = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!lower) {
return false;
}
@@ -319,20 +319,33 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
}
function hasLivingChatSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase());
if (!lower) {
const rawLower = compactWhitespace(String(text ?? "").toLowerCase());
const repairedLower = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
const variants = [rawLower, repairedLower].filter((value, index, items) => value.length > 0 && items.indexOf(value) === index);
if (variants.length === 0) {
return false;
}
if (/^(?:а\s+)?(?:тут|здесь|там|сюда|туда)[\s!?.,:;\-]*$/iu.test(lower)) {
const hasDirectionalReply = variants.some((value) => /^(?:\u0430\s+)?(?:\u0442\u0443\u0442|\u0437\u0434\u0435\u0441\u044c|\u0442\u0430\u043c|\u0441\u044e\u0434\u0430|\u0442\u0443\u0434\u0430)[\s!?.,:;\-]*$/iu.test(value));
if (hasDirectionalReply) {
return true;
}
if (/^(ага|угу|ок|окей|ясно|понял|поняла|принято|спасибо|благодарю|супер|класс|норм|го|давай|погнали|привет|хай|йо|yo|че\s+там|ч[её]\s+как|че\s+как|hello|hi|thanks?)$/i.test(lower)) {
const hasShortGreeting = variants.some((value) => /^(?:\u0430\u0433\u0430|\u0443\u0433\u0443|\u043e\u043a|\u043e\u043a\u0435\u0439|\u044f\u0441\u043d\u043e|\u043f\u043e\u043d\u044f\u043b(?:\u0430)?|\u043f\u0440\u0438\u043d\u044f\u0442\u043e|\u0441\u043f\u0430\u0441\u0438\u0431\u043e|\u0431\u043b\u0430\u0433\u043e\u0434\u0430\u0440\u044e|\u0441\u0443\u043f\u0435\u0440|\u043a\u043b\u0430\u0441\u0441|\u043d\u043e\u0440\u043c|\u0433\u043e|\u0434\u0430\u0432\u0430\u0439|\u043f\u043e\u0433\u043d\u0430\u043b\u0438|\u043f\u0440\u0438\u0432\u0435\u0442(?:\u0438\u043a)?|\u0445\u0430\u0439|\u0439\u043e|yo|\u0447[её]\s+\u0442\u0430\u043c|\u0447[её]\s+\u043a\u0430\u043a|hello|hi|thanks?)$/iu.test(value));
if (hasShortGreeting) {
return true;
}
if (/(как дела|как ты|что нового|расскажи о себе|чем можешь помочь|давай поговорим|поговорим|обсудим|посоветуй|что думаешь)/i.test(lower)) {
const hasGreetingWithFollowup = variants.some((value) => /(?:^|[\s,.!?;:()\-])(?:\u043f\u0440\u0438\u0432\u0435\u0442\p{L}*|\u0445\u0430\u0439|\u0439\u043e|hello|hi|yo)(?=$|[\s,.!?;:()\-]).*(?:^|[\s,.!?;:()\-])(?:\u043a\u0430\u043a|\u0434\u0435\u043b\u0430|\u0442\u0430\u043c|\u0447[её])(?=$|[\s,.!?;:()\-])/iu.test(value));
if (hasGreetingWithFollowup) {
return true;
}
return hasSmallTalkSignal(lower);
const hasColloquialFollowup = variants.some((value) => /(?:^|[\s,.!?;:()\-])(?:\u0447[её]\s+\u043a\u0430\u043a|\u0447[её]\s+\u0442\u0430\u043c|\u043a\u0430\u043a\s+\u0442\u0430\u043c(?:\s+\u0434\u0435\u043b\u0430)?)(?=$|[\s,.!?;:()\-])/iu.test(value));
if (hasColloquialFollowup) {
return true;
}
const hasOpenSmalltalkPrompt = variants.some((value) => /(?:\u043a\u0430\u043a\s+\u0434\u0435\u043b\u0430|\u043a\u0430\u043a\s+\u0442\u044b|\u0447\u0442\u043e\s+\u043d\u043e\u0432\u043e\u0433\u043e|\u0440\u0430\u0441\u0441\u043a\u0430\u0436\u0438\s+\u043e\s+\u0441\u0435\u0431\u0435|\u0447\u0435\u043c\s+\u043c\u043e\u0436\u0435\u0448\u044c\s+\u043f\u043e\u043c\u043e\u0447\u044c|\u0434\u0430\u0432\u0430\u0439\s+\u043f\u043e\u0433\u043e\u0432\u043e\u0440\u0438\u043c|\u043f\u043e\u0433\u043e\u0432\u043e\u0440\u0438\u043c|\u043e\u0431\u0441\u0443\u0434\u0438\u043c|\u043f\u043e\u0441\u043e\u0432\u0435\u0442\u0443\u0439|\u0447\u0442\u043e\s+\u0434\u0443\u043c\u0430\u0435\u0448\u044c)/iu.test(value));
if (hasOpenSmalltalkPrompt) {
return true;
}
return variants.some((value) => hasSmallTalkSignal(value));
}
function resolveLivingAssistantModeDecision(input) {
@@ -3290,6 +3290,7 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
apiKey: payload?.apiKey,
model: payload?.model,
baseUrl: payload?.baseUrl,
abortSignal: payload?.abortSignal,
temperature: 0,
maxOutputTokens: payload?.maxOutputTokens,
promptVersion: "normalizer_v2_0_2",
@@ -54,6 +54,12 @@ import { AssistantSessionStore } from "./assistantSessionStore";
import { NormalizerService } from "./normalizerService";
import { ensureDir, writeJsonFile } from "../utils/files";
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error("EVAL_RUN_ABORTED");
}
}
interface EvalCaseFile {
case_id: string;
raw_question: string;
@@ -1925,6 +1931,7 @@ export class EvalService {
compareWithReportFile?: string;
analysisDate?: string;
runId?: string;
abortSignal?: AbortSignal;
}): Promise<Record<string, unknown>> {
if (!FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1) {
throw new ApiError(
@@ -1943,6 +1950,7 @@ export class EvalService {
let requestsTotal = 0;
for (const suiteCase of suiteCases) {
throwIfAborted(payload.abortSignal);
const sessionId = `${runId}-${suiteCase.case_id}`;
const turnResponses: AssistantMessageResponsePayload[] = [];
const notes: string[] = [];
@@ -1950,11 +1958,13 @@ export class EvalService {
try {
for (const turn of suiteCase.turns) {
throwIfAborted(payload.abortSignal);
const response = (await assistantService.handleMessage({
session_id: sessionId,
user_message: turn.user_message,
message: turn.user_message,
mode: "assistant",
abortSignal: payload.abortSignal,
llmProvider: payload.normalizeConfig.llmProvider,
apiKey: payload.normalizeConfig.apiKey,
model: payload.normalizeConfig.model,
@@ -1979,9 +1989,13 @@ export class EvalService {
})) as AssistantMessageResponsePayload;
turnResponses.push(response);
requestsTotal += 1;
throwIfAborted(payload.abortSignal);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage === "EVAL_RUN_ABORTED") {
throw error;
}
diagnostics.push({
suite_case: suiteCase,
session_id: sessionId,
@@ -2634,6 +2648,7 @@ export class EvalService {
compareWithReportFile?: string;
analysisDate?: string;
runId?: string;
abortSignal?: AbortSignal;
}): Promise<Record<string, unknown>> {
const mode = payload.mode ?? "standard";
const evalTarget = payload.evalTarget ?? "normalizer";
@@ -2648,7 +2663,8 @@ export class EvalService {
caseSetFile: payload.caseSetFile,
compareWithReportFile: payload.compareWithReportFile,
analysisDate: analysisDate ?? undefined,
runId: payload.runId
runId: payload.runId,
abortSignal: payload.abortSignal
});
}
@@ -1608,6 +1608,7 @@ export class NormalizerService {
apiKey: String(apiKey ?? ""),
model,
baseUrl,
abortSignal: payload.abortSignal,
temperature,
maxOutputTokens
},
@@ -1656,6 +1657,7 @@ export class NormalizerService {
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model,
baseUrl,
abortSignal: payload.abortSignal,
temperature,
maxOutputTokens: retryMaxOutputTokens
},
@@ -9,6 +9,7 @@ export interface OpenAIRequestConfig {
apiKey: string;
model: string;
baseUrl?: string;
abortSignal?: AbortSignal;
temperature?: number;
maxOutputTokens?: number;
}
@@ -491,7 +492,8 @@ export class OpenAIResponsesClient {
response = await fetch(url, {
method,
headers,
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined
body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined,
signal: config.abortSignal
});
} catch (error) {
lastNetworkError = error;
@@ -325,6 +325,7 @@ export interface AssistantMessageRequestPayload {
user_message?: string;
message?: string;
mode?: "assistant" | string;
abortSignal?: AbortSignal;
llmProvider?: NormalizeRequestPayload["llmProvider"];
apiKey?: string;
model?: string;
@@ -267,6 +267,7 @@ export interface NormalizeRequestPayload {
apiKey?: string;
model?: string;
baseUrl?: string;
abortSignal?: AbortSignal;
temperature?: number;
maxOutputTokens?: number;
promptVersion?: PromptVersion | string;