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

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
+65 -12
View File
@@ -355,7 +355,7 @@ function readReportedCaseIds(job) {
return output;
}
function isTerminalCaseStatus(status) {
return status === "completed" || status === "failed";
return status === "completed" || status === "failed" || status === "canceled";
}
function syncJobWithSessions(job) {
if (!job.run_id || !job.eval_target.startsWith("assistant_")) {
@@ -473,6 +473,7 @@ function buildEvalRouter(services) {
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",
@@ -491,7 +492,8 @@ function buildEvalRouter(services) {
messages: []
})),
error: null,
report: null
report: null,
abort_controller: abortController
};
ASYNC_JOBS.set(job.job_id, job);
trimAsyncJobsStore();
@@ -500,25 +502,48 @@ function buildEvalRouter(services) {
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;
}
})();
});
@@ -552,5 +577,33 @@ function buildEvalRouter(services) {
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;
}
@@ -56,6 +56,7 @@ async function runAssistantLivingChatLlmRuntime(input) {
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
}, {
@@ -72,6 +72,9 @@ function hasPriorAssistantTurn(items) {
}
return items.some((item) => item && typeof item === "object" && item.role === "assistant");
}
function buildDeterministicSmalltalkLeadReply() {
return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e.";
}
function findLastAddressDebug(items) {
if (!Array.isArray(items)) {
return null;
@@ -295,9 +298,11 @@ async function runAssistantLivingChatRuntime(input) {
}
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;
}
@@ -8,7 +8,7 @@ function createAssistantLivingModePolicy(deps) {
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|склад|товар|номенклат|материал|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|warehouse|inventory|stock|item|организац|компан|контор|фирм)/i.test(lower);
}
function hasDataRetrievalRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase());
const lower = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!lower) {
return false;
}
@@ -248,20 +248,33 @@ function createAssistantLivingModePolicy(deps) {
return hasCapabilitySignal && !hasRetrievalSignal;
}
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) {
const userMessage = String(input?.userMessage ?? "");
@@ -3335,6 +3335,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",
+14 -1
View File
@@ -15,6 +15,11 @@ const http_1 = require("../utils/http");
const assistantService_1 = require("./assistantService");
const assistantSessionStore_1 = require("./assistantSessionStore");
const files_1 = require("../utils/files");
function throwIfAborted(signal) {
if (signal?.aborted) {
throw new Error("EVAL_RUN_ABORTED");
}
}
const BASELINE_METRICS = {
schema_validation_pass_rate: 100,
intent_class_accuracy: 72.73,
@@ -1601,17 +1606,20 @@ class EvalService {
const diagnostics = [];
let requestsTotal = 0;
for (const suiteCase of suiteCases) {
throwIfAborted(payload.abortSignal);
const sessionId = `${runId}-${suiteCase.case_id}`;
const turnResponses = [];
const notes = [];
const limitations = [];
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,
@@ -1636,10 +1644,14 @@ class EvalService {
}));
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,
@@ -2254,7 +2266,8 @@ class EvalService {
caseSetFile: payload.caseSetFile,
compareWithReportFile: payload.compareWithReportFile,
analysisDate: analysisDate ?? undefined,
runId: payload.runId
runId: payload.runId,
abortSignal: payload.abortSignal
});
}
if (evalTarget === "assistant_stage2") {
@@ -1363,6 +1363,7 @@ class NormalizerService {
apiKey: String(apiKey ?? ""),
model,
baseUrl,
abortSignal: payload.abortSignal,
temperature,
maxOutputTokens
}, {
@@ -1408,6 +1409,7 @@ class NormalizerService {
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""),
model,
baseUrl,
abortSignal: payload.abortSignal,
temperature,
maxOutputTokens: retryMaxOutputTokens
}, {
@@ -408,7 +408,8 @@ 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) {
+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;
@@ -155,9 +155,10 @@ describe("assistant living chat runtime adapter", () => {
const output = await runAssistantLivingChatRuntime(input);
expect(output.handled).toBe(true);
expect(output.chatText).toContain("llm-text");
expect(output.chatText).toContain("Привет! Всё нормально.");
expect(output.chatText).toContain("offer:ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ");
expect(output.debug?.living_chat_response_source).toBe("llm_chat_with_proactive_scope_offer");
expect(output.chatText).not.toContain("llm-text");
expect(output.debug?.living_chat_response_source).toBe("deterministic_smalltalk_with_proactive_scope_offer");
expect(output.debug?.living_chat_proactive_scope_offer_applied).toBe(true);
expect(output.debug?.living_chat_data_scope_probe_org_count).toBe(3);
expect(output.debug?.assistant_known_organizations).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"]);
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import { createAssistantLivingModePolicy } from "../src/services/assistantLivingModePolicy";
function buildPolicy() {
function buildPolicy(options?: { repairAddressMojibake?: (text: string) => string }) {
return createAssistantLivingModePolicy({
featureAssistantLivingChatRouterV1: true,
compactWhitespace: (text: string) => text.replace(/\s+/g, " ").trim(),
repairAddressMojibake: (text: string) => text,
repairAddressMojibake: options?.repairAddressMojibake ?? ((text: string) => text),
toNonEmptyString: (value: unknown) => {
if (value === null || value === undefined) {
return null;
@@ -34,6 +34,8 @@ function buildPolicy() {
}
describe("assistantLivingModePolicy", () => {
const colloquialGreeting = "\u043f\u0440\u0438\u0432\u0435\u0442\u0438\u043a - \u0447\u0435 \u043a\u0430\u043a \u0442\u0430\u043c \u0434\u0435\u043b\u0430";
it("detects explicit recap wording as memory signal even when selected-object words are present", () => {
const policy = buildPolicy();
@@ -63,6 +65,21 @@ describe("assistantLivingModePolicy", () => {
expect(decision.reason).toBe("living_chat_signal_detected");
});
it("detects colloquial greeting with extra words as living chat signal", () => {
const policy = buildPolicy();
expect(policy.hasLivingChatSignal(colloquialGreeting)).toBe(true);
});
it("detects mojibake colloquial greeting after repair", () => {
const policy = buildPolicy({
repairAddressMojibake: (text: string) =>
text === "приветик - че как там дела" ? colloquialGreeting : text
});
expect(policy.hasLivingChatSignal("приветик - че как там дела")).toBe(true);
});
it("keeps explicit inventory question in deep mode", () => {
const policy = buildPolicy();