ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов Stage 3.6 Усилина оркестрация и детект data-scope для живого сленга. Убрана шаблонность soft-refusal в limited-ответах. Подрезана словарнаю жирность в фильтр-экстракторе

This commit is contained in:
2026-04-11 19:45:20 +03:00
parent 88d5561fad
commit 351993430f
20 changed files with 2060 additions and 830 deletions
+29 -2
View File
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.__evalRouteTestUtils = void 0;
exports.__evalRouteAsyncTestUtils = exports.__evalRouteTestUtils = void 0;
exports.buildEvalRouter = buildEvalRouter;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
@@ -153,6 +153,10 @@ exports.__evalRouteTestUtils = {
splitQuestionCandidate,
normalizeRuntimeQuestions
};
exports.__evalRouteAsyncTestUtils = {
readReportedCaseIds,
syncJobWithSessions
};
function normalizeCaseIds(value) {
if (!Array.isArray(value)) {
return undefined;
@@ -296,10 +300,28 @@ function readSessionConversation(runId, caseId) {
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";
}
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) {
@@ -307,11 +329,16 @@ function syncJobWithSessions(job) {
item.messages = messages;
const assistantMessages = messages.filter((entry) => entry.role === "assistant").length;
const userMessages = messages.filter((entry) => entry.role === "user").length;
if (assistantMessages >= item.turns_total && item.turns_total > 0) {
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;
+112 -349
View File
@@ -26,6 +26,82 @@ const MONTH_PERIOD_NUMERIC_YEAR_MONTH_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for
const MONTH_PERIOD_NAME_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu;
const DOC_SIGNAL_PATTERN = "(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|docy|doci|doki|dokument(?:y|ov|am|a)?)";
const COUNTERPARTY_TOKEN_NOISE = new Set([
"за",
"с",
"по",
"у",
"на",
"и",
"или",
"в",
"к",
"год",
"года",
"г",
"year",
"кто",
"что",
"где",
"когда",
"сколько",
"почему",
"зачем",
"какой",
"какая",
"какие",
"каких",
"каким",
"мы",
"нам",
"нас",
"есть",
"можно",
"могу",
"можем",
"нет",
"покажи",
"показать",
"скажи",
"выведи",
"show",
"list",
"контра",
"контре",
"контрагент",
"компания",
"организация",
"client",
"customer",
"supplier",
"vendor",
"partner",
"company",
"counterparty"
]);
function isCounterpartyFillerToken(token) {
const normalized = String(token ?? "").trim().toLowerCase();
if (!normalized) {
return true;
}
if (/^(?:пл[сз]|пж|пжлст|pls|please|пожалуйста)$/iu.test(normalized)) {
return true;
}
if (/^(?:бл[яе]|блять|нах|нахуй|епт|ёпт|епта)$/iu.test(normalized)) {
return true;
}
if (/^(?:док(?:и|ам|ами|умент(?:ы|ов)?)?|docs?|docy|doci|doki|dokument(?:y|ov|am|a)?)$/iu.test(normalized)) {
return true;
}
if (/^(?:pokazh?|pokazhi|pokaji|pokezh|kakie|kakoi|kakaya|est|za|po|na|s|vse|all|poka)$/iu.test(normalized)) {
return true;
}
return false;
}
function isCounterpartyNoiseToken(rawToken) {
const normalized = String(rawToken ?? "").trim().toLowerCase();
return COUNTERPARTY_TOKEN_NOISE.has(normalized) || isCounterpartyFillerToken(normalized);
}
function textMojibakeScore(value) {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
@@ -425,101 +501,17 @@ function extractLooseByAnchorValue(text) {
if (!token) {
return undefined;
}
const lowered = token.toLowerCase();
const stopWords = new Set([
"какой",
"какая",
"какие",
"каких",
"каким",
"какими",
"каком",
"кто",
"что",
"мы",
"видим",
"контрагенту",
"контрагента",
"контрагентам",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"поставщикам",
"клиенту",
"клиента",
"клиентам",
"покупателю",
"покупателя",
"покупателям",
"заказчикам",
"партнеру",
"партнера",
"договору",
"договора",
"контракту",
"контракта",
"счету",
"счёту",
"дате",
"периоду",
"период",
"есть",
"же",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"неуказанному",
"указанному",
"указанный",
"указанная",
"указанное",
"объекту",
"объект",
"документам",
"документами",
"докам",
"взаиморасчетам",
"взаиморасчётам",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"связанный",
"связанные",
"связанных",
"связанным",
"связанному",
"related",
"linked",
"нему",
"ней",
"нее",
"ним",
"этому",
"тому",
"этомуже",
"томуже"
]);
if (stopWords.has(lowered)) {
const normalizedToken = cleanupAnchorValue(token);
if (!normalizedToken) {
return undefined;
}
return token;
if (!hasStrongCounterpartyTokenShape(normalizedToken)) {
return undefined;
}
if (!isLikelyCounterpartyToken(normalizedToken)) {
return undefined;
}
return normalizedToken;
}
function extractContractTokenHeuristic(text) {
const source = String(text ?? "");
@@ -545,206 +537,20 @@ function extractContractTokenHeuristic(text) {
}
function isLikelyCounterpartyToken(rawToken) {
const token = String(rawToken ?? "").trim();
const lowered = token.toLowerCase();
if (!token || token.length < 2) {
if (!token || token.length < 3) {
return false;
}
const lowered = token.toLowerCase();
if (/^\d+$/.test(lowered)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(lowered)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"у",
"на",
"и",
"или",
"какие",
"какой",
"какая",
"какое",
"каких",
"каким",
"какими",
"каком",
"какому",
"какую",
"кто",
"что",
"чего",
"где",
"когда",
"почему",
"зачем",
"сколько",
"чьи",
"чья",
"чей",
"чью",
"мы",
"видим",
"самый",
"самая",
"самое",
"самые",
"крупный",
"крупная",
"крупное",
"крупные",
"жирный",
"жирная",
"жирное",
"жирные",
"больше",
"меньше",
"платит",
"платят",
"прогноз",
"forecast",
"план",
"плана",
"ндс",
"vat",
"налог",
"оплата",
"оплаты",
"платеж",
"платёж",
"платежа",
"платежи",
"денег",
"деньги",
"объем",
"объём",
"док",
"доки",
"документ",
"документы",
"документов",
"документами",
"документу",
"документе",
"документа",
"документах",
"докам",
"доками",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"контрагентам",
"компания",
"компании",
"организация",
"организации",
"поставщикам",
"клиентам",
"покупателям",
"заказчикам",
"аванс",
"авансы",
"проблемный",
"проблемные",
"проблемным",
"закрытия",
"закрыть",
"закрыты",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй",
"есть",
"же",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"указанному",
"указанный",
"указанная",
"указанное",
"объекту",
"объект",
"покеж",
"покажи",
"скажи",
"показать",
"выведи",
"show",
"list",
"please",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"нему",
"ней",
"ним",
"этому",
"тому",
"этомуже",
"томуже",
"vse",
"all",
"kakie",
"kakoi",
"est",
"za",
"po",
"na",
"s",
"poka",
"pokaji",
"skazhi",
"pokazhi",
"pokazh",
"pokezh",
"doki",
"doky",
"dokument",
"dokumenty",
"documents",
"docs",
"связанный",
"связанные",
"связанных",
"связанным",
"связанному",
"related",
"linked"
]);
return !stopWords.has(lowered);
if (/^(?:(?:19|20)?\d{2})(?:-?й)?(?:г|год|года)?$/iu.test(lowered)) {
return false;
}
return !isCounterpartyNoiseToken(lowered);
}
function isLowQualityCounterpartyAnchorValue(rawValue) {
const value = String(rawValue ?? "")
@@ -816,61 +622,25 @@ function hasDocsOrBankSignal(text) {
const lowered = String(text ?? "").toLowerCase();
return new RegExp(`(?:${DOC_SIGNAL_PATTERN}|банк|выписк|списан|поступлен|платеж|платёж|оплат|transactions?|bank\\s+ops|bank\\s+operations?|payment|payments?|platezh|oplata)`, "iu").test(lowered);
}
function extractCounterpartyFromFreeTextHeuristic(text) {
if (!hasDocsOrBankSignal(text)) {
return undefined;
function hasStrongCounterpartyTokenShape(token) {
const source = String(token ?? "").trim();
if (!source) {
return false;
}
const tokens = String(text ?? "")
.split(/[^\p{L}\p{N}._-]+/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
return undefined;
if (/[0-9]/u.test(source) || /[._/-]/u.test(source)) {
return true;
}
const monthTokens = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"сент",
"окт",
"ноя",
"дек",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december"
];
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return token;
if (/[A-ZА-ЯЁ]/u.test(source)) {
return true;
}
return undefined;
// Keep only compact lowercase slang aliases (e.g. "svk"), not arbitrary words.
if (/^[a-z]{2,6}$/u.test(source)) {
return true;
}
if (/^[а-яё]+$/iu.test(source) && source.length <= 4) {
return true;
}
return false;
}
function extractImplicitCounterpartyValue(text) {
const input = String(text ?? "");
@@ -878,7 +648,7 @@ function extractImplicitCounterpartyValue(text) {
const beforeDocsMatch = input.match(beforeDocsPattern);
if (beforeDocsMatch) {
const candidate = String(beforeDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
@@ -886,7 +656,7 @@ function extractImplicitCounterpartyValue(text) {
const afterDocsMatch = input.match(afterDocsPattern);
if (afterDocsMatch) {
const candidate = String(afterDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
@@ -932,6 +702,9 @@ function extractLeadingCounterpartyTokenHeuristic(text) {
];
for (const token of tokens.slice(0, 3)) {
const lowered = token.toLowerCase();
if (!hasStrongCounterpartyTokenShape(token)) {
continue;
}
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
@@ -1048,16 +821,6 @@ function extractAddressFilters(userMessage, intent) {
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
}
}
if (!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")) {
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
if (heuristicCounterparty) {
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
if (!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
+83 -9
View File
@@ -930,6 +930,15 @@ function buildLimitedOffers(input) {
offers.push("пример: «покажи документы по договору <номер> за 2020 год»");
}
}
if (input.intent === "list_receivables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
}
else if (input.intent === "list_payables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76");
}
else if (input.intent === "open_items_by_counterparty_or_contract" || input.intent === "list_open_contracts") {
offers.push("показать незакрытые договоры и хвосты взаиморасчетов на дату");
}
if (counterparty) {
offers.push(`показать документы и платежи по контрагенту ${counterparty}`);
}
@@ -956,38 +965,101 @@ function buildLimitedOffers(input) {
}
return Array.from(new Set(offers)).slice(0, 3);
}
function buildLimitedIntentSignalLine(input) {
const byIntent = {
list_documents_by_counterparty: "Сигнал запроса: нужен срез документов/платежей по контрагенту.",
list_documents_by_contract: "Сигнал запроса: нужен срез документов/платежей по договору.",
bank_operations_by_counterparty: "Сигнал запроса: нужен срез банковских операций по контрагенту.",
bank_operations_by_contract: "Сигнал запроса: нужен срез банковских операций по договору.",
open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.",
list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов."
};
const byShape = {
AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.",
DOCUMENT_LIST: "Сигнал запроса: список документов/операций.",
OBJECT_LOOKUP: "Сигнал запроса: поиск конкретных объектов.",
VERIFY_FACTUAL: "Сигнал запроса: проверка фактического состояния по данным.",
COMPOUND_FACTUAL_QUERY: "Сигнал запроса: комбинированная проверка взаимосвязанных фактов."
};
return byIntent[input.intent] ?? byShape[input.shape.shape] ?? null;
}
function hasAggregateLimitedSignal(input) {
if (input.shape.shape === "AGGREGATE_LOOKUP") {
return true;
}
if (input.intent === "counterparty_population_and_roles" ||
input.intent === "counterparty_activity_lifecycle" ||
input.intent === "contract_usage_overview" ||
input.intent === "supplier_payouts_profile" ||
input.intent === "customer_revenue_and_payments" ||
input.intent === "contract_usage_and_value") {
return true;
}
return /(?:оборот|выруч|доход|прибыл|марж|рентабел|тренд|динам|самый|топ|ranking|revenue|profit|margin|year)/iu.test(String(input.reason ?? ""));
}
function composeLimitedReply(input) {
const reason = normalizeLimitedReason(input.reason);
const headingSeed = `${input.category}|${input.shape.shape}|${reason}`;
const aggregateLimitedSignal = hasAggregateLimitedSignal({
shape: input.shape,
intent: input.intent,
reason: input.reason
});
const heading = input.category === "empty_match"
? pickDeterministicVariant(headingSeed, [
"По текущим условиям в доступном срезе данных совпадений не нашлось.",
"В текущем срезе данных по этому запросу совпадения не найдены."
"В текущем срезе данных по этому запросу совпадения не найдены.",
"По заданным фильтрам в текущем срезе совпадений пока нет."
])
: input.category === "missing_anchor"
? pickDeterministicVariant(headingSeed, [
"Чтобы ответ был точным, нужно чуть сильнее заякорить запрос.",
"Запрос понятен, но для надежного ответа не хватает опорного ориентира."
"Запрос понятен, но для надежного ответа не хватает опорного ориентира.",
"Вопрос по смыслу ясен, но пока не хватает конкретной опоры для выборки."
])
: input.category === "recipe_visibility_gap"
? pickDeterministicVariant(headingSeed, [
"Запрос понятен, но текущий сценарий выборки не дает нужной детализации.",
"Смысл запроса ясен, но в этом контуре не хватает глубины выборки."
"Смысл запроса ясен, но в этом контуре не хватает глубины выборки.",
"Сценарий запроса корректный, но текущая витрина не дает нужной детализации."
])
: input.category === "unsupported"
? pickDeterministicVariant(headingSeed, [
"По этому вопросу в текущем адресном контуре пока нет надежного маршрута ответа.",
"Сейчас в адресном режиме такой сценарий не закрыт без риска ошибочного вывода."
"Сейчас не дам прямой адресный ответ, чтобы не ошибиться в выводах.",
"В текущем адресном контуре этот запрос лучше не закрывать «в лоб» — риск неверной трактовки высок.",
"Для такого формата запроса нужен более широкий аналитический контур, иначе ответ будет ненадежным."
])
: "Не удалось завершить проверку в адресном режиме.";
const reasonSeed = `${headingSeed}|reason`;
const reasonLine = input.category === "unsupported"
? "Коротко: сценарий пока не покрыт текущими адресными маршрутами."
? aggregateLimitedSignal
? pickDeterministicVariant(reasonSeed, [
"Это агрегатный/сравнительный вопрос: без расширенного анализа здесь легко дать ложную метрику.",
"Запрос про сводную аналитику или ранжирование, поэтому в address-контуре ответ сейчас будет ненадежным.",
"Нужна расширенная аналитическая обработка: адресный режим в этом кейсе не гарантирует корректный расчет."
])
: pickDeterministicVariant(reasonSeed, [
"Сценарий пока не закрыт текущими адресными маршрутами без потери точности.",
"Для этого запроса пока нет надежного ответа внутри текущего address-контура."
])
: input.category === "missing_anchor"
? "Коротко: не хватает конкретного ориентира (контрагент, договор, счет или период)."
? pickDeterministicVariant(reasonSeed, [
"Нужно чуть точнее заякорить запрос: не хватает конкретного ориентира (контрагент, договор, счет или период).",
"Для точного ответа нужен хотя бы один явный якорь: контрагент, договор, счет или период."
])
: input.category === "recipe_visibility_gap"
? "Коротко: для уверенного ответа нужен более специализированный сценарий выборки."
: `Коротко: ${reason}.`;
? "Для уверенного ответа нужен более специализированный сценарий выборки."
: `${reason}.`;
const lines = [heading, reasonLine];
const signalLine = buildLimitedIntentSignalLine({
intent: input.intent,
shape: input.shape
});
if (signalLine && !(input.category === "unsupported" && aggregateLimitedSignal)) {
lines.push(signalLine);
}
const scopeLine = buildLimitedScopeLine(input.filters);
if (scopeLine) {
lines.push(scopeLine);
@@ -995,6 +1067,7 @@ function composeLimitedReply(input) {
const offers = buildLimitedOffers({
category: input.category,
shape: input.shape,
intent: input.intent,
filters: input.filters,
missingRequiredFilters: input.missingRequiredFilters,
reason: input.reason,
@@ -1014,6 +1087,7 @@ function buildLimitedExecutionResult(input) {
reason: input.reasonText,
nextStep: input.nextStep,
shape: input.shape,
intent: input.intent.intent,
filters: input.filters,
missingRequiredFilters: input.missingRequiredFilters
}),
+71 -6
View File
@@ -3613,6 +3613,53 @@ function renderPolicyReply(structure, context) {
.filter(Boolean)
.join("\n\n"));
}
function shouldUseSoftPolicyReply(input) {
if (input.mode === "focused_grounded" || input.mode === "route_mismatch" || input.mode === "backend_error" || input.mode === "out_of_scope") {
return false;
}
if (input.mode === "clarification_required" || input.mode === "no_grounded" || input.mode === "empty") {
return true;
}
if (input.mode !== "broad_partial") {
return false;
}
const hasCoverageGaps = input.coverageReport.requirements_uncovered.length > 0 ||
input.coverageReport.requirements_partially_covered.length > 0 ||
input.coverageReport.clarification_needed_for.length > 0 ||
input.coverageReport.out_of_scope_requirements.length > 0;
const weakEvidenceSignals = input.policySignals.broad_query_detected ||
input.policySignals.broad_result_flag ||
input.policySignals.minimum_evidence_failed ||
input.aggregateEvidenceConfidence === "low" ||
input.hasCriticalEvidenceLimitation ||
input.limitationReasonCodes.includes("weak_source_mapping") ||
input.limitationReasonCodes.includes("insufficient_detail") ||
input.limitationReasonCodes.includes("missing_mechanism");
return hasCoverageGaps || weakEvidenceSignals;
}
function renderSoftPolicyReply(input) {
const questionType = input.context?.questionType ?? "unknown";
const shortLine = ensureSentence(buildShortSectionLine(input.structure));
const evidenceLines = dedupeNarrativeLines(buildEvidenceSectionLines(input.structure, questionType, input.context), 3);
const limitationLines = dedupeNarrativeLines(buildLimitationsSectionLines(input.structure), 3);
const checkLines = dedupeNarrativeLines(buildChecksSectionLines(input.structure, input.context), 3);
const clarificationLines = dedupeNarrativeLines(input.structure.next_step_block.clarification_questions ?? [], 2);
const actionLines = dedupeNarrativeLines([...checkLines, ...(input.structure.next_step_block.recommended_actions ?? []), ...clarificationLines], 3);
const modeLine = input.mode === "clarification_required"
? "Чтобы дать точный ответ, нужно уточнить несколько ориентиров."
: input.mode === "no_grounded" || input.mode === "empty"
? "Сейчас подтвержденной опоры недостаточно для прямого вывода."
: "Есть рабочие сигналы, но часть вывода пока ограничена.";
return sanitizeUserFacingReply([
`Коротко: ${shortLine}`,
modeLine,
evidenceLines.length > 0 ? `Что уже проверено: ${evidenceLines.join("; ")}` : "",
limitationLines.length > 0 ? `Что пока не доказано: ${limitationLines.join("; ")}` : "",
actionLines.length > 0 ? `Что могу сделать сейчас: ${actionLines.join("; ")}` : ""
]
.filter(Boolean)
.join("\n\n"));
}
function composeAssistantAnswerV11(input) {
const fallbackType = fallbackFromSummary(input.routeSummary);
const questionType = input.questionTypeHint ?? "unknown";
@@ -3856,12 +3903,30 @@ function composeAssistantAnswerV11(input) {
missingAnchors,
coverageReport: input.coverageReport
})
: renderPolicyReply(answerStructure, {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage,
userMessage: input.userMessage
});
: shouldUseSoftPolicyReply({
mode: guardedDecision.mode,
policySignals,
limitationReasonCodes,
aggregateEvidenceConfidence,
coverageReport: input.coverageReport,
hasCriticalEvidenceLimitation
})
? renderSoftPolicyReply({
structure: answerStructure,
context: {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage,
userMessage: input.userMessage
},
mode: guardedDecision.mode
})
: renderPolicyReply(answerStructure, {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage,
userMessage: input.userMessage
});
return {
assistant_reply: finalAssistantReply,
fallback_type: guardedDecision.fallback_type,
+195 -42
View File
@@ -1044,6 +1044,27 @@ function countTokens(text) {
function hasPeriodLiteral(text) {
return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text);
}
function hasStandaloneAddressTopicSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
if (hasFollowupMarker(normalized) || hasReferentialPointer(normalized)) {
return false;
}
const hasRequestCue = /(?:^|[\s,.;:!?()\-])(?:покажи|показать|выведи|дай|найди|список|какие|какой|какая|каких|сколько|где|show|list|find|which|what)/iu.test(normalized);
if (!hasRequestCue) {
return false;
}
const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat)/iu.test(normalized);
if (!hasBusinessObject) {
return false;
}
const hasStructuredAnchor = hasPeriodLiteral(normalized) ||
/\b\d{2}(?:[.,]\d{1,2})?\b/.test(normalized) ||
/(?:альтернатива|лайсвуд|райм|ооо\s+[a-zа-яё])/iu.test(normalized);
return hasStructuredAnchor || countTokens(normalized) >= 6;
}
function extractNormalizedPeriodLiteral(text) {
const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/);
if (monthly) {
@@ -1350,6 +1371,35 @@ function buildAddressCoverageReport() {
out_of_scope_requirements: []
};
}
function buildAssistantBackendErrorDebugPayload(errorMessage) {
return {
trace_id: `chat-${(0, nanoid_1.nanoid)(10)}`,
prompt_version: "assistant_backend_error_fallback_v1",
schema_version: "assistant_backend_error_fallback_v1",
fallback_type: "unknown",
route_summary: null,
fragments: [],
requirements_extracted: [],
coverage_report: buildAddressCoverageReport(),
routes: [],
retrieval_status: [],
retrieval_results: [],
answer_grounding_check: {
status: "no_grounded_answer",
route_subject_match: true,
missing_requirements: [],
reasons: [
`backend_error:${String(errorMessage ?? "unknown_error").slice(0, 280)}`
],
why_included_summary: [],
selection_reason_summary: []
},
dropped_intent_segments: []
};
}
function buildAssistantBackendErrorReply() {
return "Сейчас не удалось завершить разбор из-за внутренней ошибки контуров LLM. Могу продолжить в адресном режиме: проверить документы, договоры и операции по нужному периоду или контрагенту.";
}
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
@@ -2214,6 +2264,9 @@ function hasAddressFollowupContextSignal(userMessage) {
if (!text) {
return false;
}
if (hasStandaloneAddressTopicSignal(text)) {
return false;
}
if (shouldHandleAsAssistantCapabilityMetaQuery(text)) {
return false;
}
@@ -2272,6 +2325,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage)
: false;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) {
return null;
}
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) {
return null;
}
@@ -2903,6 +2961,29 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
Boolean(sourceAnchorQuality.anchorValue) &&
((candidateAnchorQuality.anchorType === "counterparty" &&
candidateAnchorQuality.quality >= 2 &&
Boolean(candidateAnchorQuality.anchorValue) &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "")) ||
(candidateAnchorQuality.quality < sourceAnchorQuality.quality &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidate)));
if (counterpartyAnchorSubstitutedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_substitution",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType &&
sourceAnchorQuality.quality >= 2 &&
@@ -2920,27 +3001,6 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
sanitizedUserMessage
}, userMessage);
}
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
candidateAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
candidateAnchorQuality.quality >= 2 &&
Boolean(sourceAnchorQuality.anchorValue) &&
Boolean(candidateAnchorQuality.anchorValue) &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "");
if (counterpartyAnchorSubstitutedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_substitution",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
if (fallbackCandidate) {
const fallbackAnchorQuality = evaluateAddressAnchorQuality(String(fallbackCandidate.candidate ?? ""));
const fallbackPreferredForAnchorSafety = sameIntentForAnchorSafety &&
@@ -3123,6 +3183,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
llmContractIntent === "unknown";
const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
hasDataRetrievalRequestSignal(rawMessageForGate) ||
hasAccountingSignal(rawMessageForGate) ||
hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate);
const strongDataSignalFromEffectiveMessage = hasStrongDataIntentSignal(repairedInputMessage) ||
@@ -3149,7 +3210,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
reason: "llm_predecompose_unsupported_mode"
};
}
const hasMessageSignal = hasAnyAddressSignal;
const hasMessageSignal = hasAnyAddressSignal || strongDataSignalFromRawMessage || strongDataSignalFromEffectiveMessage;
if (hasMessageSignal) {
return {
runAddressLane: true,
@@ -3234,7 +3295,9 @@ function hasDeepAnalysisPreferenceSignal(text) {
}
const riskOrAnomalySignal = /(?:\u0440\u0438\u0441\u043a|risk|\u0430\u043d\u043e\u043c\u0430\u043b|anomal|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442|conflict|deviation|\u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d|\u043d\u0435\u0441\u044b\u043a\u043e\u0432\u043a|\u043d\u0435\u0441\u0445\u043e\u0434|\u043e\u0448\u0438\u0431|error|issue|\u043f\u0440\u043e\u0431\u043b\u0435\u043c)/iu.test(lower);
const chainSignal = /(?:\u0446\u0435\u043f\u043e\u0447\u043a|chain|trace\s*chain|lifecycle|\u0436\u0438\u0437\u043d\u0435\u043d\u043d[\u0430-\u044f]+\s+\u0446\u0438\u043a\u043b|state\s+transition|\u0440\u0430\u0437\u0440\u044b\u0432[\u0430-\u044f]*)/iu.test(lower);
const diagnosticsSignal = /(?:\u0440\u0430\u0437\u043b\u043e\u0436\u0438|\u0434\u0435\u043a\u043e\u043c\u043f\u043e\u0437|\u0440\u0430\u0437\u0431\u0435\u0440\u0438|\u043f\u043e\u0447\u0435\u043c\u0443|why|\u043a\u043e\u0440\u043d\u0435\u0432[\u0430-\u044f]+\s+\u043f\u0440\u0438\u0447\u0438\u043d|root\s*cause|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c[\u0430-\u044f]*|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower);
const diagnosticsKeywordSignal = /(?:\u0440\u0430\u0437\u043b\u043e\u0436\u0438|\u0434\u0435\u043a\u043e\u043c\u043f\u043e\u0437|\u0440\u0430\u0437\u0431\u0435\u0440\u0438|\u043f\u043e\u0447\u0435\u043c\u0443|why|audit|scan|\u043a\u043e\u0440\u043d\u0435\u0432[\u0430-\u044f]+\s+\u043f\u0440\u0438\u0447\u0438\u043d|root\s*cause|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c[\u0430-\u044f]*|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower);
const diagnosticsCheckVerbSignal = /(?:^|[\s,.;:!?()\-])\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c|\u043a\u0443|\u0438\u043c|\u043a\u0430)(?:$|[\s,.;:!?()\-])/iu.test(lower);
const diagnosticsSignal = diagnosticsKeywordSignal || diagnosticsCheckVerbSignal;
const closureSignal = /(?:\u0437\u0430\u043a\u0440\u044b\u0442\u0438[\u0435\u044f]\s+\u043f\u0435\u0440\u0438\u043e\u0434|period\s*close|\u043d\u0435\s+\u0437\u0430\u043a\u0440\u044b\u043b[\u0430-\u044f]*|\u0445\u0432\u043e\u0441\u0442[\u0430-\u044f]*)/iu.test(lower);
const closureIntentSignal = /(?:\u0437\u0430\u043a\u0440\u044b\u0442[\u0430-\u044f]*|period\s*close|close\s+period)/iu.test(lower);
const closureDiagnosticPhraseSignal = /(?:\u0447\u0442\u043e(?:\s+\S+){0,8}\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower);
@@ -3242,13 +3305,12 @@ function hasDeepAnalysisPreferenceSignal(text) {
const lifecycleMismatchSignal = /(?:\u043d\u0435\s+\u0442\u0435\u043c\s+\u0442\u0438\u043f(?:\u043e\u043c)?\s+\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|wrong\s+closing\s+document|expected\s+transition)/iu.test(lower);
const lifecycleTransitionGapSignal = /(?:\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432|\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432|\u0441\u0442\u0430\u0434\u0438[\u0438\u044f\u0435]\s+.*\u043f\u0440\u043e\u0439\u0434\u0435\u043d.*\u043f\u0435\u0440\u0435\u0445\u043e\u0434)/iu.test(lower);
const expectedActualMismatchSignal = /(?:\u0444\u0430\u043a\u0442\u0438\u0447\u0435\u0441\u043a[\u0430-\u044f]+\s+\u0441\u043e\u0441\u0442\u043e\u044f\u043d[\u0438\u0435\u044f]+\s+.*\u0440\u0430\u0441\u0445\u043e\u0434[\u0430-\u044f]*\s+\u0441\s+\u043e\u0436\u0438\u0434\u0430\u0435\u043c|\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d[\u0430-\u044f]*\s+\u0441\u043f\u0438\u0441\u0430\u043d)/iu.test(lower);
return riskOrAnomalySignal ||
lifecycleMismatchSignal ||
return lifecycleMismatchSignal ||
(chainSignal && lifecycleTransitionGapSignal) ||
expectedActualMismatchSignal ||
(chainSignal && diagnosticsSignal) ||
(riskOrAnomalySignal && (chainSignal || closureSignal || diagnosticsSignal || closureIntentSignal)) ||
(diagnosticsSignal && closureIntentSignal) ||
(riskOrAnomalySignal && (chainSignal || diagnosticsSignal || lifecycleTransitionGapSignal)) ||
(diagnosticsSignal && (closureSignal || closureIntentSignal)) ||
closureDiagnosticPhraseSignal ||
signalVsNoiseDiagnostic;
}
@@ -3257,7 +3319,7 @@ function hasDirectDeepAnalysisSignal(text) {
if (!normalized) {
return false;
}
return /(?:\u0440\u0430\u0437\u043b\u043e\u0436|\u0446\u0435\u043f\u043e\u0447|lifecycle|\u0440\u0430\u0437\u0440\u044b\u0432|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0437\u0430\u043a\u0440\u044b\u0442[\u0430-\u044f]*|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized);
return /(?:\u0440\u0430\u0437\u043b\u043e\u0436|\u0446\u0435\u043f\u043e\u0447|lifecycle|\u0440\u0430\u0437\u0440\u044b\u0432|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0437\u0430\u043a\u0440\u044b\u0442\u0438[\u0435\u044f]\s+\u043f\u0435\u0440\u0438\u043e\u0434|period\s*close|close\s+period|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized);
}
function hasStrictDeepInvestigationCue(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
@@ -3283,6 +3345,23 @@ function hasAggregateBusinessAnalyticsSignal(text) {
const hasPeriodAggregateCue = /(?:\u043f\u043e\s+\u0433\u043e\u0434\u0430\u043c|\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u0433\u043e\u0434(?:\u0430|\u0443|\u044b)?|year|years|\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043c\u0435\u0441\u044f\u0446|\u043f\u0435\u0440\u0438\u043e\u0434)/iu.test(normalized);
return hasRankingOrTrendCue || hasPeriodAggregateCue;
}
function hasOpenContractsAddressSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
const hasContractCue = /(?:договор|контракт|contract)/iu.test(normalized);
if (!hasContractCue) {
return false;
}
const hasOpenCue = /(?:незакрыт|не\s+закрыт|открыт|open\s+contract|open\s+item|open)/iu.test(normalized);
if (!hasOpenCue) {
return false;
}
const hasRequestCue = /(?:покажи|показать|список|какие|какой|show|list|find|на\s+дату|as\s+of)/iu.test(normalized);
const hasTemporalCue = hasPeriodLiteral(normalized) || /\b\d{4}[-/.]\d{2}[-/.]\d{2}\b/.test(normalized);
return hasRequestCue || hasTemporalCue;
}
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_open_contracts",
"open_items_by_counterparty_or_contract",
@@ -3320,10 +3399,21 @@ function resolveAssistantOrchestrationDecision(input) {
hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) ||
hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) ||
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage);
const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) ||
hasStandaloneAddressTopicSignal(repairedRawUserMessage) ||
hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) ||
hasStandaloneAddressTopicSignal(repairedEffectiveAddressUserMessage);
const openContractsAddressSignal = hasOpenContractsAddressSignal(rawUserMessage) ||
hasOpenContractsAddressSignal(repairedRawUserMessage) ||
hasOpenContractsAddressSignal(effectiveAddressUserMessage) ||
hasOpenContractsAddressSignal(repairedEffectiveAddressUserMessage);
const modeSample = repairedEffectiveAddressUserMessage || effectiveAddressUserMessage;
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(modeSample);
const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(modeSample);
const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmPreDecomposeReason = toNonEmptyString(llmPreDecomposeMeta?.reason);
const llmRuntimeUnavailableDetected = Boolean(llmPreDecomposeReason &&
/(?:openai\s+api\s+key\s+is\s+missing|api\s+key\s+is\s+missing|missing\s+api\s+key|authentication)/iu.test(llmPreDecomposeReason));
const semanticExtractionContract = llmPreDecomposeMeta?.semanticExtractionContract &&
typeof llmPreDecomposeMeta.semanticExtractionContract === "object"
? llmPreDecomposeMeta.semanticExtractionContract
@@ -3339,7 +3429,8 @@ function resolveAssistantOrchestrationDecision(input) {
hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage);
const keepAddressLaneByIntent = semanticApplyCanonicalRecommended &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent))) &&
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal) &&
!strictDeepInvestigationCueDetected;
const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
hasStrongDataIntentSignal(repairedRawUserMessage) ||
@@ -3431,7 +3522,8 @@ function resolveAssistantOrchestrationDecision(input) {
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage));
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)));
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal);
const semanticGuardHints = semanticExtractionContract?.guard_hints &&
typeof semanticExtractionContract.guard_hints === "object"
? semanticExtractionContract.guard_hints
@@ -3452,8 +3544,14 @@ function resolveAssistantOrchestrationDecision(input) {
const unsupportedIntentOrMode = (modeDetection.mode !== "address_query" && intentResolution.intent === "unknown") ||
llmContractMode === "unsupported";
const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
unsupportedIntentOrMode &&
strongDataSignal &&
(llmContractMode === "deep_analysis" ||
!dataRetrievalSignal ||
strictDeepInvestigationCueDetected ||
semanticDeepInvestigationHintDetected ||
aggregateBusinessAnalyticsSignal) &&
!preserveAddressLaneSignal &&
!keepAddressLaneByIntent &&
!supportedAddressIntentDetected &&
@@ -3470,21 +3568,25 @@ function resolveAssistantOrchestrationDecision(input) {
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
/(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`)));
const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
!keepAddressLaneByIntent &&
!supportedAddressIntentDetected &&
!vatExplainFollowupSignal &&
(!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed));
const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
aggregateBusinessAnalyticsSignal &&
!keepAddressLaneByIntent &&
!supportedAddressIntentDetected &&
(!followupContext ||
llmContractMode === "unsupported" ||
semanticAggregateShapeDetected ||
!semanticApplyCanonicalRecommended));
!semanticApplyCanonicalRecommended ||
standaloneAddressTopicSignal));
const deepSessionContinuationFallbackToDeep = Boolean(!followupContext &&
baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
hasDeepSessionContinuationSignal({
rawUserMessage,
repairedRawUserMessage,
@@ -3591,24 +3693,29 @@ function resolveAssistantOrchestrationDecision(input) {
}
function hasStrongDataIntentSignal(text) {
const lower = String(text ?? "").toLowerCase();
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account|организац|компан|контор|фирм)/i.test(lower);
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм)/i.test(lower);
}
function hasDataRetrievalRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase());
if (!lower) {
return false;
}
const hasBroadInterrogative = /(?:\u0433\u0434\u0435|\u0432\s+\u043a\u0430\u043a\u0438\u0445|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c|\u043f\u043e\s+\u043a\u043e\u043c\u0443|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0442\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|where|which|who|how\s+many)/iu.test(lower);
const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover)/iu.test(lower);
if (hasBroadInterrogative && hasBroadBusinessObject) {
return true;
}
const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a)(?:$|[\s,.!?;:])/iu.test(lower);
const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446)/iu.test(lower);
if (hasRussianRetrievalAction && hasRussianRetrievalObject) {
return true;
}
const hasExplicitRetrievalAction = /(?:\bпокажи\b|\bпоказать\b|\bвыведи\b|\bнайди\b|\bсписок\b|\bдай\b|\bраскрой\b|\bshow\b|\blist\b|\bfind\b|\bcount\b)/i.test(lower);
const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bwhich\b|\bwho\b)/i.test(lower);
const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bгде\b|\bпо\s+каким\b|\bпо\s+кому\b|\bу\s+кого\b|\bwhich\b|\bwho\b|\bwhere\b)/i.test(lower);
if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
return false;
}
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
if (!hasRetrievalObject) {
return false;
}
@@ -3769,6 +3876,10 @@ function hasAssistantDataScopeMetaQuestionSignal(text) {
if (!normalized) {
return false;
}
const hasDirectSlangScopeLead = /(?:по\s+каким\s+(?:контор(?:ам|ы|а)?|кантор(?:ам|ы|а)?|компан(?:иям|ии|ию|ия)|организац(?:иям|ии|ию|ия))\s+мож(?:ем|но)\s+(?:общат|работ)|база\s+какой\s+(?:контор|компан|организац|фирм)|какая\s+база\s+(?:подключ|подруб|актив))/iu.test(normalized);
if (hasDirectSlangScopeLead) {
return true;
}
const hasSlangScopeQuestion = /(?:\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440(?:\u0430\u043c|\u044b|\u0430)?|\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u0444\u0438\u0440\u043c(?:\u0430\u043c|\u0435|\u0443|\u0430)).*(?:\u043c\u043e\u0436(?:\u0435\u043c|\u043d\u043e)|\u0440\u0430\u0431\u043e\u0442|\u043e\u0431\u0449\u0430\u0442|\u043f\u043e\u0434\u0440\u0443\u0431|\u043f\u043e\u0434\u043a\u043b\u044e\u0447)|(?:\u0431\u0430\u0437\u0430\s+\u043a\u0430\u043a\u043e\u0439\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0444\u0438\u0440\u043c))|(?:\u043a\u0430\u043a\u0430\u044f\s+\u0431\u0430\u0437\u0430\s+(?:\u043f\u043e\u0434\u043a\u043b\u044e\u0447|\u0430\u043a\u0442\u0438\u0432)))/iu.test(normalized);
if (hasSlangScopeQuestion) {
return true;
@@ -4816,14 +4927,56 @@ class AssistantService {
extractExecutionState
}
});
const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({
payload,
runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)),
resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems),
runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)),
runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps))
});
return turnRuntime.response;
try {
const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({
payload,
runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)),
resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems),
runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)),
runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps))
});
return turnRuntime.response;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const sessionId = String(payload?.session_id ?? payload?.sessionId ?? "").trim() || `asst-${(0, nanoid_1.nanoid)(10)}`;
const ensuredSession = this.sessions.ensureSession(sessionId);
const existingAssistant = [...ensuredSession.items].reverse().find((item) => item.role === "assistant") ?? null;
if (existingAssistant) {
return {
ok: true,
session_id: sessionId,
assistant_reply: existingAssistant.text,
reply_type: existingAssistant.reply_type ?? "backend_error",
conversation_item: existingAssistant,
debug: existingAssistant.debug ?? buildAssistantBackendErrorDebugPayload(errorMessage),
conversation: cloneItems(ensuredSession.items)
};
}
const createdAt = new Date().toISOString();
const debugPayload = buildAssistantBackendErrorDebugPayload(errorMessage);
const assistantItem = this.sessions.appendItem(sessionId, {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: buildAssistantBackendErrorReply(),
reply_type: "backend_error",
created_at: createdAt,
trace_id: debugPayload.trace_id ?? null,
debug: debugPayload
});
const sessionSnapshot = this.sessions.getSession(sessionId) ?? this.sessions.ensureSession(sessionId);
this.sessionLogger.persistSession(sessionSnapshot);
return {
ok: true,
session_id: sessionId,
assistant_reply: assistantItem.text,
reply_type: "backend_error",
conversation_item: assistantItem,
debug: debugPayload,
conversation: cloneItems(sessionSnapshot.items)
};
}
}
}
exports.AssistantService = AssistantService;
+31 -1
View File
@@ -202,6 +202,11 @@ export const __evalRouteTestUtils = {
normalizeRuntimeQuestions
};
export const __evalRouteAsyncTestUtils = {
readReportedCaseIds,
syncJobWithSessions
};
function normalizeCaseIds(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
@@ -365,10 +370,30 @@ function readSessionConversation(runId: string, caseId: string): EvalAsyncCaseIn
}
}
function readReportedCaseIds(job: EvalAsyncJob): Set<string> {
const output = new Set<string>();
const reportRecord = toRecord(job.report);
const reportResults = toArray(reportRecord?.results)
.map((item) => toRecord(item))
.filter((item): item is Record<string, unknown> => item !== null);
for (const result of reportResults) {
const caseId = toStringSafe(result.case_id);
if (!caseId) continue;
output.add(caseId);
}
return output;
}
function isTerminalCaseStatus(status: EvalAsyncStatus): boolean {
return status === "completed" || status === "failed";
}
function syncJobWithSessions(job: EvalAsyncJob): void {
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) {
@@ -376,11 +401,16 @@ function syncJobWithSessions(job: EvalAsyncJob): void {
item.messages = messages;
const assistantMessages = messages.filter((entry) => entry.role === "assistant").length;
const userMessages = messages.filter((entry) => entry.role === "user").length;
if (assistantMessages >= item.turns_total && item.turns_total > 0) {
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;
@@ -36,6 +36,84 @@ const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN =
/(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu;
const DOC_SIGNAL_PATTERN =
"(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|docy|doci|doki|dokument(?:y|ov|am|a)?)";
const COUNTERPARTY_TOKEN_NOISE = new Set([
"за",
"с",
"по",
"у",
"на",
"и",
"или",
"в",
"к",
"год",
"года",
"г",
"year",
"кто",
"что",
"где",
"когда",
"сколько",
"почему",
"зачем",
"какой",
"какая",
"какие",
"каких",
"каким",
"мы",
"нам",
"нас",
"есть",
"можно",
"могу",
"можем",
"нет",
"покажи",
"показать",
"скажи",
"выведи",
"show",
"list",
"контра",
"контре",
"контрагент",
"компания",
"организация",
"client",
"customer",
"supplier",
"vendor",
"partner",
"company",
"counterparty"
]);
function isCounterpartyFillerToken(token: string): boolean {
const normalized = String(token ?? "").trim().toLowerCase();
if (!normalized) {
return true;
}
if (/^(?:пл[сз]|пж|пжлст|pls|please|пожалуйста)$/iu.test(normalized)) {
return true;
}
if (/^(?:бл[яе]|блять|нах|нахуй|епт|ёпт|епта)$/iu.test(normalized)) {
return true;
}
if (/^(?:док(?:и|ам|ами|умент(?:ы|ов)?)?|docs?|docy|doci|doki|dokument(?:y|ov|am|a)?)$/iu.test(normalized)) {
return true;
}
if (/^(?:pokazh?|pokazhi|pokaji|pokezh|kakie|kakoi|kakaya|est|za|po|na|s|vse|all|poka)$/iu.test(normalized)) {
return true;
}
return false;
}
function isCounterpartyNoiseToken(rawToken: string): boolean {
const normalized = String(rawToken ?? "").trim().toLowerCase();
return COUNTERPARTY_TOKEN_NOISE.has(normalized) || isCounterpartyFillerToken(normalized);
}
function textMojibakeScore(value: string): number {
const source = String(value ?? "");
@@ -488,101 +566,17 @@ function extractLooseByAnchorValue(text: string): string | undefined {
if (!token) {
return undefined;
}
const lowered = token.toLowerCase();
const stopWords = new Set([
"какой",
"какая",
"какие",
"каких",
"каким",
"какими",
"каком",
"кто",
"что",
"мы",
"видим",
"контрагенту",
"контрагента",
"контрагентам",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"поставщикам",
"клиенту",
"клиента",
"клиентам",
"покупателю",
"покупателя",
"покупателям",
"заказчикам",
"партнеру",
"партнера",
"договору",
"договора",
"контракту",
"контракта",
"счету",
"счёту",
"дате",
"периоду",
"период",
"есть",
"же",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"неуказанному",
"указанному",
"указанный",
"указанная",
"указанное",
"объекту",
"объект",
"документам",
"документами",
"докам",
"взаиморасчетам",
"взаиморасчётам",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"связанный",
"связанные",
"связанных",
"связанным",
"связанному",
"related",
"linked",
"нему",
"ней",
"нее",
"ним",
"этому",
"тому",
"этомуже",
"томуже"
]);
if (stopWords.has(lowered)) {
const normalizedToken = cleanupAnchorValue(token);
if (!normalizedToken) {
return undefined;
}
return token;
if (!hasStrongCounterpartyTokenShape(normalizedToken)) {
return undefined;
}
if (!isLikelyCounterpartyToken(normalizedToken)) {
return undefined;
}
return normalizedToken;
}
function extractContractTokenHeuristic(text: string): string | undefined {
@@ -610,207 +604,20 @@ function extractContractTokenHeuristic(text: string): string | undefined {
function isLikelyCounterpartyToken(rawToken: string): boolean {
const token = String(rawToken ?? "").trim();
const lowered = token.toLowerCase();
if (!token || token.length < 2) {
if (!token || token.length < 3) {
return false;
}
const lowered = token.toLowerCase();
if (/^\d+$/.test(lowered)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(lowered)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"у",
"на",
"и",
"или",
"какие",
"какой",
"какая",
"какое",
"каких",
"каким",
"какими",
"каком",
"какому",
"какую",
"кто",
"что",
"чего",
"где",
"когда",
"почему",
"зачем",
"сколько",
"чьи",
"чья",
"чей",
"чью",
"мы",
"видим",
"самый",
"самая",
"самое",
"самые",
"крупный",
"крупная",
"крупное",
"крупные",
"жирный",
"жирная",
"жирное",
"жирные",
"больше",
"меньше",
"платит",
"платят",
"прогноз",
"forecast",
"план",
"плана",
"ндс",
"vat",
"налог",
"оплата",
"оплаты",
"платеж",
"платёж",
"платежа",
"платежи",
"денег",
"деньги",
"объем",
"объём",
"док",
"доки",
"документ",
"документы",
"документов",
"документами",
"документу",
"документе",
"документа",
"документах",
"докам",
"доками",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"контрагентам",
"компания",
"компании",
"организация",
"организации",
"поставщикам",
"клиентам",
"покупателям",
"заказчикам",
"аванс",
"авансы",
"проблемный",
"проблемные",
"проблемным",
"закрытия",
"закрыть",
"закрыты",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй",
"есть",
"же",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"указанному",
"указанный",
"указанная",
"указанное",
"объекту",
"объект",
"покеж",
"покажи",
"скажи",
"показать",
"выведи",
"show",
"list",
"please",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"нему",
"ней",
"ним",
"этому",
"тому",
"этомуже",
"томуже",
"vse",
"all",
"kakie",
"kakoi",
"est",
"za",
"po",
"na",
"s",
"poka",
"pokaji",
"skazhi",
"pokazhi",
"pokazh",
"pokezh",
"doki",
"doky",
"dokument",
"dokumenty",
"documents",
"docs",
"связанный",
"связанные",
"связанных",
"связанным",
"связанному",
"related",
"linked"
]);
return !stopWords.has(lowered);
if (/^(?:(?:19|20)?\d{2})(?:-?й)?(?:г|год|года)?$/iu.test(lowered)) {
return false;
}
return !isCounterpartyNoiseToken(lowered);
}
function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
@@ -892,64 +699,25 @@ function hasDocsOrBankSignal(text: string): boolean {
);
}
function extractCounterpartyFromFreeTextHeuristic(text: string): string | undefined {
if (!hasDocsOrBankSignal(text)) {
return undefined;
function hasStrongCounterpartyTokenShape(token: string): boolean {
const source = String(token ?? "").trim();
if (!source) {
return false;
}
const tokens = String(text ?? "")
.split(/[^\p{L}\p{N}._-]+/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
return undefined;
if (/[0-9]/u.test(source) || /[._/-]/u.test(source)) {
return true;
}
const monthTokens = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"сент",
"окт",
"ноя",
"дек",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december"
];
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return token;
if (/[A-ZА-ЯЁ]/u.test(source)) {
return true;
}
return undefined;
// Keep only compact lowercase slang aliases (e.g. "svk"), not arbitrary words.
if (/^[a-z]{2,6}$/u.test(source)) {
return true;
}
if (/^[а-яё]+$/iu.test(source) && source.length <= 4) {
return true;
}
return false;
}
function extractImplicitCounterpartyValue(text: string): string | undefined {
@@ -961,7 +729,7 @@ function extractImplicitCounterpartyValue(text: string): string | undefined {
const beforeDocsMatch = input.match(beforeDocsPattern);
if (beforeDocsMatch) {
const candidate = String(beforeDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
@@ -973,7 +741,7 @@ function extractImplicitCounterpartyValue(text: string): string | undefined {
const afterDocsMatch = input.match(afterDocsPattern);
if (afterDocsMatch) {
const candidate = String(afterDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
@@ -1022,6 +790,9 @@ function extractLeadingCounterpartyTokenHeuristic(text: string): string | undefi
];
for (const token of tokens.slice(0, 3)) {
const lowered = token.toLowerCase();
if (!hasStrongCounterpartyTokenShape(token)) {
continue;
}
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
@@ -1154,18 +925,6 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
}
}
if (
!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
) {
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
if (heuristicCounterparty) {
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
if (
!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
@@ -1111,6 +1111,7 @@ function buildLimitedScopeLine(filters: AddressFilterSet): string | null {
function buildLimitedOffers(input: {
category: AddressLimitedReasonCategory;
shape: AddressQueryShapeDetection;
intent: AddressIntent;
filters: AddressFilterSet;
missingRequiredFilters: string[];
reason: string;
@@ -1139,6 +1140,14 @@ function buildLimitedOffers(input: {
}
}
if (input.intent === "list_receivables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
} else if (input.intent === "list_payables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76");
} else if (input.intent === "open_items_by_counterparty_or_contract" || input.intent === "list_open_contracts") {
offers.push("показать незакрытые договоры и хвосты взаиморасчетов на дату");
}
if (counterparty) {
offers.push(`показать документы и платежи по контрагенту ${counterparty}`);
} else if (contract) {
@@ -1170,49 +1179,128 @@ function buildLimitedOffers(input: {
return Array.from(new Set(offers)).slice(0, 3);
}
function buildLimitedIntentSignalLine(input: {
intent: AddressIntent;
shape: AddressQueryShapeDetection;
}): string | null {
const byIntent: Partial<Record<AddressIntent, string>> = {
list_documents_by_counterparty: "Сигнал запроса: нужен срез документов/платежей по контрагенту.",
list_documents_by_contract: "Сигнал запроса: нужен срез документов/платежей по договору.",
bank_operations_by_counterparty: "Сигнал запроса: нужен срез банковских операций по контрагенту.",
bank_operations_by_contract: "Сигнал запроса: нужен срез банковских операций по договору.",
open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.",
list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов."
};
const byShape: Partial<Record<AddressQueryShapeDetection["shape"], string>> = {
AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.",
DOCUMENT_LIST: "Сигнал запроса: список документов/операций.",
OBJECT_LOOKUP: "Сигнал запроса: поиск конкретных объектов.",
VERIFY_FACTUAL: "Сигнал запроса: проверка фактического состояния по данным.",
COMPOUND_FACTUAL_QUERY: "Сигнал запроса: комбинированная проверка взаимосвязанных фактов."
};
return byIntent[input.intent] ?? byShape[input.shape.shape] ?? null;
}
function hasAggregateLimitedSignal(input: {
shape: AddressQueryShapeDetection;
intent: AddressIntent;
reason: string;
}): boolean {
if (input.shape.shape === "AGGREGATE_LOOKUP") {
return true;
}
if (
input.intent === "counterparty_population_and_roles" ||
input.intent === "counterparty_activity_lifecycle" ||
input.intent === "contract_usage_overview" ||
input.intent === "supplier_payouts_profile" ||
input.intent === "customer_revenue_and_payments" ||
input.intent === "contract_usage_and_value"
) {
return true;
}
return /(?:оборот|выруч|доход|прибыл|марж|рентабел|тренд|динам|самый|топ|ranking|revenue|profit|margin|year)/iu.test(
String(input.reason ?? "")
);
}
function composeLimitedReply(input: {
category: AddressLimitedReasonCategory;
reason: string;
nextStep?: string;
shape: AddressQueryShapeDetection;
intent: AddressIntent;
filters: AddressFilterSet;
missingRequiredFilters: string[];
}): string {
const reason = normalizeLimitedReason(input.reason);
const headingSeed = `${input.category}|${input.shape.shape}|${reason}`;
const aggregateLimitedSignal = hasAggregateLimitedSignal({
shape: input.shape,
intent: input.intent,
reason: input.reason
});
const heading =
input.category === "empty_match"
? pickDeterministicVariant(headingSeed, [
"По текущим условиям в доступном срезе данных совпадений не нашлось.",
"В текущем срезе данных по этому запросу совпадения не найдены."
"В текущем срезе данных по этому запросу совпадения не найдены.",
"По заданным фильтрам в текущем срезе совпадений пока нет."
])
: input.category === "missing_anchor"
? pickDeterministicVariant(headingSeed, [
"Чтобы ответ был точным, нужно чуть сильнее заякорить запрос.",
"Запрос понятен, но для надежного ответа не хватает опорного ориентира."
"Запрос понятен, но для надежного ответа не хватает опорного ориентира.",
"Вопрос по смыслу ясен, но пока не хватает конкретной опоры для выборки."
])
: input.category === "recipe_visibility_gap"
? pickDeterministicVariant(headingSeed, [
"Запрос понятен, но текущий сценарий выборки не дает нужной детализации.",
"Смысл запроса ясен, но в этом контуре не хватает глубины выборки."
"Смысл запроса ясен, но в этом контуре не хватает глубины выборки.",
"Сценарий запроса корректный, но текущая витрина не дает нужной детализации."
])
: input.category === "unsupported"
? pickDeterministicVariant(headingSeed, [
"По этому вопросу в текущем адресном контуре пока нет надежного маршрута ответа.",
"Сейчас в адресном режиме такой сценарий не закрыт без риска ошибочного вывода."
"Сейчас не дам прямой адресный ответ, чтобы не ошибиться в выводах.",
"В текущем адресном контуре этот запрос лучше не закрывать «в лоб» — риск неверной трактовки высок.",
"Для такого формата запроса нужен более широкий аналитический контур, иначе ответ будет ненадежным."
])
: "Не удалось завершить проверку в адресном режиме.";
const reasonSeed = `${headingSeed}|reason`;
const reasonLine =
input.category === "unsupported"
? "Коротко: сценарий пока не покрыт текущими адресными маршрутами."
? aggregateLimitedSignal
? pickDeterministicVariant(reasonSeed, [
"Это агрегатный/сравнительный вопрос: без расширенного анализа здесь легко дать ложную метрику.",
"Запрос про сводную аналитику или ранжирование, поэтому в address-контуре ответ сейчас будет ненадежным.",
"Нужна расширенная аналитическая обработка: адресный режим в этом кейсе не гарантирует корректный расчет."
])
: pickDeterministicVariant(reasonSeed, [
"Сценарий пока не закрыт текущими адресными маршрутами без потери точности.",
"Для этого запроса пока нет надежного ответа внутри текущего address-контура."
])
: input.category === "missing_anchor"
? "Коротко: не хватает конкретного ориентира (контрагент, договор, счет или период)."
? pickDeterministicVariant(reasonSeed, [
"Нужно чуть точнее заякорить запрос: не хватает конкретного ориентира (контрагент, договор, счет или период).",
"Для точного ответа нужен хотя бы один явный якорь: контрагент, договор, счет или период."
])
: input.category === "recipe_visibility_gap"
? "Коротко: для уверенного ответа нужен более специализированный сценарий выборки."
: `Коротко: ${reason}.`;
? "Для уверенного ответа нужен более специализированный сценарий выборки."
: `${reason}.`;
const lines = [heading, reasonLine];
const signalLine = buildLimitedIntentSignalLine({
intent: input.intent,
shape: input.shape
});
if (signalLine && !(input.category === "unsupported" && aggregateLimitedSignal)) {
lines.push(signalLine);
}
const scopeLine = buildLimitedScopeLine(input.filters);
if (scopeLine) {
lines.push(scopeLine);
@@ -1221,6 +1309,7 @@ function composeLimitedReply(input: {
const offers = buildLimitedOffers({
category: input.category,
shape: input.shape,
intent: input.intent,
filters: input.filters,
missingRequiredFilters: input.missingRequiredFilters,
reason: input.reason,
@@ -1275,6 +1364,7 @@ function buildLimitedExecutionResult(input: {
reason: input.reasonText,
nextStep: input.nextStep,
shape: input.shape,
intent: input.intent.intent,
filters: input.filters,
missingRequiredFilters: input.missingRequiredFilters
}),
@@ -4306,6 +4306,74 @@ function renderPolicyReply(structure: AnswerStructureV11, context?: AnswerRender
);
}
function shouldUseSoftPolicyReply(input: {
mode: PolicyMode;
policySignals: PolicySignals;
limitationReasonCodes: EvidenceLimitationReasonCode[];
aggregateEvidenceConfidence: EvidenceConfidence;
coverageReport: RequirementCoverageReport;
hasCriticalEvidenceLimitation: boolean;
}): boolean {
if (input.mode === "focused_grounded" || input.mode === "route_mismatch" || input.mode === "backend_error" || input.mode === "out_of_scope") {
return false;
}
if (input.mode === "clarification_required" || input.mode === "no_grounded" || input.mode === "empty") {
return true;
}
if (input.mode !== "broad_partial") {
return false;
}
const hasCoverageGaps =
input.coverageReport.requirements_uncovered.length > 0 ||
input.coverageReport.requirements_partially_covered.length > 0 ||
input.coverageReport.clarification_needed_for.length > 0 ||
input.coverageReport.out_of_scope_requirements.length > 0;
const weakEvidenceSignals =
input.policySignals.broad_query_detected ||
input.policySignals.broad_result_flag ||
input.policySignals.minimum_evidence_failed ||
input.aggregateEvidenceConfidence === "low" ||
input.hasCriticalEvidenceLimitation ||
input.limitationReasonCodes.includes("weak_source_mapping") ||
input.limitationReasonCodes.includes("insufficient_detail") ||
input.limitationReasonCodes.includes("missing_mechanism");
return hasCoverageGaps || weakEvidenceSignals;
}
function renderSoftPolicyReply(input: {
structure: AnswerStructureV11;
context?: AnswerRenderContext;
mode: PolicyMode;
}): string {
const questionType = input.context?.questionType ?? "unknown";
const shortLine = ensureSentence(buildShortSectionLine(input.structure));
const evidenceLines = dedupeNarrativeLines(buildEvidenceSectionLines(input.structure, questionType, input.context), 3);
const limitationLines = dedupeNarrativeLines(buildLimitationsSectionLines(input.structure), 3);
const checkLines = dedupeNarrativeLines(buildChecksSectionLines(input.structure, input.context), 3);
const clarificationLines = dedupeNarrativeLines(input.structure.next_step_block.clarification_questions ?? [], 2);
const actionLines = dedupeNarrativeLines(
[...checkLines, ...(input.structure.next_step_block.recommended_actions ?? []), ...clarificationLines],
3
);
const modeLine =
input.mode === "clarification_required"
? "Чтобы дать точный ответ, нужно уточнить несколько ориентиров."
: input.mode === "no_grounded" || input.mode === "empty"
? "Сейчас подтвержденной опоры недостаточно для прямого вывода."
: "Есть рабочие сигналы, но часть вывода пока ограничена.";
return sanitizeUserFacingReply(
[
`Коротко: ${shortLine}`,
modeLine,
evidenceLines.length > 0 ? `Что уже проверено: ${evidenceLines.join("; ")}` : "",
limitationLines.length > 0 ? `Что пока не доказано: ${limitationLines.join("; ")}` : "",
actionLines.length > 0 ? `Что могу сделать сейчас: ${actionLines.join("; ")}` : ""
]
.filter(Boolean)
.join("\n\n")
);
}
function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutput {
const fallbackType = fallbackFromSummary(input.routeSummary);
const questionType: QuestionTypeClass = input.questionTypeHint ?? "unknown";
@@ -4596,12 +4664,30 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
missingAnchors,
coverageReport: input.coverageReport
})
: renderPolicyReply(answerStructure, {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage,
userMessage: input.userMessage
});
: shouldUseSoftPolicyReply({
mode: guardedDecision.mode,
policySignals,
limitationReasonCodes,
aggregateEvidenceConfidence,
coverageReport: input.coverageReport,
hasCriticalEvidenceLimitation
})
? renderSoftPolicyReply({
structure: answerStructure,
context: {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage,
userMessage: input.userMessage
},
mode: guardedDecision.mode
})
: renderPolicyReply(answerStructure, {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage,
userMessage: input.userMessage
});
return {
assistant_reply: finalAssistantReply,
@@ -998,6 +998,27 @@ function countTokens(text) {
function hasPeriodLiteral(text) {
return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text);
}
function hasStandaloneAddressTopicSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
if (hasFollowupMarker(normalized) || hasReferentialPointer(normalized)) {
return false;
}
const hasRequestCue = /(?:^|[\s,.;:!?()\-])(?:покажи|показать|выведи|дай|найди|список|какие|какой|какая|каких|сколько|где|show|list|find|which|what)/iu.test(normalized);
if (!hasRequestCue) {
return false;
}
const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat)/iu.test(normalized);
if (!hasBusinessObject) {
return false;
}
const hasStructuredAnchor = hasPeriodLiteral(normalized) ||
/\b\d{2}(?:[.,]\d{1,2})?\b/.test(normalized) ||
/(?:альтернатива|лайсвуд|райм|ооо\s+[a-zа-яё])/iu.test(normalized);
return hasStructuredAnchor || countTokens(normalized) >= 6;
}
function extractNormalizedPeriodLiteral(text) {
const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/);
if (monthly) {
@@ -1304,6 +1325,35 @@ function buildAddressCoverageReport() {
out_of_scope_requirements: []
};
}
function buildAssistantBackendErrorDebugPayload(errorMessage) {
return {
trace_id: `chat-${(0, nanoid_1.nanoid)(10)}`,
prompt_version: "assistant_backend_error_fallback_v1",
schema_version: "assistant_backend_error_fallback_v1",
fallback_type: "unknown",
route_summary: null,
fragments: [],
requirements_extracted: [],
coverage_report: buildAddressCoverageReport(),
routes: [],
retrieval_status: [],
retrieval_results: [],
answer_grounding_check: {
status: "no_grounded_answer",
route_subject_match: true,
missing_requirements: [],
reasons: [
`backend_error:${String(errorMessage ?? "unknown_error").slice(0, 280)}`
],
why_included_summary: [],
selection_reason_summary: []
},
dropped_intent_segments: []
};
}
function buildAssistantBackendErrorReply() {
return "Сейчас не удалось завершить разбор из-за внутренней ошибки контуров LLM. Могу продолжить в адресном режиме: проверить документы, договоры и операции по нужному периоду или контрагенту.";
}
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
@@ -2170,6 +2220,9 @@ function hasAddressFollowupContextSignal(userMessage) {
if (!text) {
return false;
}
if (hasStandaloneAddressTopicSignal(text)) {
return false;
}
if (shouldHandleAsAssistantCapabilityMetaQuery(text)) {
return false;
}
@@ -2228,6 +2281,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage)
: false;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) {
return null;
}
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) {
return null;
}
@@ -2859,6 +2917,29 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
Boolean(sourceAnchorQuality.anchorValue) &&
((candidateAnchorQuality.anchorType === "counterparty" &&
candidateAnchorQuality.quality >= 2 &&
Boolean(candidateAnchorQuality.anchorValue) &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "")) ||
(candidateAnchorQuality.quality < sourceAnchorQuality.quality &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidate)));
if (counterpartyAnchorSubstitutedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_substitution",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType &&
sourceAnchorQuality.quality >= 2 &&
@@ -2876,27 +2957,6 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
sanitizedUserMessage
}, userMessage);
}
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
candidateAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
candidateAnchorQuality.quality >= 2 &&
Boolean(sourceAnchorQuality.anchorValue) &&
Boolean(candidateAnchorQuality.anchorValue) &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "");
if (counterpartyAnchorSubstitutedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_substitution",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
if (fallbackCandidate) {
const fallbackAnchorQuality = evaluateAddressAnchorQuality(String(fallbackCandidate.candidate ?? ""));
const fallbackPreferredForAnchorSafety = sameIntentForAnchorSafety &&
@@ -3079,6 +3139,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
llmContractIntent === "unknown";
const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
hasDataRetrievalRequestSignal(rawMessageForGate) ||
hasAccountingSignal(rawMessageForGate) ||
hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate);
const strongDataSignalFromEffectiveMessage = hasStrongDataIntentSignal(repairedInputMessage) ||
@@ -3105,7 +3166,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
reason: "llm_predecompose_unsupported_mode"
};
}
const hasMessageSignal = hasAnyAddressSignal;
const hasMessageSignal = hasAnyAddressSignal || strongDataSignalFromRawMessage || strongDataSignalFromEffectiveMessage;
if (hasMessageSignal) {
return {
runAddressLane: true,
@@ -3190,7 +3251,9 @@ function hasDeepAnalysisPreferenceSignal(text) {
}
const riskOrAnomalySignal = /(?:\u0440\u0438\u0441\u043a|risk|\u0430\u043d\u043e\u043c\u0430\u043b|anomal|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442|conflict|deviation|\u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d|\u043d\u0435\u0441\u044b\u043a\u043e\u0432\u043a|\u043d\u0435\u0441\u0445\u043e\u0434|\u043e\u0448\u0438\u0431|error|issue|\u043f\u0440\u043e\u0431\u043b\u0435\u043c)/iu.test(lower);
const chainSignal = /(?:\u0446\u0435\u043f\u043e\u0447\u043a|chain|trace\s*chain|lifecycle|\u0436\u0438\u0437\u043d\u0435\u043d\u043d[\u0430-\u044f]+\s+\u0446\u0438\u043a\u043b|state\s+transition|\u0440\u0430\u0437\u0440\u044b\u0432[\u0430-\u044f]*)/iu.test(lower);
const diagnosticsSignal = /(?:\u0440\u0430\u0437\u043b\u043e\u0436\u0438|\u0434\u0435\u043a\u043e\u043c\u043f\u043e\u0437|\u0440\u0430\u0437\u0431\u0435\u0440\u0438|\u043f\u043e\u0447\u0435\u043c\u0443|why|\u043a\u043e\u0440\u043d\u0435\u0432[\u0430-\u044f]+\s+\u043f\u0440\u0438\u0447\u0438\u043d|root\s*cause|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c[\u0430-\u044f]*|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower);
const diagnosticsKeywordSignal = /(?:\u0440\u0430\u0437\u043b\u043e\u0436\u0438|\u0434\u0435\u043a\u043e\u043c\u043f\u043e\u0437|\u0440\u0430\u0437\u0431\u0435\u0440\u0438|\u043f\u043e\u0447\u0435\u043c\u0443|why|audit|scan|\u043a\u043e\u0440\u043d\u0435\u0432[\u0430-\u044f]+\s+\u043f\u0440\u0438\u0447\u0438\u043d|root\s*cause|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c[\u0430-\u044f]*|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower);
const diagnosticsCheckVerbSignal = /(?:^|[\s,.;:!?()\-])\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c|\u043a\u0443|\u0438\u043c|\u043a\u0430)(?:$|[\s,.;:!?()\-])/iu.test(lower);
const diagnosticsSignal = diagnosticsKeywordSignal || diagnosticsCheckVerbSignal;
const closureSignal = /(?:\u0437\u0430\u043a\u0440\u044b\u0442\u0438[\u0435\u044f]\s+\u043f\u0435\u0440\u0438\u043e\u0434|period\s*close|\u043d\u0435\s+\u0437\u0430\u043a\u0440\u044b\u043b[\u0430-\u044f]*|\u0445\u0432\u043e\u0441\u0442[\u0430-\u044f]*)/iu.test(lower);
const closureIntentSignal = /(?:\u0437\u0430\u043a\u0440\u044b\u0442[\u0430-\u044f]*|period\s*close|close\s+period)/iu.test(lower);
const closureDiagnosticPhraseSignal = /(?:\u0447\u0442\u043e(?:\s+\S+){0,8}\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower);
@@ -3198,13 +3261,12 @@ function hasDeepAnalysisPreferenceSignal(text) {
const lifecycleMismatchSignal = /(?:\u043d\u0435\s+\u0442\u0435\u043c\s+\u0442\u0438\u043f(?:\u043e\u043c)?\s+\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|wrong\s+closing\s+document|expected\s+transition)/iu.test(lower);
const lifecycleTransitionGapSignal = /(?:\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432|\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432|\u0441\u0442\u0430\u0434\u0438[\u0438\u044f\u0435]\s+.*\u043f\u0440\u043e\u0439\u0434\u0435\u043d.*\u043f\u0435\u0440\u0435\u0445\u043e\u0434)/iu.test(lower);
const expectedActualMismatchSignal = /(?:\u0444\u0430\u043a\u0442\u0438\u0447\u0435\u0441\u043a[\u0430-\u044f]+\s+\u0441\u043e\u0441\u0442\u043e\u044f\u043d[\u0438\u0435\u044f]+\s+.*\u0440\u0430\u0441\u0445\u043e\u0434[\u0430-\u044f]*\s+\u0441\s+\u043e\u0436\u0438\u0434\u0430\u0435\u043c|\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d[\u0430-\u044f]*\s+\u0441\u043f\u0438\u0441\u0430\u043d)/iu.test(lower);
return riskOrAnomalySignal ||
lifecycleMismatchSignal ||
return lifecycleMismatchSignal ||
(chainSignal && lifecycleTransitionGapSignal) ||
expectedActualMismatchSignal ||
(chainSignal && diagnosticsSignal) ||
(riskOrAnomalySignal && (chainSignal || closureSignal || diagnosticsSignal || closureIntentSignal)) ||
(diagnosticsSignal && closureIntentSignal) ||
(riskOrAnomalySignal && (chainSignal || diagnosticsSignal || lifecycleTransitionGapSignal)) ||
(diagnosticsSignal && (closureSignal || closureIntentSignal)) ||
closureDiagnosticPhraseSignal ||
signalVsNoiseDiagnostic;
}
@@ -3213,7 +3275,7 @@ function hasDirectDeepAnalysisSignal(text) {
if (!normalized) {
return false;
}
return /(?:\u0440\u0430\u0437\u043b\u043e\u0436|\u0446\u0435\u043f\u043e\u0447|lifecycle|\u0440\u0430\u0437\u0440\u044b\u0432|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0437\u0430\u043a\u0440\u044b\u0442[\u0430-\u044f]*|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized);
return /(?:\u0440\u0430\u0437\u043b\u043e\u0436|\u0446\u0435\u043f\u043e\u0447|lifecycle|\u0440\u0430\u0437\u0440\u044b\u0432|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0437\u0430\u043a\u0440\u044b\u0442\u0438[\u0435\u044f]\s+\u043f\u0435\u0440\u0438\u043e\u0434|period\s*close|close\s+period|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized);
}
function hasStrictDeepInvestigationCue(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
@@ -3239,6 +3301,23 @@ function hasAggregateBusinessAnalyticsSignal(text) {
const hasPeriodAggregateCue = /(?:\u043f\u043e\s+\u0433\u043e\u0434\u0430\u043c|\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u0433\u043e\u0434(?:\u0430|\u0443|\u044b)?|year|years|\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043c\u0435\u0441\u044f\u0446|\u043f\u0435\u0440\u0438\u043e\u0434)/iu.test(normalized);
return hasRankingOrTrendCue || hasPeriodAggregateCue;
}
function hasOpenContractsAddressSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
const hasContractCue = /(?:договор|контракт|contract)/iu.test(normalized);
if (!hasContractCue) {
return false;
}
const hasOpenCue = /(?:незакрыт|не\s+закрыт|открыт|open\s+contract|open\s+item|open)/iu.test(normalized);
if (!hasOpenCue) {
return false;
}
const hasRequestCue = /(?:покажи|показать|список|какие|какой|show|list|find|на\s+дату|as\s+of)/iu.test(normalized);
const hasTemporalCue = hasPeriodLiteral(normalized) || /\b\d{4}[-/.]\d{2}[-/.]\d{2}\b/.test(normalized);
return hasRequestCue || hasTemporalCue;
}
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_open_contracts",
"open_items_by_counterparty_or_contract",
@@ -3276,10 +3355,21 @@ export function resolveAssistantOrchestrationDecision(input) {
hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) ||
hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) ||
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage);
const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) ||
hasStandaloneAddressTopicSignal(repairedRawUserMessage) ||
hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) ||
hasStandaloneAddressTopicSignal(repairedEffectiveAddressUserMessage);
const openContractsAddressSignal = hasOpenContractsAddressSignal(rawUserMessage) ||
hasOpenContractsAddressSignal(repairedRawUserMessage) ||
hasOpenContractsAddressSignal(effectiveAddressUserMessage) ||
hasOpenContractsAddressSignal(repairedEffectiveAddressUserMessage);
const modeSample = repairedEffectiveAddressUserMessage || effectiveAddressUserMessage;
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(modeSample);
const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(modeSample);
const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmPreDecomposeReason = toNonEmptyString(llmPreDecomposeMeta?.reason);
const llmRuntimeUnavailableDetected = Boolean(llmPreDecomposeReason &&
/(?:openai\s+api\s+key\s+is\s+missing|api\s+key\s+is\s+missing|missing\s+api\s+key|authentication)/iu.test(llmPreDecomposeReason));
const semanticExtractionContract = llmPreDecomposeMeta?.semanticExtractionContract &&
typeof llmPreDecomposeMeta.semanticExtractionContract === "object"
? llmPreDecomposeMeta.semanticExtractionContract
@@ -3295,7 +3385,8 @@ export function resolveAssistantOrchestrationDecision(input) {
hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage);
const keepAddressLaneByIntent = semanticApplyCanonicalRecommended &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent))) &&
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal) &&
!strictDeepInvestigationCueDetected;
const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
hasStrongDataIntentSignal(repairedRawUserMessage) ||
@@ -3387,7 +3478,8 @@ export function resolveAssistantOrchestrationDecision(input) {
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage));
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)));
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal);
const semanticGuardHints = semanticExtractionContract?.guard_hints &&
typeof semanticExtractionContract.guard_hints === "object"
? semanticExtractionContract.guard_hints
@@ -3408,8 +3500,14 @@ export function resolveAssistantOrchestrationDecision(input) {
const unsupportedIntentOrMode = (modeDetection.mode !== "address_query" && intentResolution.intent === "unknown") ||
llmContractMode === "unsupported";
const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
unsupportedIntentOrMode &&
strongDataSignal &&
(llmContractMode === "deep_analysis" ||
!dataRetrievalSignal ||
strictDeepInvestigationCueDetected ||
semanticDeepInvestigationHintDetected ||
aggregateBusinessAnalyticsSignal) &&
!preserveAddressLaneSignal &&
!keepAddressLaneByIntent &&
!supportedAddressIntentDetected &&
@@ -3426,21 +3524,25 @@ export function resolveAssistantOrchestrationDecision(input) {
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
/(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`)));
const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
!keepAddressLaneByIntent &&
!supportedAddressIntentDetected &&
!vatExplainFollowupSignal &&
(!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed));
const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
aggregateBusinessAnalyticsSignal &&
!keepAddressLaneByIntent &&
!supportedAddressIntentDetected &&
(!followupContext ||
llmContractMode === "unsupported" ||
semanticAggregateShapeDetected ||
!semanticApplyCanonicalRecommended));
!semanticApplyCanonicalRecommended ||
standaloneAddressTopicSignal));
const deepSessionContinuationFallbackToDeep = Boolean(!followupContext &&
baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
hasDeepSessionContinuationSignal({
rawUserMessage,
repairedRawUserMessage,
@@ -3547,24 +3649,29 @@ export function resolveAssistantOrchestrationDecision(input) {
}
function hasStrongDataIntentSignal(text) {
const lower = String(text ?? "").toLowerCase();
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account|организац|компан|контор|фирм)/i.test(lower);
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм)/i.test(lower);
}
function hasDataRetrievalRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase());
if (!lower) {
return false;
}
const hasBroadInterrogative = /(?:\u0433\u0434\u0435|\u0432\s+\u043a\u0430\u043a\u0438\u0445|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c|\u043f\u043e\s+\u043a\u043e\u043c\u0443|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0442\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|where|which|who|how\s+many)/iu.test(lower);
const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover)/iu.test(lower);
if (hasBroadInterrogative && hasBroadBusinessObject) {
return true;
}
const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a)(?:$|[\s,.!?;:])/iu.test(lower);
const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446)/iu.test(lower);
if (hasRussianRetrievalAction && hasRussianRetrievalObject) {
return true;
}
const hasExplicitRetrievalAction = /(?:\bпокажи\b|\bпоказать\b|\bвыведи\b|\bнайди\b|\bсписок\b|\bдай\b|\bраскрой\b|\bshow\b|\blist\b|\bfind\b|\bcount\b)/i.test(lower);
const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bwhich\b|\bwho\b)/i.test(lower);
const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bгде\b|\bпо\s+каким\b|\bпо\s+кому\b|\bу\s+кого\b|\bwhich\b|\bwho\b|\bwhere\b)/i.test(lower);
if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
return false;
}
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
if (!hasRetrievalObject) {
return false;
}
@@ -3725,6 +3832,10 @@ function hasAssistantDataScopeMetaQuestionSignal(text) {
if (!normalized) {
return false;
}
const hasDirectSlangScopeLead = /(?:по\s+каким\s+(?:контор(?:ам|ы|а)?|кантор(?:ам|ы|а)?|компан(?:иям|ии|ию|ия)|организац(?:иям|ии|ию|ия))\s+мож(?:ем|но)\s+(?:общат|работ)|база\s+какой\s+(?:контор|компан|организац|фирм)|какая\s+база\s+(?:подключ|подруб|актив))/iu.test(normalized);
if (hasDirectSlangScopeLead) {
return true;
}
const hasSlangScopeQuestion = /(?:\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440(?:\u0430\u043c|\u044b|\u0430)?|\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u0444\u0438\u0440\u043c(?:\u0430\u043c|\u0435|\u0443|\u0430)).*(?:\u043c\u043e\u0436(?:\u0435\u043c|\u043d\u043e)|\u0440\u0430\u0431\u043e\u0442|\u043e\u0431\u0449\u0430\u0442|\u043f\u043e\u0434\u0440\u0443\u0431|\u043f\u043e\u0434\u043a\u043b\u044e\u0447)|(?:\u0431\u0430\u0437\u0430\s+\u043a\u0430\u043a\u043e\u0439\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0444\u0438\u0440\u043c))|(?:\u043a\u0430\u043a\u0430\u044f\s+\u0431\u0430\u0437\u0430\s+(?:\u043f\u043e\u0434\u043a\u043b\u044e\u0447|\u0430\u043a\u0442\u0438\u0432)))/iu.test(normalized);
if (hasSlangScopeQuestion) {
return true;
@@ -4771,13 +4882,55 @@ export class AssistantService {
extractExecutionState
}
});
const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({
payload,
runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)),
resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems),
runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)),
runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps))
});
return turnRuntime.response;
try {
const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({
payload,
runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)),
resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems),
runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)),
runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps))
});
return turnRuntime.response;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const sessionId = String(payload?.session_id ?? payload?.sessionId ?? "").trim() || `asst-${(0, nanoid_1.nanoid)(10)}`;
const ensuredSession = this.sessions.ensureSession(sessionId);
const existingAssistant = [...ensuredSession.items].reverse().find((item) => item.role === "assistant") ?? null;
if (existingAssistant) {
return {
ok: true,
session_id: sessionId,
assistant_reply: existingAssistant.text,
reply_type: existingAssistant.reply_type ?? "backend_error",
conversation_item: existingAssistant,
debug: existingAssistant.debug ?? buildAssistantBackendErrorDebugPayload(errorMessage),
conversation: cloneItems(ensuredSession.items)
};
}
const createdAt = new Date().toISOString();
const debugPayload = buildAssistantBackendErrorDebugPayload(errorMessage);
const assistantItem = this.sessions.appendItem(sessionId, {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: buildAssistantBackendErrorReply(),
reply_type: "backend_error",
created_at: createdAt,
trace_id: debugPayload.trace_id ?? null,
debug: debugPayload
});
const sessionSnapshot = this.sessions.getSession(sessionId) ?? this.sessions.ensureSession(sessionId);
this.sessionLogger.persistSession(sessionSnapshot);
return {
ok: true,
session_id: sessionId,
assistant_reply: assistantItem.text,
reply_type: "backend_error",
conversation_item: assistantItem,
debug: debugPayload,
conversation: cloneItems(sessionSnapshot.items)
};
}
}
}
@@ -1883,6 +1883,15 @@ describe("address filter extraction for balance drilldown", () => {
expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality");
});
it("does not capture narrative filler as counterparty in broad docs-vs-money question", () => {
const extracted = extractAddressFilters(
"В каких случаях мы видим ситуацию, когда документы есть, а денег нет и пока не предвидится?",
"list_documents_by_counterparty"
);
expect(extracted.extracted_filters.counterparty).toBeUndefined();
expect(String(extracted.extracted_filters.counterparty ?? "")).not.toContain("случая");
});
it("does not derive fake counterparty anchor for open-contracts stale-advance wording", () => {
const extracted = extractAddressFilters(
"по каким поставщикам мы видим проблемные авансы, которые давно не закрыты документами?",
@@ -2070,10 +2079,7 @@ describe("address filter extraction for balance drilldown", () => {
it("extracts leading counterparty token for short bank phrase", () => {
const result = extractAddressFilters("свк списания/поступления за 2020", "bank_operations_by_counterparty");
expect(result.extracted_filters.counterparty).toBe("свк");
expect(
result.warnings.includes("counterparty_anchor_derived_from_leading_token") ||
result.warnings.includes("counterparty_anchor_derived_from_free_text_heuristic")
).toBe(true);
expect(result.warnings).toContain("counterparty_anchor_derived_from_leading_token");
});
it("treats 'за весь период' as all-time hint and does not force 90-day default", () => {
@@ -2119,7 +2125,7 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.extracted_filters.period_to).toBe("2020-12-31");
});
it("extracts free-text counterparty and relaxed short-year period from noisy phrase", () => {
it("extracts compact counterparty and relaxed short-year period from noisy phrase", () => {
const result = extractAddressFilters(
"свк 20 год - покажи доки плс",
"list_documents_by_counterparty"
@@ -2127,7 +2133,7 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.extracted_filters.counterparty).toBe("свк");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
expect(result.warnings).toContain("counterparty_anchor_derived_from_free_text_heuristic");
expect(result.warnings).toContain("counterparty_anchor_derived_from_leading_token");
expect(result.warnings).toContain("period_derived_from_year_phrase");
expect(result.extracted_filters.counterparty).not.toBe("плс");
});
@@ -2172,8 +2178,8 @@ describe("address filter extraction for balance drilldown", () => {
expect(
result.warnings.some(
(warning) =>
warning === "counterparty_anchor_derived_from_free_text_heuristic" ||
warning === "counterparty_anchor_derived_from_implicit_phrase"
warning === "counterparty_anchor_derived_from_implicit_phrase" ||
warning === "counterparty_anchor_derived_from_leading_token"
)
).toBe(true);
expect(result.warnings).toContain("period_derived_from_year_phrase");
@@ -272,6 +272,28 @@ describe("assistant orchestration contract", () => {
).toBe(true);
});
it("routes standalone aggregate query to deep even when stale followup context exists and LLM predecompose is unavailable", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "какие обороты по альтернативе за 2020 год",
effectiveAddressUserMessage: "какие обороты по альтернативе за 2020 год",
followupContext: {
previous_intent: "list_documents_by_contract",
previous_filters: {
organization: "ООО Альтернатива Плюс"
}
},
llmPreDecomposeMeta: null as any,
useMock: true
} as any);
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateDecision).toBe("skip_address_lane");
expect(decision.toolGateReason).toBe("aggregate_analytics_signal_fallback_to_deep");
expect(decision.livingMode).toBe("deep_analysis");
expect(decision.livingReason).toBe("aggregate_analytics_signal_fallback_to_deep");
expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(true);
});
it("routes profitability ranking query to deep analysis instead of address lane", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?",
@@ -289,6 +311,47 @@ describe("assistant orchestration contract", () => {
expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(true);
});
it("keeps unsupported retrieval query in address lane when LLM runtime is unavailable", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage:
"\u0413\u0434\u0435 \u0443 \u043d\u0430\u0441 \u043d\u0430\u043a\u043e\u043f\u0438\u043b\u0438\u0441\u044c \u0430\u0432\u0430\u043d\u0441\u044b \u043a \u043e\u0442\u0433\u0440\u0443\u0437\u043a\u0430\u043c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0434\u0430\u0432\u043d\u043e \u043f\u043e\u0440\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u044c?",
effectiveAddressUserMessage:
"\u0413\u0434\u0435 \u0443 \u043d\u0430\u0441 \u043d\u0430\u043a\u043e\u043f\u0438\u043b\u0438\u0441\u044c \u0430\u0432\u0430\u043d\u0441\u044b \u043a \u043e\u0442\u0433\u0440\u0443\u0437\u043a\u0430\u043c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0434\u0430\u0432\u043d\u043e \u043f\u043e\u0440\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u044c?",
followupContext: null,
llmPreDecomposeMeta: {
applied: false,
reason: "error:OpenAI API key is missing.",
llmCanonicalCandidateDetected: false,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
},
semanticExtractionContract: {
valid: false,
apply_canonical_recommended: false,
reason_codes: ["unsupported_low_confidence_contract", "deep_investigation_signal_detected"],
guard_hints: {
deep_investigation_signal_detected: true
},
extraction: {
query_shape: "VERIFY_FACTUAL",
aggregation_profile: "unknown"
}
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
});
it("keeps VAT explain follow-up in address lane when followup context is present", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "почему прогноз к уплате 0?",
@@ -367,6 +430,48 @@ describe("assistant orchestration contract", () => {
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
});
it("keeps open-contracts request in address lane even with stale deep followup context when LLM contract is absent", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",
effectiveAddressUserMessage: "Покажи незакрытые договоры на 2020-12-31",
followupContext: {
previous_intent: "month_close_costs_20_44"
},
llmPreDecomposeMeta: null as any,
useMock: true
} as any);
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("routes 'по каким конторам можем общаться?' to chat/data-scope in orchestration contract", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "по каким конторам можем общаться?",
effectiveAddressUserMessage: "по каким контрагентам у нас есть активность или договоры?",
followupContext: null,
llmPreDecomposeMeta: {
applied: true,
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "address_query",
mode_confidence: "medium",
intent: "list_documents_by_contract",
intent_confidence: "medium"
}
} as any,
useMock: false
} as any);
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateDecision).toBe("skip_address_lane");
expect(decision.toolGateReason).toBe("assistant_data_scope_query_detected");
expect(decision.livingMode).toBe("chat");
expect(decision.livingReason).toBe("assistant_data_scope_query_detected");
});
it("does not force address lane for deep-analysis unknown intent query with date-like token", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "найди какие либо ошибки на 21 мая 2022 года",
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import { composeAssistantAnswer } from "../src/services/answerComposer";
import type { UnifiedRetrievalResult } from "../src/types/assistant";
function routeSummary() {
return {
mode: "deterministic_v2" as const,
message_in_scope: true,
scope_confidence: "high" as const,
planner: {
total_fragments: 1,
in_scope_fragments: 1,
out_of_scope_fragments: 0,
discarded_fragments: 0,
contains_multiple_tasks: false
},
decisions: [],
fallback: {
type: "none" as const,
message: null
}
};
}
describe("assistant soft policy reply", () => {
it("renders soft non-template reply for weak broad-partial envelope", () => {
const retrieval: UnifiedRetrievalResult = {
fragment_id: "F1",
requirement_ids: ["R1"],
route: "store_feature_risk",
status: "partial",
result_type: "summary",
items: [{ note: "weak candidate" }],
summary: {
broad_query_detected: true,
broad_result_flag: true,
minimum_evidence_failed: true,
narrowing_strength: "weak"
},
evidence: [],
why_included: [],
selection_reason: [],
risk_factors: [],
business_interpretation: [],
confidence: "low",
limitations: ["Weak evidence envelope"],
errors: []
};
const output = composeAssistantAnswer({
userMessage: "Покажи общую картину рисков и аномалий по документам.",
routeSummary: routeSummary(),
retrievalResults: [retrieval],
requirements: [
{
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: "общая картина рисков",
subject_tokens: [],
status: "partially_covered",
route: "store_feature_risk"
}
],
coverageReport: {
requirements_total: 1,
requirements_covered: 0,
requirements_uncovered: [],
requirements_partially_covered: ["R1"],
clarification_needed_for: [],
out_of_scope_requirements: []
},
groundingCheck: {
status: "partial",
route_subject_match: true,
missing_requirements: ["R1"],
reasons: ["insufficient_detail"],
why_included_summary: [],
selection_reason_summary: []
},
enableAnswerPolicyV11: true
});
expect(output.reply_type).toBe("partial_coverage");
expect(output.assistant_reply).toContain("Что могу сделать сейчас:");
expect(output.assistant_reply).toContain("Что пока не доказано:");
expect(output.assistant_reply).not.toContain("Что сломано:");
});
it("keeps structured sectioned reply for focused grounded envelope", () => {
const retrieval: UnifiedRetrievalResult = {
fragment_id: "F1",
requirement_ids: ["R1"],
route: "store_feature_risk",
status: "ok",
result_type: "summary",
items: [{ doc: "A-1" }],
summary: {
broad_query_detected: false,
broad_result_flag: false,
minimum_evidence_failed: false,
narrowing_strength: "strong"
},
evidence: [
{
evidence_id: "ev-1",
claim_ref: "requirement:R1",
source_type: "retrieval_item",
source_ref: {
schema_version: "evidence_source_ref_v1",
namespace: "snapshot_2020",
entity: "document",
id: "A-1",
period: "2020-06",
canonical_ref: "evidence_source_ref_v1|snapshot_2020|document|A-1|2020-06"
},
pointer: {
fragment_id: "F1",
route: "store_feature_risk",
source: {
namespace: "snapshot_2020",
entity: "document",
id: "A-1",
period: "2020-06"
}
},
evidence_kind: "fact",
mechanism_note: "Переход подтвержден документом и проводкой.",
confidence: "high",
payload: {
amount: 100
}
}
],
why_included: ["релевантная запись"],
selection_reason: ["сильное совпадение"],
risk_factors: [],
business_interpretation: [],
confidence: "high",
limitations: [],
errors: []
};
const output = composeAssistantAnswer({
userMessage: "Проверь документ A-1 за июнь 2020.",
routeSummary: routeSummary(),
retrievalResults: [retrieval],
requirements: [
{
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: "проверить документ",
subject_tokens: [],
status: "covered",
route: "store_feature_risk"
}
],
coverageReport: {
requirements_total: 1,
requirements_covered: 1,
requirements_uncovered: [],
requirements_partially_covered: [],
clarification_needed_for: [],
out_of_scope_requirements: []
},
groundingCheck: {
status: "grounded",
route_subject_match: true,
missing_requirements: [],
reasons: [],
why_included_summary: ["релевантная запись"],
selection_reason_summary: ["сильное совпадение"]
},
enableAnswerPolicyV11: true
});
expect(output.reply_type).toBe("factual_with_explanation");
expect(output.assistant_reply).toContain("Что сломано:");
expect(output.assistant_reply).toContain("Ограничения:");
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { __evalRouteAsyncTestUtils } from "../src/routes/eval";
describe("eval async job sync", () => {
it("marks cases as completed when report already contains case results", () => {
const job = {
job_id: "job-sync-report",
status: "running",
created_at: "2026-04-11T00:00:00.000Z",
updated_at: "2026-04-11T00:00:00.000Z",
eval_target: "assistant_stage1",
run_id: "assistant-stage1-ut-sync-report",
case_set_file: "dummy.json",
analysis_date: "2017-07-10",
total_cases: 2,
completed_cases: 0,
cases: [
{
case_id: "AUTO-001",
turns_total: 1,
status: "queued",
messages: []
},
{
case_id: "AUTO-002",
turns_total: 1,
status: "queued",
messages: []
}
],
error: null,
report: {
results: [{ case_id: "AUTO-001" }, { case_id: "AUTO-002" }]
}
} as any;
__evalRouteAsyncTestUtils.syncJobWithSessions(job);
expect(job.completed_cases).toBe(2);
expect(job.status).toBe("completed");
expect(job.cases.map((item: any) => item.status)).toEqual(["completed", "completed"]);
});
it("keeps terminal case state when job is already completed and session file is missing", () => {
const job = {
job_id: "job-sync-sticky",
status: "completed",
created_at: "2026-04-11T00:00:00.000Z",
updated_at: "2026-04-11T00:00:00.000Z",
eval_target: "assistant_stage1",
run_id: "assistant-stage1-ut-sync-sticky",
case_set_file: "dummy.json",
analysis_date: null,
total_cases: 1,
completed_cases: 1,
cases: [
{
case_id: "AUTO-001",
turns_total: 1,
status: "completed",
messages: []
}
],
error: null,
report: null
} as any;
__evalRouteAsyncTestUtils.syncJobWithSessions(job);
expect(job.completed_cases).toBe(1);
expect(job.status).toBe("completed");
expect(job.cases[0].status).toBe("completed");
});
});