АДРЕСНЫЙ РЕЖИМ -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
@@ -11,6 +11,8 @@ export interface ComposeStageRow {
interface ComposeFactualReplyOptions {
userMessage?: string;
periodFrom?: string;
periodTo?: string;
}
type PeriodProfileFocus =
@@ -130,6 +132,93 @@ function formatPercent(value: number, total: number): string | null {
return `${((value / total) * 100).toFixed(1)}%`;
}
function formatMoney(value: number): string {
if (!Number.isFinite(value)) {
return "0.00";
}
return value.toFixed(2);
}
function parseIsoDateToken(value: string | null | undefined): { year: number; month: number; day: number } | null {
const source = String(value ?? "").trim();
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
if (month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
return { year, month, day };
}
function toIsoDate(year: number, month: number, day: number): string {
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function formatDateRu(isoDate: string): string {
const parsed = parseIsoDateToken(isoDate);
if (!parsed) {
return isoDate;
}
return `${String(parsed.day).padStart(2, "0")}.${String(parsed.month).padStart(2, "0")}.${String(parsed.year).padStart(4, "0")}`;
}
function buildIsoDateWithMonthShift(
year: number,
monthOneBased: number,
day: number,
monthShift = 0
): string {
const date = new Date(Date.UTC(year, monthOneBased - 1 + monthShift, day));
return date.toISOString().slice(0, 10);
}
function deriveVatDeadlineCalendar(
periodFrom: string | null | undefined,
periodTo: string | null | undefined
): {
periodLabel: string;
quarterStart: string;
quarterEnd: string;
declarationDueDate: string;
paymentDueDates: [string, string, string];
windowFrom: string | null;
windowTo: string | null;
} | null {
const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom);
if (!reference) {
return null;
}
const quarterIndex = Math.floor((reference.month - 1) / 3);
const quarterNumber = quarterIndex + 1;
const quarterStartMonth = quarterIndex * 3 + 1;
const quarterEndMonth = quarterStartMonth + 2;
const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
return {
periodLabel: `${quarterNumber} кв. ${reference.year}`,
quarterStart,
quarterEnd,
declarationDueDate,
paymentDueDates: [payment1, payment2, payment3],
windowFrom: periodFrom ?? null,
windowTo: periodTo ?? null
};
}
function extractAccountSectionCode(value: string | null): string | null {
const source = String(value ?? "").trim();
if (!source) {
@@ -150,6 +239,18 @@ function normalizeQuestionText(value: string | null | undefined): string {
.trim();
}
function needsVatWhyExplanation(userMessage: string | null | undefined): boolean {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
const asksReason = /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text);
if (!asksReason) {
return false;
}
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
}
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -249,13 +350,13 @@ function detectCounterpartyProfileFocus(userMessage: string | null | undefined):
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";
}
@@ -781,8 +882,19 @@ export function composeFactualReply(
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: string[] = [
directLead,
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
@@ -871,19 +983,19 @@ export function composeFactualReply(
: "в выбранном периоде";
const lines: string[] = [
`Активные заказчики ${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) => {
@@ -923,7 +1035,13 @@ export function composeFactualReply(
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: string[] = [
usageLead,
"Профиль договорной базы собран (catalog + usage aggregate).",
`Строк агрегата: ${rows.length}.`
];
@@ -1046,11 +1164,10 @@ export function composeFactualReply(
if (focus === "top_by_ops") {
const visible = rankedByOps.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`
);
const heading = isSupplier
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
lines.unshift(heading);
lines.push(
...visible.map(
(item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`
@@ -1064,11 +1181,10 @@ export function composeFactualReply(
if (focus === "top_by_max_single") {
const visible = rankedByMaxSingle.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`
);
const heading = isSupplier
? `Топ-${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}`)
);
@@ -1080,11 +1196,10 @@ export function composeFactualReply(
if (focus === "top_by_avg_check_min_ops") {
const visible = rankedByAvgCheck.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`
);
const heading = isSupplier
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
lines.unshift(heading);
if (visible.length === 0) {
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
} else {
@@ -1103,11 +1218,10 @@ export function composeFactualReply(
if (focus === "top_deals") {
const visible = rankedDealsTop.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`
);
const heading = isSupplier
? `Топ-${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}`
@@ -1121,11 +1235,10 @@ export function composeFactualReply(
if (focus === "bottom_deals") {
const visible = rankedDealsBottom.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} самых маленьких разовых выплат:`
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`
);
const heading = isSupplier
? `Топ-${visible.length} самых маленьких разовых выплат:`
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
lines.unshift(heading);
if (activeOnlyForBottomDeals) {
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
}
@@ -1141,11 +1254,10 @@ export function composeFactualReply(
}
const visible = rankedByTotal.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} поставщиков по сумме выплат:`
: `Топ-${visible.length} заказчиков по сумме поступлений:`
);
const heading = isSupplier
? `Топ-${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";
@@ -1212,9 +1324,10 @@ export function composeFactualReply(
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
const lines: string[] = [
`Активных договоров: ${contractRows.length}.`,
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
`Строк источника: ${rows.length}.`,
`Активных договоров: ${contractRows.length}.`
`Договорных агрегатов: ${contractRows.length}.`
];
if (contractRows.length === 0) {
@@ -1227,7 +1340,8 @@ export function composeFactualReply(
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) =>
@@ -1242,7 +1356,8 @@ export function composeFactualReply(
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) =>
@@ -1256,7 +1371,8 @@ export function composeFactualReply(
}
const visible = rankedByTurnover.slice(0, limit);
lines.push(`Топ-${visible.length} договоров по сумме оборота:`);
const heading = `Топ-${visible.length} договоров по сумме оборота:`;
lines.unshift(heading);
lines.push(
...visible.map(
(item, index) =>
@@ -1269,6 +1385,98 @@ export function composeFactualReply(
};
}
if (intent === "vat_payable_forecast") {
const rowsByMarker = new Map<string, number>();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
const nextValue = (rowsByMarker.get(marker) ?? 0) + (row.amount ?? 0);
rowsByMarker.set(marker, nextValue);
}
const turnover68Credit = rowsByMarker.get("VAT_68_CREDIT") ?? 0;
const turnover68Debit = rowsByMarker.get("VAT_68_DEBIT") ?? 0;
const turnover19Debit = rowsByMarker.get("VAT_19_DEBIT") ?? 0;
const turnover19Credit = rowsByMarker.get("VAT_19_CREDIT") ?? 0;
const netVat = turnover68Credit - turnover68Debit;
const vatToPay = Math.max(0, netVat);
const carryoverOrOverpayment = Math.max(0, -netVat);
const totalVatTurnoverAbs =
Math.abs(turnover68Credit) + Math.abs(turnover68Debit) + Math.abs(turnover19Debit) + Math.abs(turnover19Credit);
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
const lines = [
"Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
`Строк агрегата: ${rows.length}.`,
`Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
`Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
`Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
`Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
`Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
`Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
];
if (!vatActivityDetected) {
lines.push(
"В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00."
);
} else if (vatToPay === 0 && netVatIsEffectivelyZero) {
lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
} else if (vatToPay === 0 && netVat < 0) {
lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
}
if (vatToPay === 0) {
lines.push(
"Чеклист проверки в 1С (почему к уплате 0):",
`1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`,
"2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).",
"3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).",
"4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.",
"5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов."
);
}
if (vatCalendar) {
const periodWindowLabel =
vatCalendar.windowFrom && vatCalendar.windowTo
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
: `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`;
const [payment1, payment2, payment3] = vatCalendar.paymentDueDates;
const installmentRaw = vatToPay / 3;
const installmentRounded = Number(installmentRaw.toFixed(2));
const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
lines.push(
`Период расчета (срез обязательств): ${periodWindowLabel}.`,
`Налоговый период: ${vatCalendar.periodLabel}.`,
`Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`,
`Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`,
`Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`,
"Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."
);
}
if (explainWhyRequested) {
lines.push(
"Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).",
`За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`,
netVat <= 0
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
: "Разница положительная, поэтому к уплате берется эта положительная величина.",
"Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация."
);
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
@@ -1372,8 +1580,7 @@ export function composeFactualReply(
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
`Найдено документов по контрагенту: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
@@ -1384,6 +1591,7 @@ export function composeFactualReply(
if (intent === "list_documents_by_contract") {
const lines = [
`Найдено документов по договору: ${rows.length}.`,
"Собран список документов по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -1396,6 +1604,7 @@ export function composeFactualReply(
if (intent === "bank_operations_by_counterparty") {
const lines = [
`Найдено банковских операций по контрагенту: ${rows.length}.`,
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -1408,6 +1617,7 @@ export function composeFactualReply(
if (intent === "bank_operations_by_contract") {
const lines = [
`Найдено банковских операций по договору: ${rows.length}.`,
"Собран список банковских операций по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@@ -144,6 +144,29 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"что",
"все",
"всё",
"кроме",
"помимо",
"этого",
"этот",
"эта",
"эту",
"этом",
"это",
"эти",
"этих",
"документ",
"документа",
"документы",
"документов",
"договор",
"договора",
"контрагент",
"контрагента",
"еще",
"ещё",
"другие",
"другое",
"остальное",
"год",
"года",
"году",
@@ -284,6 +307,13 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
}
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
if (
tokenCount <= 12 &&
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(normalized) &&
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(normalized)
) {
return true;
}
const hasPeriodLiteral = /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(normalized);
if (tokenCount <= 8 && hasPeriodLiteral) {
return true;
@@ -411,9 +441,28 @@ function mergeFollowupFilters(
}
}
const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage);
const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage);
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
if (intent === "vat_payable_forecast" && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) {
const currentPeriodFrom = toNonEmptyString(merged.period_from);
const currentPeriodTo = toNonEmptyString(merged.period_to);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = !currentPeriodFrom && currentPeriodTo === todayIso;
if (!currentPeriodFrom || currentLooksDefaultedToToday) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
if (previousPeriodTo) {
merged.period_to = previousPeriodTo;
}
reasons.push("period_from_followup_context");
}
}
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
@@ -562,7 +611,11 @@ export function runAddressDecomposeStage(
): AddressDecomposeStageResult | null {
const detectedMode = detectAddressQuestionMode(userMessage);
const shape = classifyAddressQueryShape(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
const allowExplainAsFollowup =
shape.shape === "EXPLAIN_OR_REASON" &&
Boolean(followupContext?.previous_intent) &&
hasAddressFollowupContextSignal(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON" && !allowExplainAsFollowup) {
return null;
}
const detectedIntent = resolveAddressIntent(userMessage);
@@ -85,7 +85,8 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
intent === "contract_usage_overview" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value"
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast"
) {
return "management_profile";
}