АДРЕСНЫЙ РЕЖИМ -Step-5 - feat(assistant): стабилизация свободного LLM-роутинга, прическа маршрутов chat/address, прототип прогноза НДС

This commit is contained in:
2026-04-08 23:40:12 +03:00
parent 71762af575
commit df29798fa2
47 changed files with 11439 additions and 651 deletions
@@ -169,13 +169,13 @@ function detectCounterpartyProfileFocus(userMessage) {
const hasCustomerToken = /(?:заказчик(?:ов|а)?|клиент(?:ов|а)?|customer(?:s)?|client(?:s)?)/iu.test(text);
const hasMixedToken = /(?:смешан|проч(?:их|ие)|mixed)/iu.test(text);
const asksRoles = /(?:заказчик(?:ов|а)?|поставщик(?:ов|а)?|смешан|проч(?:их|ие)|типы?\s+контрагент|разбей|раздели|roles?|split)/iu.test(text);
if (hasSupplierToken && !hasCustomerToken && !hasMixedToken && !asksTotal) {
if (hasSupplierToken && !hasCustomerToken && !hasMixedToken) {
return "suppliers_only";
}
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken && !asksTotal) {
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken) {
return "customers_only";
}
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken && !asksTotal) {
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken) {
return "mixed_only";
}
if (asksTotal && !asksRoles) {
@@ -614,7 +614,17 @@ function composeFactualReply(intent, rows, options = {}) {
const focus = detectCounterpartyProfileFocus(options.userMessage);
const includeTotal = focus === "full_profile" || focus === "total_only";
const includeRoles = focus === "full_profile" || focus === "roles_only";
const directLead = focus === "suppliers_only"
? `Поставщиков (только supplier-роль): ${supplierOnly}.`
: focus === "customers_only"
? `Заказчиков (только customer-роль): ${customerOnly}.`
: focus === "mixed_only"
? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`
: includeTotal && totalCounterparties > 0
? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
: `Активных контрагентов по операциям: ${activeCounterparties}.`;
const lines = [
directLead,
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
@@ -693,17 +703,17 @@ function composeFactualReply(intent, rows, options = {}) {
? `в ${requestedYear} году`
: "в выбранном периоде";
const lines = [
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (counterparties.length === 0) {
lines.push("Активных заказчиков по выбранному окну не найдено.");
lines.push("По выбранному окну активности заказчики не найдены.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
lines.push(`Активные заказчики ${scopeLabel}: ${counterparties.length}.`);
const visible = counterparties.slice(0, 120);
lines.push(...visible.map((item, index) => {
const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : "";
@@ -734,7 +744,11 @@ function composeFactualReply(intent, rows, options = {}) {
const usedContracts = sumMarker("CT_USED");
const unusedContracts = totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
const usageLead = totalContracts > 0
? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
: `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`;
const lines = [
usageLead,
"Профиль договорной базы собран (catalog + usage aggregate).",
`Строк агрегата: ${rows.length}.`
];
@@ -830,9 +844,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_ops") {
const visible = rankedByOps.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`);
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`));
return {
responseType: "FACTUAL_LIST",
@@ -841,9 +856,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_max_single") {
const visible = rankedByMaxSingle.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`);
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`));
return {
responseType: "FACTUAL_LIST",
@@ -852,9 +868,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_avg_check_min_ops") {
const visible = rankedByAvgCheck.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`);
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
lines.unshift(heading);
if (visible.length === 0) {
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
}
@@ -868,9 +885,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_deals") {
const visible = rankedDealsTop.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`);
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`));
return {
responseType: "FACTUAL_LIST",
@@ -879,9 +897,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "bottom_deals") {
const visible = rankedDealsBottom.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} самых маленьких разовых выплат:`
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`);
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
lines.unshift(heading);
if (activeOnlyForBottomDeals) {
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
}
@@ -892,9 +911,10 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
const visible = rankedByTotal.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} поставщиков по сумме выплат:`
: `Топ-${visible.length} заказчиков по сумме поступлений:`);
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0";
return `${index + 1}. ${item.name} | сумма: ${item.total} | операций: ${item.ops} | средний чек: ${avgCheck} | макс: ${item.maxSingle}`;
@@ -943,9 +963,10 @@ function composeFactualReply(intent, rows, options = {}) {
.filter((item) => item.docs > 0 && item.turnover > 0)
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
const lines = [
`Активных договоров: ${contractRows.length}.`,
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
`Строк источника: ${rows.length}.`,
`Активных договоров: ${contractRows.length}.`
`Договорных агрегатов: ${contractRows.length}.`
];
if (contractRows.length === 0) {
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
@@ -956,7 +977,8 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_docs") {
const visible = rankedByDocs.slice(0, limit);
lines.push(`Топ-${visible.length} договоров по количеству операций:`);
const heading = `Топ-${visible.length} договоров по количеству операций:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${item.turnover} | контрагентов: ${item.counterparties.size}`));
return {
responseType: "FACTUAL_LIST",
@@ -965,7 +987,8 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "bottom_by_turnover_active") {
const visible = rankedBottomActive.slice(0, limit);
lines.push(`Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`);
const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
return {
responseType: "FACTUAL_LIST",
@@ -973,7 +996,8 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
const visible = rankedByTurnover.slice(0, limit);
lines.push(`Топ-${visible.length} договоров по сумме оборота:`);
const heading = `Топ-${visible.length} договоров по сумме оборота:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
return {
responseType: "FACTUAL_LIST",
@@ -1073,8 +1097,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
`Найдено документов по контрагенту: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
@@ -1084,6 +1107,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "list_documents_by_contract") {
const lines = [
`Найдено документов по договору: ${rows.length}.`,
"Собран список документов по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -1095,6 +1119,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
`Найдено банковских операций по контрагенту: ${rows.length}.`,
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -1106,6 +1131,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "bank_operations_by_contract") {
const lines = [
`Найдено банковских операций по договору: ${rows.length}.`,
"Собран список банковских операций по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -24,6 +24,9 @@ function hasAllTimeHint(text) {
function hasSameDateHint(text) {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? ""));
}
function hasExplicitPeriodLiteral(text) {
return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? ""));
}
function hasOpenItemsHint(text) {
return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? ""));
}
@@ -93,6 +96,29 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"что",
"все",
"всё",
"кроме",
"помимо",
"этого",
"этот",
"эта",
"эту",
"этом",
"это",
"эти",
"этих",
"документ",
"документа",
"документы",
"документов",
"договор",
"договора",
"контрагент",
"контрагента",
"еще",
"ещё",
"другие",
"другое",
"остальное",
"год",
"года",
"году",
@@ -313,6 +339,17 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
return { filters: merged, reasons };
}
if (intent === "counterparty_activity_lifecycle" &&
hasAddressFollowupContextSignal(userMessage) &&
!hasExplicitPeriodLiteral(userMessage)) {
const currentPeriodFrom = toNonEmptyString(merged.period_from);
const currentPeriodTo = toNonEmptyString(merged.period_to);
const todayIso = new Date().toISOString().slice(0, 10);
if (!currentPeriodFrom && currentPeriodTo === todayIso) {
delete merged.period_to;
reasons.push("period_to_cleared_for_lifecycle_followup");
}
}
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
@@ -439,23 +476,29 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
}
function runAddressDecomposeStage(userMessage, followupContext) {
const detectedMode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
const mode = detectedMode.mode === "address_query"
? detectedMode
: followupContext && hasAddressFollowupContextSignal(userMessage)
? {
mode: "address_query",
confidence: "medium",
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
}
: detectedMode;
if (mode.mode !== "address_query") {
return null;
}
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const detectedIntent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
const mode = detectedMode.mode === "address_query"
? detectedMode
: detectedIntent.intent !== "unknown"
? {
mode: "address_query",
confidence: "medium",
reasons: [...detectedMode.reasons, "address_mode_from_resolved_intent"]
}
: followupContext && hasAddressFollowupContextSignal(userMessage)
? {
mode: "address_query",
confidence: "medium",
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
}
: detectedMode;
if (mode.mode !== "address_query") {
return null;
}
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
const extractedFilters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);