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

This commit is contained in:
dctouch 2026-04-11 19:45:20 +03:00
parent 88d5561fad
commit 351993430f
20 changed files with 2060 additions and 830 deletions

View File

@ -2416,6 +2416,43 @@ Implemented in current pass (Stage 3.4 deep/living soft-refusal boundary widenin
- Stage 3 focused suite (+boundary): `12` files / `348` tests passed.
- Type build: `npm --prefix llm_normalizer/backend run build` passed.
Implemented in current pass (Stage 3.5 evidence-first soft policy reply):
1. Added soft policy renderer for weak deep-policy envelopes:
- For weak `broad_partial` and clarification/no-grounded envelopes, final user reply now uses concise non-sectional soft format (`Коротко`, `Что уже проверено`, `Что пока не доказано`, `Что могу сделать сейчас`).
- Prevents repeated rigid section template in weak-evidence responses while preserving actionable next steps.
2. Added explicit policy gate for soft rendering:
- `shouldUseSoftPolicyReply(...)` evaluates mode + coverage gaps + weak evidence signals (`broad_query_detected`, `minimum_evidence_failed`, low confidence, critical limitation reason codes).
- Keeps structured sectioned renderer for strong grounded envelopes.
3. Preserved strong answer path:
- `focused_grounded` / high-confidence cases continue to use structured policy layout with explicit sections.
4. Added regression coverage:
- New `assistantSoftPolicyReply.test.ts`:
- weak broad-partial -> soft non-template reply;
- strong grounded -> structured sectioned reply.
5. Validation snapshot:
- Stage 3 focused suite (+soft policy): `13` files / `350` tests passed.
- Type build: `npm --prefix llm_normalizer/backend run build` passed.
Implemented in current pass (Stage 3.6 route arbitration hardening + followup isolation):
1. Isolated standalone address topics from stale followup carryover:
- Added standalone-topic detector (`hasStandaloneAddressTopicSignal`) and applied it in followup signal/carryover gates.
- Explicit standalone requests with their own anchor (`date/account/object`) no longer inherit previous address followup context by default.
2. Hardened orchestration for real regression cases:
- Added explicit open-contract lookup signal guard (`hasOpenContractsAddressSignal`) to keep `list_open_contracts` style requests in address lane even with stale deep context.
- Extended aggregate fallback arbitration so stale followup context cannot pin standalone aggregate queries in address lane (`aggregate_analytics_signal_fallback_to_deep` remains allowed).
3. Improved data-scope slang detection resilience:
- Added direct slang lead detection for queries like `по каким конторам можем общаться` / `какая база подрублена`.
- Preserved deterministic chat/data-scope routing despite aggressive predecompose rewrites.
4. Reduced limited-reply template repetition:
- Reworked unsupported/aggregate limited phrasing in `composeLimitedReply(...)` to softer non-rigid variants.
- Suppressed redundant `Сигнал запроса` line for unsupported aggregate envelopes.
5. Reduced dictionary overfitting pressure in filter extraction:
- Shrunk `COUNTERPARTY_TOKEN_NOISE` to compact core stopwords.
- Added pattern-based filler/slang/profanity detector (`isCounterpartyFillerToken`) instead of expanding static token lists.
6. Validation snapshot:
- Targeted regression pack: `4` files / `318` tests passed (`assistantLivingRouter`, `assistantLivingChatMode`, `addressQueryRuntimeM23`, `assistantSoftPolicyReply`).
- Type build: `npm --prefix llm_normalizer/backend run build` passed.
Acceptance (Stage 3):
1. LLM outputs strictly validated schema for extraction/decomposition (no free-form).
2. Deterministic guards can block or downgrade answers when evidence insufficient.

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;

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 (/[A-ZА-ЯЁ]/u.test(source)) {
return true;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
// Keep only compact lowercase slang aliases (e.g. "svk"), not arbitrary words.
if (/^[a-z]{2,6}$/u.test(source)) {
return true;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
if (/^[а-яё]+$/iu.test(source) && source.length <= 4) {
return true;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return token;
}
return undefined;
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" ||

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
}),

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,6 +3903,24 @@ function composeAssistantAnswerV11(input) {
missingAnchors,
coverageReport: input.coverageReport
})
: 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,

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,6 +4927,7 @@ class AssistantService {
extractExecutionState
}
});
try {
const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({
payload,
runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)),
@ -4825,5 +4937,46 @@ class AssistantService {
});
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;

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;

View File

@ -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 (/[A-ZА-ЯЁ]/u.test(source)) {
return true;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
// Keep only compact lowercase slang aliases (e.g. "svk"), not arbitrary words.
if (/^[a-z]{2,6}$/u.test(source)) {
return true;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
if (/^[а-яё]+$/iu.test(source) && source.length <= 4) {
return true;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return token;
}
return undefined;
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" ||

View File

@ -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
}),

View File

@ -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,6 +4664,24 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
missingAnchors,
coverageReport: input.coverageReport
})
: 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,

View File

@ -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,6 +4882,7 @@ export class AssistantService {
extractExecutionState
}
});
try {
const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({
payload,
runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)),
@ -4780,4 +4892,45 @@ export class AssistantService {
});
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)
};
}
}
}

View File

@ -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");

View File

@ -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 года",

View File

@ -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("Ограничения:");
});
});

View File

@ -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");
});
});

View File

@ -0,0 +1,190 @@
{
"suite_id": "assistant_autogen_runtime_job-dpHXAu3U4-",
"suite_version": "0.1.0",
"schema_version": "assistant_autogen_runtime_v0_1",
"scenario_count": 15,
"case_ids": [
"AUTO-001",
"AUTO-002",
"AUTO-003",
"AUTO-004",
"AUTO-005",
"AUTO-006",
"AUTO-007",
"AUTO-008",
"AUTO-009",
"AUTO-010",
"AUTO-011",
"AUTO-012",
"AUTO-013",
"AUTO-014",
"AUTO-015"
],
"cases": [
{
"case_id": "AUTO-001",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Кому из контрагентов мы уже месяц отдаем товары, но на счетах все еще красуется минусовое сальдо - это реально зеленый свет для ручного вмешательства?"
}
]
},
{
"case_id": "AUTO-002",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?"
}
]
},
{
"case_id": "AUTO-003",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, по которым сальдо у нас выглядит так, будто оно врет - ну точно не совпадает с тем, что они нам прислали. Это уже критично для сверки."
}
]
},
{
"case_id": "AUTO-004",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?"
}
]
},
{
"case_id": "AUTO-005",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "В каких случаях мы видим ситуацию, когда документы есть, а денег - нет и пока не предвидится?"
}
]
},
{
"case_id": "AUTO-006",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие контрагенты висят с закрытыми отгрузками, но с открытыми документами оплаты, что явно выглядит как кейс для ручной проверки?"
}
]
},
{
"case_id": "AUTO-007",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, у которых есть неоплаченные задолженности по договорам на конец месяца - это уже красный свет для бухгалтера."
}
]
},
{
"case_id": "AUTO-008",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "По каким заказчикам мы можем выделить непростую картину: сальдо нулевое, а история платежей явно говорит о том, что все не так просто?"
}
]
},
{
"case_id": "AUTO-009",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?"
}
]
},
{
"case_id": "AUTO-010",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности."
}
]
},
{
"case_id": "AUTO-011",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, по которым на конец месяца сальдо выглядит так, будто документы собраны криво и их нужно перепроверить."
}
]
},
{
"case_id": "AUTO-012",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие у нас зависшие авансы или предоплаты уже давно пора либо закрыть, либо хотя бы проверить - это уже не просто вопрос времени?"
}
]
},
{
"case_id": "AUTO-013",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "По каким контрагентам мы можем заметить такую картину: оплачено меньше, чем отгружено, и это явно требует вмешательства бухгалтера."
}
]
},
{
"case_id": "AUTO-014",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие незакрытые документы по договорам у нас уже давно пора проверить - это грозит серьезными проблемами?"
}
]
},
{
"case_id": "AUTO-015",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться."
}
]
}
]
}

View File

@ -0,0 +1,190 @@
{
"suite_id": "assistant_autogen_runtime_job-wH3kvBePAs",
"suite_version": "0.1.0",
"schema_version": "assistant_autogen_runtime_v0_1",
"scenario_count": 15,
"case_ids": [
"AUTO-001",
"AUTO-002",
"AUTO-003",
"AUTO-004",
"AUTO-005",
"AUTO-006",
"AUTO-007",
"AUTO-008",
"AUTO-009",
"AUTO-010",
"AUTO-011",
"AUTO-012",
"AUTO-013",
"AUTO-014",
"AUTO-015"
],
"cases": [
{
"case_id": "AUTO-001",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Кому из контрагентов мы уже месяц отдаем товары, но на счетах все еще красуется минусовое сальдо - это реально зеленый свет для ручного вмешательства?"
}
]
},
{
"case_id": "AUTO-002",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?"
}
]
},
{
"case_id": "AUTO-003",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, по которым сальдо у нас выглядит так, будто оно врет - ну точно не совпадает с тем, что они нам прислали. Это уже критично для сверки."
}
]
},
{
"case_id": "AUTO-004",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?"
}
]
},
{
"case_id": "AUTO-005",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "В каких случаях мы видим ситуацию, когда документы есть, а денег - нет и пока не предвидится?"
}
]
},
{
"case_id": "AUTO-006",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие контрагенты висят с закрытыми отгрузками, но с открытыми документами оплаты, что явно выглядит как кейс для ручной проверки?"
}
]
},
{
"case_id": "AUTO-007",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, у которых есть неоплаченные задолженности по договорам на конец месяца - это уже красный свет для бухгалтера."
}
]
},
{
"case_id": "AUTO-008",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "По каким заказчикам мы можем выделить непростую картину: сальдо нулевое, а история платежей явно говорит о том, что все не так просто?"
}
]
},
{
"case_id": "AUTO-009",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?"
}
]
},
{
"case_id": "AUTO-010",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности."
}
]
},
{
"case_id": "AUTO-011",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, по которым на конец месяца сальдо выглядит так, будто документы собраны криво и их нужно перепроверить."
}
]
},
{
"case_id": "AUTO-012",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие у нас зависшие авансы или предоплаты уже давно пора либо закрыть, либо хотя бы проверить - это уже не просто вопрос времени?"
}
]
},
{
"case_id": "AUTO-013",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "По каким контрагентам мы можем заметить такую картину: оплачено меньше, чем отгружено, и это явно требует вмешательства бухгалтера."
}
]
},
{
"case_id": "AUTO-014",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Какие незакрытые документы по договорам у нас уже давно пора проверить - это грозит серьезными проблемами?"
}
]
},
{
"case_id": "AUTO-015",
"scenario_tag": "autogen_runtime",
"question_type": "direct",
"broadness_level": "medium",
"turns": [
{
"user_message": "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться."
}
]
}
]
}

View File

@ -0,0 +1,112 @@
{
"run_id": "eval-Mu-hfNaOlK",
"timestamp": "2026-04-11T15:21:02.731Z",
"mode": "single-pass-strict",
"use_mock": true,
"prompt_version": "normalizer_v2_0_2",
"schema_version": "v2_0_2",
"dataset": {
"source": "inline_raw_questions",
"file": null,
"raw_questions_count": 2
},
"cases_total": 2,
"metrics": {
"schema_validation_pass_rate": 100,
"scope_detection_accuracy": null,
"scope_in_scope_rate": 100,
"multi_intent_detected_rate": 0,
"clarification_required_rate": 0,
"avg_fragments_per_message": 1,
"out_of_scope_fragment_rate": 0,
"routed_fragment_rate": 100,
"no_route_fragment_rate": 0,
"route_resolution_accuracy": null,
"no_route_precision": null,
"false_no_route_rate": null,
"execution_state_consistency_rate": 100,
"executable_with_soft_assumptions_rate": 100,
"soft_assumption_used_fragment_rate": 100,
"clarification_precision": null,
"clarification_recall": null,
"false_clarification_rate": null
},
"budget": {
"requests_total": 0,
"retries_used": 0
},
"clarification_eval": {
"labeled_cases": 0,
"true_positive": 0,
"false_positive": 0,
"false_negative": 0
},
"route_eval": {
"labeled_cases": 0,
"correct_cases": 0,
"expected_routed_cases": 0,
"no_route_true_positive": 0,
"no_route_false_positive": 0
},
"scope_eval": {
"labeled_cases": 0,
"correct_cases": 0
},
"execution_state_eval": {
"checks_total": 2,
"checks_passed": 2
},
"route_distribution": {
"store_feature_risk": 1,
"hybrid_store_plus_live": 1
},
"fallback_distribution": {
"none": 2
},
"results": [
{
"case_id": "BQ-001",
"raw_question": "Проверь счет 60 за июнь 2020",
"validation_passed": true,
"message_in_scope": true,
"scope_confidence": "high",
"contains_multiple_tasks": false,
"fragments_total": 1,
"in_scope_fragments": 1,
"out_of_scope_fragments": 0,
"unclear_fragments": 0,
"fallback_type": "none",
"predicted_route_status": "routed",
"expected_route_status": null,
"predicted_no_route_reason": null,
"expected_no_route_reason": null,
"predicted_clarification_required": false,
"expected_clarification_required": null,
"executable_with_soft_assumptions_fragments": 1,
"trace_id": "B_29wyGfEBZtW1",
"request_count_for_case": 0
},
{
"case_id": "BQ-002",
"raw_question": "Покажи риски по счету 97",
"validation_passed": true,
"message_in_scope": true,
"scope_confidence": "high",
"contains_multiple_tasks": false,
"fragments_total": 1,
"in_scope_fragments": 1,
"out_of_scope_fragments": 0,
"unclear_fragments": 0,
"fallback_type": "none",
"predicted_route_status": "routed",
"expected_route_status": null,
"predicted_no_route_reason": null,
"expected_no_route_reason": null,
"predicted_clarification_required": false,
"expected_clarification_required": null,
"executable_with_soft_assumptions_fragments": 1,
"trace_id": "Tq98dzUzkLNd9e",
"request_count_for_case": 0
}
]
}

View File

@ -407,6 +407,15 @@ function CommentResolvedIcon({ resolved }: { resolved: boolean }) {
);
}
function CopyOutlineIcon() {
return (
<svg className="autoruns-copy-icon-svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="9" y="9" width="11" height="11" rx="2.2" />
<path d="M15 7V5.8a1.8 1.8 0 0 0-1.8-1.8H5.8A1.8 1.8 0 0 0 4 5.8v7.4A1.8 1.8 0 0 0 5.8 15H7" />
</svg>
);
}
export function AutoRunsHistoryPanel({
connection,
prompts,
@ -526,6 +535,38 @@ export function AutoRunsHistoryPanel({
[onLog]
);
const copyRunIdToClipboard = useCallback(
async (event: React.SyntheticEvent, runId: string) => {
event.stopPropagation();
event.preventDefault();
const value = String(runId ?? "").trim();
if (!value) {
return;
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
log(`run id copied: ${value}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Копирование run id: ${message}`);
log(`copy run id error: ${message}`);
}
},
[log]
);
function startAssistantLiveStatusTicker(): () => void {
let index = 0;
setAssistantLiveStatus(ASSISTANT_STAGES[0]);
@ -1819,7 +1860,25 @@ export function AutoRunsHistoryPanel({
<strong>{formatDateTime(run.run_timestamp)}</strong>
<span>{formatShortTarget(run.eval_target)}</span>
</div>
<div className="autoruns-run-meta">{run.run_id}</div>
<div className="autoruns-run-meta autoruns-run-id-row">
<span>{run.run_id}</span>
<span
role="button"
tabIndex={0}
className="autoruns-copy-run-id-btn"
onClick={(event) => void copyRunIdToClipboard(event, run.run_id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void copyRunIdToClipboard(event, run.run_id);
}
}}
title="Скопировать run id"
aria-label={`Скопировать run id ${run.run_id}`}
>
<CopyOutlineIcon />
</span>
</div>
<div className="autoruns-run-meta">
режим={run.mode ?? "нет данных"} | mock={String(run.use_mock)}
</div>

View File

@ -891,6 +891,60 @@ button:disabled {
word-break: break-word;
}
.autoruns-run-id-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.autoruns-run-id-row > span {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.autoruns-copy-run-id-btn {
border: none;
background: transparent;
color: rgb(var(--rgb-text-main));
width: 16px;
height: 16px;
min-width: 16px;
min-height: 16px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
opacity: 0.92;
cursor: pointer;
}
.autoruns-copy-run-id-btn:hover {
color: rgb(var(--rgb-text-main));
opacity: 1;
background: transparent;
box-shadow: none;
transform: none;
}
.autoruns-copy-run-id-btn:focus-visible {
outline: 1px solid rgba(var(--rgb-text-main), 0.7);
outline-offset: 1px;
}
.autoruns-copy-icon-svg {
width: 0.82rem;
height: 0.82rem;
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.autoruns-dialog-toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -946,12 +1000,17 @@ button:disabled {
padding: 8px 10px;
display: grid;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.autoruns-msg header,
.autoruns-msg footer {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
min-width: 0;
gap: 8px;
font-size: 0.74rem;
color: var(--text-muted);
@ -960,9 +1019,19 @@ button:disabled {
.autoruns-msg-head-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 1 1 auto;
min-width: 0;
flex-wrap: wrap;
gap: 8px;
}
.autoruns-msg-head-actions > span {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.autoruns-msg-case-tag {
display: inline-flex;
align-items: center;
@ -979,6 +1048,14 @@ button:disabled {
white-space: pre-wrap;
line-height: 1.35;
font-size: 0.84rem;
overflow-wrap: anywhere;
word-break: break-word;
}
.autoruns-msg footer span {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.autoruns-comment-icon {