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

This commit is contained in:
2026-04-11 19:45:20 +03:00
parent 88d5561fad
commit 351993430f
20 changed files with 2060 additions and 830 deletions
+31 -1
View File
@@ -202,6 +202,11 @@ export const __evalRouteTestUtils = {
normalizeRuntimeQuestions
};
export const __evalRouteAsyncTestUtils = {
readReportedCaseIds,
syncJobWithSessions
};
function normalizeCaseIds(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
@@ -365,10 +370,30 @@ function readSessionConversation(runId: string, caseId: string): EvalAsyncCaseIn
}
}
function readReportedCaseIds(job: EvalAsyncJob): Set<string> {
const output = new Set<string>();
const reportRecord = toRecord(job.report);
const reportResults = toArray(reportRecord?.results)
.map((item) => toRecord(item))
.filter((item): item is Record<string, unknown> => item !== null);
for (const result of reportResults) {
const caseId = toStringSafe(result.case_id);
if (!caseId) continue;
output.add(caseId);
}
return output;
}
function isTerminalCaseStatus(status: EvalAsyncStatus): boolean {
return status === "completed" || status === "failed";
}
function syncJobWithSessions(job: EvalAsyncJob): void {
if (!job.run_id || !job.eval_target.startsWith("assistant_")) {
return;
}
const reportCaseIds = readReportedCaseIds(job);
let completed = 0;
let hasRunning = false;
for (const item of job.cases) {
@@ -376,11 +401,16 @@ function syncJobWithSessions(job: EvalAsyncJob): void {
item.messages = messages;
const assistantMessages = messages.filter((entry) => entry.role === "assistant").length;
const userMessages = messages.filter((entry) => entry.role === "user").length;
if (assistantMessages >= item.turns_total && item.turns_total > 0) {
const reportMarkedDone = reportCaseIds.has(item.case_id);
if ((assistantMessages >= item.turns_total && item.turns_total > 0) || reportMarkedDone) {
item.status = "completed";
completed += 1;
continue;
}
if (isTerminalCaseStatus(item.status) && isTerminalCaseStatus(job.status)) {
completed += 1;
continue;
}
if (userMessages > 0 || messages.length > 0) {
item.status = "running";
hasRunning = true;
@@ -36,6 +36,84 @@ const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN =
/(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu;
const DOC_SIGNAL_PATTERN =
"(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|docy|doci|doki|dokument(?:y|ov|am|a)?)";
const COUNTERPARTY_TOKEN_NOISE = new Set([
"за",
"с",
"по",
"у",
"на",
"и",
"или",
"в",
"к",
"год",
"года",
"г",
"year",
"кто",
"что",
"где",
"когда",
"сколько",
"почему",
"зачем",
"какой",
"какая",
"какие",
"каких",
"каким",
"мы",
"нам",
"нас",
"есть",
"можно",
"могу",
"можем",
"нет",
"покажи",
"показать",
"скажи",
"выведи",
"show",
"list",
"контра",
"контре",
"контрагент",
"компания",
"организация",
"client",
"customer",
"supplier",
"vendor",
"partner",
"company",
"counterparty"
]);
function isCounterpartyFillerToken(token: string): boolean {
const normalized = String(token ?? "").trim().toLowerCase();
if (!normalized) {
return true;
}
if (/^(?:пл[сз]|пж|пжлст|pls|please|пожалуйста)$/iu.test(normalized)) {
return true;
}
if (/^(?:бл[яе]|блять|нах|нахуй|епт|ёпт|епта)$/iu.test(normalized)) {
return true;
}
if (/^(?:док(?:и|ам|ами|умент(?:ы|ов)?)?|docs?|docy|doci|doki|dokument(?:y|ov|am|a)?)$/iu.test(normalized)) {
return true;
}
if (/^(?:pokazh?|pokazhi|pokaji|pokezh|kakie|kakoi|kakaya|est|za|po|na|s|vse|all|poka)$/iu.test(normalized)) {
return true;
}
return false;
}
function isCounterpartyNoiseToken(rawToken: string): boolean {
const normalized = String(rawToken ?? "").trim().toLowerCase();
return COUNTERPARTY_TOKEN_NOISE.has(normalized) || isCounterpartyFillerToken(normalized);
}
function textMojibakeScore(value: string): number {
const source = String(value ?? "");
@@ -488,101 +566,17 @@ function extractLooseByAnchorValue(text: string): string | undefined {
if (!token) {
return undefined;
}
const lowered = token.toLowerCase();
const stopWords = new Set([
"какой",
"какая",
"какие",
"каких",
"каким",
"какими",
"каком",
"кто",
"что",
"мы",
"видим",
"контрагенту",
"контрагента",
"контрагентам",
"контре",
"компании",
"компанию",
"организации",
"организацию",
"поставщику",
"поставщика",
"поставщикам",
"клиенту",
"клиента",
"клиентам",
"покупателю",
"покупателя",
"покупателям",
"заказчикам",
"партнеру",
"партнера",
"договору",
"договора",
"контракту",
"контракта",
"счету",
"счёту",
"дате",
"периоду",
"период",
"есть",
"же",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"неуказанному",
"указанному",
"указанный",
"указанная",
"указанное",
"объекту",
"объект",
"документам",
"документами",
"докам",
"взаиморасчетам",
"взаиморасчётам",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"связанный",
"связанные",
"связанных",
"связанным",
"связанному",
"related",
"linked",
"нему",
"ней",
"нее",
"ним",
"этому",
"тому",
"этомуже",
"томуже"
]);
if (stopWords.has(lowered)) {
const normalizedToken = cleanupAnchorValue(token);
if (!normalizedToken) {
return undefined;
}
return token;
if (!hasStrongCounterpartyTokenShape(normalizedToken)) {
return undefined;
}
if (!isLikelyCounterpartyToken(normalizedToken)) {
return undefined;
}
return normalizedToken;
}
function extractContractTokenHeuristic(text: string): string | undefined {
@@ -610,207 +604,20 @@ function extractContractTokenHeuristic(text: string): string | undefined {
function isLikelyCounterpartyToken(rawToken: string): boolean {
const token = String(rawToken ?? "").trim();
const lowered = token.toLowerCase();
if (!token || token.length < 2) {
if (!token || token.length < 3) {
return false;
}
const lowered = token.toLowerCase();
if (/^\d+$/.test(lowered)) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(lowered)) {
return false;
}
const stopWords = new Set([
"за",
"с",
"по",
"у",
"на",
"и",
"или",
"какие",
"какой",
"какая",
"какое",
"каких",
"каким",
"какими",
"каком",
"какому",
"какую",
"кто",
"что",
"чего",
"где",
"когда",
"почему",
"зачем",
"сколько",
"чьи",
"чья",
"чей",
"чью",
"мы",
"видим",
"самый",
"самая",
"самое",
"самые",
"крупный",
"крупная",
"крупное",
"крупные",
"жирный",
"жирная",
"жирное",
"жирные",
"больше",
"меньше",
"платит",
"платят",
"прогноз",
"forecast",
"план",
"плана",
"ндс",
"vat",
"налог",
"оплата",
"оплаты",
"платеж",
"платёж",
"платежа",
"платежи",
"денег",
"деньги",
"объем",
"объём",
"док",
"доки",
"документ",
"документы",
"документов",
"документами",
"документу",
"документе",
"документа",
"документах",
"докам",
"доками",
"банк",
"банковские",
"операции",
"платежи",
"платеж",
"платёж",
"контрагент",
"контрагенту",
"контрагента",
"контрагентам",
"компания",
"компании",
"организация",
"организации",
"поставщикам",
"клиентам",
"покупателям",
"заказчикам",
"аванс",
"авансы",
"проблемный",
"проблемные",
"проблемным",
"закрытия",
"закрыть",
"закрыты",
"год",
"года",
"г",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"бля",
"блять",
"епт",
"ёпт",
"епта",
"нах",
"нахуй",
"есть",
"же",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"указанному",
"указанный",
"указанная",
"указанное",
"объекту",
"объект",
"покеж",
"покажи",
"скажи",
"показать",
"выведи",
"show",
"list",
"please",
"теперь",
"сейчас",
"вернись",
"вернуться",
"вернуть",
"раскрой",
"раскрыть",
"раскройте",
"нему",
"ней",
"ним",
"этому",
"тому",
"этомуже",
"томуже",
"vse",
"all",
"kakie",
"kakoi",
"est",
"za",
"po",
"na",
"s",
"poka",
"pokaji",
"skazhi",
"pokazhi",
"pokazh",
"pokezh",
"doki",
"doky",
"dokument",
"dokumenty",
"documents",
"docs",
"связанный",
"связанные",
"связанных",
"связанным",
"связанному",
"related",
"linked"
]);
return !stopWords.has(lowered);
if (/^(?:(?:19|20)?\d{2})(?:-?й)?(?:г|год|года)?$/iu.test(lowered)) {
return false;
}
return !isCounterpartyNoiseToken(lowered);
}
function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
@@ -892,64 +699,25 @@ function hasDocsOrBankSignal(text: string): boolean {
);
}
function extractCounterpartyFromFreeTextHeuristic(text: string): string | undefined {
if (!hasDocsOrBankSignal(text)) {
return undefined;
function hasStrongCounterpartyTokenShape(token: string): boolean {
const source = String(token ?? "").trim();
if (!source) {
return false;
}
const tokens = String(text ?? "")
.split(/[^\p{L}\p{N}._-]+/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (tokens.length === 0) {
return undefined;
if (/[0-9]/u.test(source) || /[._/-]/u.test(source)) {
return true;
}
const monthTokens = [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"сент",
"окт",
"ноя",
"дек",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december"
];
for (const token of tokens) {
const lowered = token.toLowerCase();
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) {
continue;
}
if (monthTokens.some((prefix) => lowered.startsWith(prefix))) {
continue;
}
if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) {
continue;
}
return token;
if (/[A-ZА-ЯЁ]/u.test(source)) {
return true;
}
return undefined;
// Keep only compact lowercase slang aliases (e.g. "svk"), not arbitrary words.
if (/^[a-z]{2,6}$/u.test(source)) {
return true;
}
if (/^[а-яё]+$/iu.test(source) && source.length <= 4) {
return true;
}
return false;
}
function extractImplicitCounterpartyValue(text: string): string | undefined {
@@ -961,7 +729,7 @@ function extractImplicitCounterpartyValue(text: string): string | undefined {
const beforeDocsMatch = input.match(beforeDocsPattern);
if (beforeDocsMatch) {
const candidate = String(beforeDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
@@ -973,7 +741,7 @@ function extractImplicitCounterpartyValue(text: string): string | undefined {
const afterDocsMatch = input.match(afterDocsPattern);
if (afterDocsMatch) {
const candidate = String(afterDocsMatch[1] ?? "").trim();
if (isLikelyCounterpartyToken(candidate)) {
if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) {
return candidate;
}
}
@@ -1022,6 +790,9 @@ function extractLeadingCounterpartyTokenHeuristic(text: string): string | undefi
];
for (const token of tokens.slice(0, 3)) {
const lowered = token.toLowerCase();
if (!hasStrongCounterpartyTokenShape(token)) {
continue;
}
if (!isLikelyCounterpartyToken(lowered)) {
continue;
}
@@ -1154,18 +925,6 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
}
}
if (
!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
) {
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
if (heuristicCounterparty) {
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
if (
!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
@@ -1111,6 +1111,7 @@ function buildLimitedScopeLine(filters: AddressFilterSet): string | null {
function buildLimitedOffers(input: {
category: AddressLimitedReasonCategory;
shape: AddressQueryShapeDetection;
intent: AddressIntent;
filters: AddressFilterSet;
missingRequiredFilters: string[];
reason: string;
@@ -1139,6 +1140,14 @@ function buildLimitedOffers(input: {
}
}
if (input.intent === "list_receivables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
} else if (input.intent === "list_payables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76");
} else if (input.intent === "open_items_by_counterparty_or_contract" || input.intent === "list_open_contracts") {
offers.push("показать незакрытые договоры и хвосты взаиморасчетов на дату");
}
if (counterparty) {
offers.push(`показать документы и платежи по контрагенту ${counterparty}`);
} else if (contract) {
@@ -1170,49 +1179,128 @@ function buildLimitedOffers(input: {
return Array.from(new Set(offers)).slice(0, 3);
}
function buildLimitedIntentSignalLine(input: {
intent: AddressIntent;
shape: AddressQueryShapeDetection;
}): string | null {
const byIntent: Partial<Record<AddressIntent, string>> = {
list_documents_by_counterparty: "Сигнал запроса: нужен срез документов/платежей по контрагенту.",
list_documents_by_contract: "Сигнал запроса: нужен срез документов/платежей по договору.",
bank_operations_by_counterparty: "Сигнал запроса: нужен срез банковских операций по контрагенту.",
bank_operations_by_contract: "Сигнал запроса: нужен срез банковских операций по договору.",
open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.",
list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов."
};
const byShape: Partial<Record<AddressQueryShapeDetection["shape"], string>> = {
AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.",
DOCUMENT_LIST: "Сигнал запроса: список документов/операций.",
OBJECT_LOOKUP: "Сигнал запроса: поиск конкретных объектов.",
VERIFY_FACTUAL: "Сигнал запроса: проверка фактического состояния по данным.",
COMPOUND_FACTUAL_QUERY: "Сигнал запроса: комбинированная проверка взаимосвязанных фактов."
};
return byIntent[input.intent] ?? byShape[input.shape.shape] ?? null;
}
function hasAggregateLimitedSignal(input: {
shape: AddressQueryShapeDetection;
intent: AddressIntent;
reason: string;
}): boolean {
if (input.shape.shape === "AGGREGATE_LOOKUP") {
return true;
}
if (
input.intent === "counterparty_population_and_roles" ||
input.intent === "counterparty_activity_lifecycle" ||
input.intent === "contract_usage_overview" ||
input.intent === "supplier_payouts_profile" ||
input.intent === "customer_revenue_and_payments" ||
input.intent === "contract_usage_and_value"
) {
return true;
}
return /(?:оборот|выруч|доход|прибыл|марж|рентабел|тренд|динам|самый|топ|ranking|revenue|profit|margin|year)/iu.test(
String(input.reason ?? "")
);
}
function composeLimitedReply(input: {
category: AddressLimitedReasonCategory;
reason: string;
nextStep?: string;
shape: AddressQueryShapeDetection;
intent: AddressIntent;
filters: AddressFilterSet;
missingRequiredFilters: string[];
}): string {
const reason = normalizeLimitedReason(input.reason);
const headingSeed = `${input.category}|${input.shape.shape}|${reason}`;
const aggregateLimitedSignal = hasAggregateLimitedSignal({
shape: input.shape,
intent: input.intent,
reason: input.reason
});
const heading =
input.category === "empty_match"
? pickDeterministicVariant(headingSeed, [
"По текущим условиям в доступном срезе данных совпадений не нашлось.",
"В текущем срезе данных по этому запросу совпадения не найдены."
"В текущем срезе данных по этому запросу совпадения не найдены.",
"По заданным фильтрам в текущем срезе совпадений пока нет."
])
: input.category === "missing_anchor"
? pickDeterministicVariant(headingSeed, [
"Чтобы ответ был точным, нужно чуть сильнее заякорить запрос.",
"Запрос понятен, но для надежного ответа не хватает опорного ориентира."
"Запрос понятен, но для надежного ответа не хватает опорного ориентира.",
"Вопрос по смыслу ясен, но пока не хватает конкретной опоры для выборки."
])
: input.category === "recipe_visibility_gap"
? pickDeterministicVariant(headingSeed, [
"Запрос понятен, но текущий сценарий выборки не дает нужной детализации.",
"Смысл запроса ясен, но в этом контуре не хватает глубины выборки."
"Смысл запроса ясен, но в этом контуре не хватает глубины выборки.",
"Сценарий запроса корректный, но текущая витрина не дает нужной детализации."
])
: input.category === "unsupported"
? pickDeterministicVariant(headingSeed, [
"По этому вопросу в текущем адресном контуре пока нет надежного маршрута ответа.",
"Сейчас в адресном режиме такой сценарий не закрыт без риска ошибочного вывода."
"Сейчас не дам прямой адресный ответ, чтобы не ошибиться в выводах.",
"В текущем адресном контуре этот запрос лучше не закрывать «в лоб» — риск неверной трактовки высок.",
"Для такого формата запроса нужен более широкий аналитический контур, иначе ответ будет ненадежным."
])
: "Не удалось завершить проверку в адресном режиме.";
const reasonSeed = `${headingSeed}|reason`;
const reasonLine =
input.category === "unsupported"
? "Коротко: сценарий пока не покрыт текущими адресными маршрутами."
? aggregateLimitedSignal
? pickDeterministicVariant(reasonSeed, [
"Это агрегатный/сравнительный вопрос: без расширенного анализа здесь легко дать ложную метрику.",
"Запрос про сводную аналитику или ранжирование, поэтому в address-контуре ответ сейчас будет ненадежным.",
"Нужна расширенная аналитическая обработка: адресный режим в этом кейсе не гарантирует корректный расчет."
])
: pickDeterministicVariant(reasonSeed, [
"Сценарий пока не закрыт текущими адресными маршрутами без потери точности.",
"Для этого запроса пока нет надежного ответа внутри текущего address-контура."
])
: input.category === "missing_anchor"
? "Коротко: не хватает конкретного ориентира (контрагент, договор, счет или период)."
? pickDeterministicVariant(reasonSeed, [
"Нужно чуть точнее заякорить запрос: не хватает конкретного ориентира (контрагент, договор, счет или период).",
"Для точного ответа нужен хотя бы один явный якорь: контрагент, договор, счет или период."
])
: input.category === "recipe_visibility_gap"
? "Коротко: для уверенного ответа нужен более специализированный сценарий выборки."
: `Коротко: ${reason}.`;
? "Для уверенного ответа нужен более специализированный сценарий выборки."
: `${reason}.`;
const lines = [heading, reasonLine];
const signalLine = buildLimitedIntentSignalLine({
intent: input.intent,
shape: input.shape
});
if (signalLine && !(input.category === "unsupported" && aggregateLimitedSignal)) {
lines.push(signalLine);
}
const scopeLine = buildLimitedScopeLine(input.filters);
if (scopeLine) {
lines.push(scopeLine);
@@ -1221,6 +1309,7 @@ function composeLimitedReply(input: {
const offers = buildLimitedOffers({
category: input.category,
shape: input.shape,
intent: input.intent,
filters: input.filters,
missingRequiredFilters: input.missingRequiredFilters,
reason: input.reason,
@@ -1275,6 +1364,7 @@ function buildLimitedExecutionResult(input: {
reason: input.reasonText,
nextStep: input.nextStep,
shape: input.shape,
intent: input.intent.intent,
filters: input.filters,
missingRequiredFilters: input.missingRequiredFilters
}),
@@ -4306,6 +4306,74 @@ function renderPolicyReply(structure: AnswerStructureV11, context?: AnswerRender
);
}
function shouldUseSoftPolicyReply(input: {
mode: PolicyMode;
policySignals: PolicySignals;
limitationReasonCodes: EvidenceLimitationReasonCode[];
aggregateEvidenceConfidence: EvidenceConfidence;
coverageReport: RequirementCoverageReport;
hasCriticalEvidenceLimitation: boolean;
}): boolean {
if (input.mode === "focused_grounded" || input.mode === "route_mismatch" || input.mode === "backend_error" || input.mode === "out_of_scope") {
return false;
}
if (input.mode === "clarification_required" || input.mode === "no_grounded" || input.mode === "empty") {
return true;
}
if (input.mode !== "broad_partial") {
return false;
}
const hasCoverageGaps =
input.coverageReport.requirements_uncovered.length > 0 ||
input.coverageReport.requirements_partially_covered.length > 0 ||
input.coverageReport.clarification_needed_for.length > 0 ||
input.coverageReport.out_of_scope_requirements.length > 0;
const weakEvidenceSignals =
input.policySignals.broad_query_detected ||
input.policySignals.broad_result_flag ||
input.policySignals.minimum_evidence_failed ||
input.aggregateEvidenceConfidence === "low" ||
input.hasCriticalEvidenceLimitation ||
input.limitationReasonCodes.includes("weak_source_mapping") ||
input.limitationReasonCodes.includes("insufficient_detail") ||
input.limitationReasonCodes.includes("missing_mechanism");
return hasCoverageGaps || weakEvidenceSignals;
}
function renderSoftPolicyReply(input: {
structure: AnswerStructureV11;
context?: AnswerRenderContext;
mode: PolicyMode;
}): string {
const questionType = input.context?.questionType ?? "unknown";
const shortLine = ensureSentence(buildShortSectionLine(input.structure));
const evidenceLines = dedupeNarrativeLines(buildEvidenceSectionLines(input.structure, questionType, input.context), 3);
const limitationLines = dedupeNarrativeLines(buildLimitationsSectionLines(input.structure), 3);
const checkLines = dedupeNarrativeLines(buildChecksSectionLines(input.structure, input.context), 3);
const clarificationLines = dedupeNarrativeLines(input.structure.next_step_block.clarification_questions ?? [], 2);
const actionLines = dedupeNarrativeLines(
[...checkLines, ...(input.structure.next_step_block.recommended_actions ?? []), ...clarificationLines],
3
);
const modeLine =
input.mode === "clarification_required"
? "Чтобы дать точный ответ, нужно уточнить несколько ориентиров."
: input.mode === "no_grounded" || input.mode === "empty"
? "Сейчас подтвержденной опоры недостаточно для прямого вывода."
: "Есть рабочие сигналы, но часть вывода пока ограничена.";
return sanitizeUserFacingReply(
[
`Коротко: ${shortLine}`,
modeLine,
evidenceLines.length > 0 ? `Что уже проверено: ${evidenceLines.join("; ")}` : "",
limitationLines.length > 0 ? `Что пока не доказано: ${limitationLines.join("; ")}` : "",
actionLines.length > 0 ? `Что могу сделать сейчас: ${actionLines.join("; ")}` : ""
]
.filter(Boolean)
.join("\n\n")
);
}
function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutput {
const fallbackType = fallbackFromSummary(input.routeSummary);
const questionType: QuestionTypeClass = input.questionTypeHint ?? "unknown";
@@ -4596,12 +4664,30 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
missingAnchors,
coverageReport: input.coverageReport
})
: renderPolicyReply(answerStructure, {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage,
userMessage: input.userMessage
});
: shouldUseSoftPolicyReply({
mode: guardedDecision.mode,
policySignals,
limitationReasonCodes,
aggregateEvidenceConfidence,
coverageReport: input.coverageReport,
hasCriticalEvidenceLimitation
})
? renderSoftPolicyReply({
structure: answerStructure,
context: {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage,
userMessage: input.userMessage
},
mode: guardedDecision.mode
})
: renderPolicyReply(answerStructure, {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage,
userMessage: input.userMessage
});
return {
assistant_reply: finalAssistantReply,
@@ -998,6 +998,27 @@ function countTokens(text) {
function hasPeriodLiteral(text) {
return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text);
}
function hasStandaloneAddressTopicSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
if (hasFollowupMarker(normalized) || hasReferentialPointer(normalized)) {
return false;
}
const hasRequestCue = /(?:^|[\s,.;:!?()\-])(?:покажи|показать|выведи|дай|найди|список|какие|какой|какая|каких|сколько|где|show|list|find|which|what)/iu.test(normalized);
if (!hasRequestCue) {
return false;
}
const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat)/iu.test(normalized);
if (!hasBusinessObject) {
return false;
}
const hasStructuredAnchor = hasPeriodLiteral(normalized) ||
/\b\d{2}(?:[.,]\d{1,2})?\b/.test(normalized) ||
/(?:альтернатива|лайсвуд|райм|ооо\s+[a-zа-яё])/iu.test(normalized);
return hasStructuredAnchor || countTokens(normalized) >= 6;
}
function extractNormalizedPeriodLiteral(text) {
const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/);
if (monthly) {
@@ -1304,6 +1325,35 @@ function buildAddressCoverageReport() {
out_of_scope_requirements: []
};
}
function buildAssistantBackendErrorDebugPayload(errorMessage) {
return {
trace_id: `chat-${(0, nanoid_1.nanoid)(10)}`,
prompt_version: "assistant_backend_error_fallback_v1",
schema_version: "assistant_backend_error_fallback_v1",
fallback_type: "unknown",
route_summary: null,
fragments: [],
requirements_extracted: [],
coverage_report: buildAddressCoverageReport(),
routes: [],
retrieval_status: [],
retrieval_results: [],
answer_grounding_check: {
status: "no_grounded_answer",
route_subject_match: true,
missing_requirements: [],
reasons: [
`backend_error:${String(errorMessage ?? "unknown_error").slice(0, 280)}`
],
why_included_summary: [],
selection_reason_summary: []
},
dropped_intent_segments: []
};
}
function buildAssistantBackendErrorReply() {
return "Сейчас не удалось завершить разбор из-за внутренней ошибки контуров LLM. Могу продолжить в адресном режиме: проверить документы, договоры и операции по нужному периоду или контрагенту.";
}
function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded";
const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null;
@@ -2170,6 +2220,9 @@ function hasAddressFollowupContextSignal(userMessage) {
if (!text) {
return false;
}
if (hasStandaloneAddressTopicSignal(text)) {
return false;
}
if (shouldHandleAsAssistantCapabilityMetaQuery(text)) {
return false;
}
@@ -2228,6 +2281,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage)
: false;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) {
return null;
}
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) {
return null;
}
@@ -2859,6 +2917,29 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
Boolean(sourceAnchorQuality.anchorValue) &&
((candidateAnchorQuality.anchorType === "counterparty" &&
candidateAnchorQuality.quality >= 2 &&
Boolean(candidateAnchorQuality.anchorValue) &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "")) ||
(candidateAnchorQuality.quality < sourceAnchorQuality.quality &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidate)));
if (counterpartyAnchorSubstitutedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_substitution",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType &&
sourceAnchorQuality.quality >= 2 &&
@@ -2876,27 +2957,6 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
sanitizedUserMessage
}, userMessage);
}
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
candidateAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
candidateAnchorQuality.quality >= 2 &&
Boolean(sourceAnchorQuality.anchorValue) &&
Boolean(candidateAnchorQuality.anchorValue) &&
hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "");
if (counterpartyAnchorSubstitutedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_substitution",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
if (fallbackCandidate) {
const fallbackAnchorQuality = evaluateAddressAnchorQuality(String(fallbackCandidate.candidate ?? ""));
const fallbackPreferredForAnchorSafety = sameIntentForAnchorSafety &&
@@ -3079,6 +3139,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
llmContractIntent === "unknown";
const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
hasDataRetrievalRequestSignal(rawMessageForGate) ||
hasAccountingSignal(rawMessageForGate) ||
hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate);
const strongDataSignalFromEffectiveMessage = hasStrongDataIntentSignal(repairedInputMessage) ||
@@ -3105,7 +3166,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
reason: "llm_predecompose_unsupported_mode"
};
}
const hasMessageSignal = hasAnyAddressSignal;
const hasMessageSignal = hasAnyAddressSignal || strongDataSignalFromRawMessage || strongDataSignalFromEffectiveMessage;
if (hasMessageSignal) {
return {
runAddressLane: true,
@@ -3190,7 +3251,9 @@ function hasDeepAnalysisPreferenceSignal(text) {
}
const riskOrAnomalySignal = /(?:\u0440\u0438\u0441\u043a|risk|\u0430\u043d\u043e\u043c\u0430\u043b|anomal|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442|conflict|deviation|\u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d|\u043d\u0435\u0441\u044b\u043a\u043e\u0432\u043a|\u043d\u0435\u0441\u0445\u043e\u0434|\u043e\u0448\u0438\u0431|error|issue|\u043f\u0440\u043e\u0431\u043b\u0435\u043c)/iu.test(lower);
const chainSignal = /(?:\u0446\u0435\u043f\u043e\u0447\u043a|chain|trace\s*chain|lifecycle|\u0436\u0438\u0437\u043d\u0435\u043d\u043d[\u0430-\u044f]+\s+\u0446\u0438\u043a\u043b|state\s+transition|\u0440\u0430\u0437\u0440\u044b\u0432[\u0430-\u044f]*)/iu.test(lower);
const diagnosticsSignal = /(?:\u0440\u0430\u0437\u043b\u043e\u0436\u0438|\u0434\u0435\u043a\u043e\u043c\u043f\u043e\u0437|\u0440\u0430\u0437\u0431\u0435\u0440\u0438|\u043f\u043e\u0447\u0435\u043c\u0443|why|\u043a\u043e\u0440\u043d\u0435\u0432[\u0430-\u044f]+\s+\u043f\u0440\u0438\u0447\u0438\u043d|root\s*cause|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c[\u0430-\u044f]*|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower);
const diagnosticsKeywordSignal = /(?:\u0440\u0430\u0437\u043b\u043e\u0436\u0438|\u0434\u0435\u043a\u043e\u043c\u043f\u043e\u0437|\u0440\u0430\u0437\u0431\u0435\u0440\u0438|\u043f\u043e\u0447\u0435\u043c\u0443|why|audit|scan|\u043a\u043e\u0440\u043d\u0435\u0432[\u0430-\u044f]+\s+\u043f\u0440\u0438\u0447\u0438\u043d|root\s*cause|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c[\u0430-\u044f]*|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower);
const diagnosticsCheckVerbSignal = /(?:^|[\s,.;:!?()\-])\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c|\u043a\u0443|\u0438\u043c|\u043a\u0430)(?:$|[\s,.;:!?()\-])/iu.test(lower);
const diagnosticsSignal = diagnosticsKeywordSignal || diagnosticsCheckVerbSignal;
const closureSignal = /(?:\u0437\u0430\u043a\u0440\u044b\u0442\u0438[\u0435\u044f]\s+\u043f\u0435\u0440\u0438\u043e\u0434|period\s*close|\u043d\u0435\s+\u0437\u0430\u043a\u0440\u044b\u043b[\u0430-\u044f]*|\u0445\u0432\u043e\u0441\u0442[\u0430-\u044f]*)/iu.test(lower);
const closureIntentSignal = /(?:\u0437\u0430\u043a\u0440\u044b\u0442[\u0430-\u044f]*|period\s*close|close\s+period)/iu.test(lower);
const closureDiagnosticPhraseSignal = /(?:\u0447\u0442\u043e(?:\s+\S+){0,8}\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower);
@@ -3198,13 +3261,12 @@ function hasDeepAnalysisPreferenceSignal(text) {
const lifecycleMismatchSignal = /(?:\u043d\u0435\s+\u0442\u0435\u043c\s+\u0442\u0438\u043f(?:\u043e\u043c)?\s+\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|wrong\s+closing\s+document|expected\s+transition)/iu.test(lower);
const lifecycleTransitionGapSignal = /(?:\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432|\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432|\u0441\u0442\u0430\u0434\u0438[\u0438\u044f\u0435]\s+.*\u043f\u0440\u043e\u0439\u0434\u0435\u043d.*\u043f\u0435\u0440\u0435\u0445\u043e\u0434)/iu.test(lower);
const expectedActualMismatchSignal = /(?:\u0444\u0430\u043a\u0442\u0438\u0447\u0435\u0441\u043a[\u0430-\u044f]+\s+\u0441\u043e\u0441\u0442\u043e\u044f\u043d[\u0438\u0435\u044f]+\s+.*\u0440\u0430\u0441\u0445\u043e\u0434[\u0430-\u044f]*\s+\u0441\s+\u043e\u0436\u0438\u0434\u0430\u0435\u043c|\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d[\u0430-\u044f]*\s+\u0441\u043f\u0438\u0441\u0430\u043d)/iu.test(lower);
return riskOrAnomalySignal ||
lifecycleMismatchSignal ||
return lifecycleMismatchSignal ||
(chainSignal && lifecycleTransitionGapSignal) ||
expectedActualMismatchSignal ||
(chainSignal && diagnosticsSignal) ||
(riskOrAnomalySignal && (chainSignal || closureSignal || diagnosticsSignal || closureIntentSignal)) ||
(diagnosticsSignal && closureIntentSignal) ||
(riskOrAnomalySignal && (chainSignal || diagnosticsSignal || lifecycleTransitionGapSignal)) ||
(diagnosticsSignal && (closureSignal || closureIntentSignal)) ||
closureDiagnosticPhraseSignal ||
signalVsNoiseDiagnostic;
}
@@ -3213,7 +3275,7 @@ function hasDirectDeepAnalysisSignal(text) {
if (!normalized) {
return false;
}
return /(?:\u0440\u0430\u0437\u043b\u043e\u0436|\u0446\u0435\u043f\u043e\u0447|lifecycle|\u0440\u0430\u0437\u0440\u044b\u0432|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0437\u0430\u043a\u0440\u044b\u0442[\u0430-\u044f]*|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized);
return /(?:\u0440\u0430\u0437\u043b\u043e\u0436|\u0446\u0435\u043f\u043e\u0447|lifecycle|\u0440\u0430\u0437\u0440\u044b\u0432|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0437\u0430\u043a\u0440\u044b\u0442\u0438[\u0435\u044f]\s+\u043f\u0435\u0440\u0438\u043e\u0434|period\s*close|close\s+period|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized);
}
function hasStrictDeepInvestigationCue(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
@@ -3239,6 +3301,23 @@ function hasAggregateBusinessAnalyticsSignal(text) {
const hasPeriodAggregateCue = /(?:\u043f\u043e\s+\u0433\u043e\u0434\u0430\u043c|\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u0433\u043e\u0434(?:\u0430|\u0443|\u044b)?|year|years|\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043c\u0435\u0441\u044f\u0446|\u043f\u0435\u0440\u0438\u043e\u0434)/iu.test(normalized);
return hasRankingOrTrendCue || hasPeriodAggregateCue;
}
function hasOpenContractsAddressSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
const hasContractCue = /(?:договор|контракт|contract)/iu.test(normalized);
if (!hasContractCue) {
return false;
}
const hasOpenCue = /(?:незакрыт|не\s+закрыт|открыт|open\s+contract|open\s+item|open)/iu.test(normalized);
if (!hasOpenCue) {
return false;
}
const hasRequestCue = /(?:покажи|показать|список|какие|какой|show|list|find|на\s+дату|as\s+of)/iu.test(normalized);
const hasTemporalCue = hasPeriodLiteral(normalized) || /\b\d{4}[-/.]\d{2}[-/.]\d{2}\b/.test(normalized);
return hasRequestCue || hasTemporalCue;
}
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_open_contracts",
"open_items_by_counterparty_or_contract",
@@ -3276,10 +3355,21 @@ export function resolveAssistantOrchestrationDecision(input) {
hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) ||
hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) ||
hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage);
const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) ||
hasStandaloneAddressTopicSignal(repairedRawUserMessage) ||
hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) ||
hasStandaloneAddressTopicSignal(repairedEffectiveAddressUserMessage);
const openContractsAddressSignal = hasOpenContractsAddressSignal(rawUserMessage) ||
hasOpenContractsAddressSignal(repairedRawUserMessage) ||
hasOpenContractsAddressSignal(effectiveAddressUserMessage) ||
hasOpenContractsAddressSignal(repairedEffectiveAddressUserMessage);
const modeSample = repairedEffectiveAddressUserMessage || effectiveAddressUserMessage;
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(modeSample);
const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(modeSample);
const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmPreDecomposeReason = toNonEmptyString(llmPreDecomposeMeta?.reason);
const llmRuntimeUnavailableDetected = Boolean(llmPreDecomposeReason &&
/(?:openai\s+api\s+key\s+is\s+missing|api\s+key\s+is\s+missing|missing\s+api\s+key|authentication)/iu.test(llmPreDecomposeReason));
const semanticExtractionContract = llmPreDecomposeMeta?.semanticExtractionContract &&
typeof llmPreDecomposeMeta.semanticExtractionContract === "object"
? llmPreDecomposeMeta.semanticExtractionContract
@@ -3295,7 +3385,8 @@ export function resolveAssistantOrchestrationDecision(input) {
hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage);
const keepAddressLaneByIntent = semanticApplyCanonicalRecommended &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent))) &&
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal) &&
!strictDeepInvestigationCueDetected;
const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
hasStrongDataIntentSignal(repairedRawUserMessage) ||
@@ -3387,7 +3478,8 @@ export function resolveAssistantOrchestrationDecision(input) {
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage));
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)));
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
openContractsAddressSignal);
const semanticGuardHints = semanticExtractionContract?.guard_hints &&
typeof semanticExtractionContract.guard_hints === "object"
? semanticExtractionContract.guard_hints
@@ -3408,8 +3500,14 @@ export function resolveAssistantOrchestrationDecision(input) {
const unsupportedIntentOrMode = (modeDetection.mode !== "address_query" && intentResolution.intent === "unknown") ||
llmContractMode === "unsupported";
const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
unsupportedIntentOrMode &&
strongDataSignal &&
(llmContractMode === "deep_analysis" ||
!dataRetrievalSignal ||
strictDeepInvestigationCueDetected ||
semanticDeepInvestigationHintDetected ||
aggregateBusinessAnalyticsSignal) &&
!preserveAddressLaneSignal &&
!keepAddressLaneByIntent &&
!supportedAddressIntentDetected &&
@@ -3426,21 +3524,25 @@ export function resolveAssistantOrchestrationDecision(input) {
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
/(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`)));
const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
!keepAddressLaneByIntent &&
!supportedAddressIntentDetected &&
!vatExplainFollowupSignal &&
(!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed));
const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
aggregateBusinessAnalyticsSignal &&
!keepAddressLaneByIntent &&
!supportedAddressIntentDetected &&
(!followupContext ||
llmContractMode === "unsupported" ||
semanticAggregateShapeDetected ||
!semanticApplyCanonicalRecommended));
!semanticApplyCanonicalRecommended ||
standaloneAddressTopicSignal));
const deepSessionContinuationFallbackToDeep = Boolean(!followupContext &&
baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected &&
hasDeepSessionContinuationSignal({
rawUserMessage,
repairedRawUserMessage,
@@ -3547,24 +3649,29 @@ export function resolveAssistantOrchestrationDecision(input) {
}
function hasStrongDataIntentSignal(text) {
const lower = String(text ?? "").toLowerCase();
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account|организац|компан|контор|фирм)/i.test(lower);
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм)/i.test(lower);
}
function hasDataRetrievalRequestSignal(text) {
const lower = compactWhitespace(String(text ?? "").toLowerCase());
if (!lower) {
return false;
}
const hasBroadInterrogative = /(?:\u0433\u0434\u0435|\u0432\s+\u043a\u0430\u043a\u0438\u0445|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c|\u043f\u043e\s+\u043a\u043e\u043c\u0443|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0442\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|where|which|who|how\s+many)/iu.test(lower);
const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover)/iu.test(lower);
if (hasBroadInterrogative && hasBroadBusinessObject) {
return true;
}
const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a)(?:$|[\s,.!?;:])/iu.test(lower);
const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446)/iu.test(lower);
if (hasRussianRetrievalAction && hasRussianRetrievalObject) {
return true;
}
const hasExplicitRetrievalAction = /(?:\bпокажи\b|\bпоказать\b|\bвыведи\b|\bнайди\b|\bсписок\b|\bдай\b|\bраскрой\b|\bshow\b|\blist\b|\bfind\b|\bcount\b)/i.test(lower);
const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bwhich\b|\bwho\b)/i.test(lower);
const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bгде\b|\bпо\s+каким\b|\bпо\s+кому\b|\bу\s+кого\b|\bwhich\b|\bwho\b|\bwhere\b)/i.test(lower);
if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
return false;
}
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
if (!hasRetrievalObject) {
return false;
}
@@ -3725,6 +3832,10 @@ function hasAssistantDataScopeMetaQuestionSignal(text) {
if (!normalized) {
return false;
}
const hasDirectSlangScopeLead = /(?:по\s+каким\s+(?:контор(?:ам|ы|а)?|кантор(?:ам|ы|а)?|компан(?:иям|ии|ию|ия)|организац(?:иям|ии|ию|ия))\s+мож(?:ем|но)\s+(?:общат|работ)|база\s+какой\s+(?:контор|компан|организац|фирм)|какая\s+база\s+(?:подключ|подруб|актив))/iu.test(normalized);
if (hasDirectSlangScopeLead) {
return true;
}
const hasSlangScopeQuestion = /(?:\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440(?:\u0430\u043c|\u044b|\u0430)?|\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u0444\u0438\u0440\u043c(?:\u0430\u043c|\u0435|\u0443|\u0430)).*(?:\u043c\u043e\u0436(?:\u0435\u043c|\u043d\u043e)|\u0440\u0430\u0431\u043e\u0442|\u043e\u0431\u0449\u0430\u0442|\u043f\u043e\u0434\u0440\u0443\u0431|\u043f\u043e\u0434\u043a\u043b\u044e\u0447)|(?:\u0431\u0430\u0437\u0430\s+\u043a\u0430\u043a\u043e\u0439\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0444\u0438\u0440\u043c))|(?:\u043a\u0430\u043a\u0430\u044f\s+\u0431\u0430\u0437\u0430\s+(?:\u043f\u043e\u0434\u043a\u043b\u044e\u0447|\u0430\u043a\u0442\u0438\u0432)))/iu.test(normalized);
if (hasSlangScopeQuestion) {
return true;
@@ -4771,13 +4882,55 @@ export class AssistantService {
extractExecutionState
}
});
const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({
payload,
runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)),
resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems),
runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)),
runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps))
});
return turnRuntime.response;
try {
const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({
payload,
runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)),
resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems),
runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)),
runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps))
});
return turnRuntime.response;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const sessionId = String(payload?.session_id ?? payload?.sessionId ?? "").trim() || `asst-${(0, nanoid_1.nanoid)(10)}`;
const ensuredSession = this.sessions.ensureSession(sessionId);
const existingAssistant = [...ensuredSession.items].reverse().find((item) => item.role === "assistant") ?? null;
if (existingAssistant) {
return {
ok: true,
session_id: sessionId,
assistant_reply: existingAssistant.text,
reply_type: existingAssistant.reply_type ?? "backend_error",
conversation_item: existingAssistant,
debug: existingAssistant.debug ?? buildAssistantBackendErrorDebugPayload(errorMessage),
conversation: cloneItems(ensuredSession.items)
};
}
const createdAt = new Date().toISOString();
const debugPayload = buildAssistantBackendErrorDebugPayload(errorMessage);
const assistantItem = this.sessions.appendItem(sessionId, {
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
session_id: sessionId,
role: "assistant",
text: buildAssistantBackendErrorReply(),
reply_type: "backend_error",
created_at: createdAt,
trace_id: debugPayload.trace_id ?? null,
debug: debugPayload
});
const sessionSnapshot = this.sessions.getSession(sessionId) ?? this.sessions.ensureSession(sessionId);
this.sessionLogger.persistSession(sessionSnapshot);
return {
ok: true,
session_id: sessionId,
assistant_reply: assistantItem.text,
reply_type: "backend_error",
conversation_item: assistantItem,
debug: debugPayload,
conversation: cloneItems(sessionSnapshot.items)
};
}
}
}