АДРЕСНЫЙ РЕЖИМ -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
+18
View File
@@ -19,6 +19,18 @@ function toNumberFlag(value: string | undefined, defaultValue: number): number {
return Number.isFinite(parsed) ? parsed : defaultValue;
}
function toStringListFlag(value: string | undefined, defaultValue: string[]): string[] {
const source = String(value ?? "").trim();
if (!source) {
return [...defaultValue];
}
const tokens = source
.split(/[,\s;]+/g)
.map((item) => item.trim())
.filter((item) => item.length > 0);
return tokens.length > 0 ? Array.from(new Set(tokens)) : [...defaultValue];
}
export const PORT = Number(process.env.PORT ?? 8787);
export const TIMEZONE = process.env.TZ_FALLBACK ?? "Europe/Moscow";
export const DEFAULT_OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
@@ -103,6 +115,10 @@ export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
true
);
export const FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1,
true
);
export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(
/\/+$/,
""
@@ -110,6 +126,8 @@ export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "
export const ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
export const ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
export const ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24)));
export const VAT_PAYABLE_68_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_68_PREFIXES, ["68.02"]);
export const VAT_PAYABLE_19_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_19_PREFIXES, ["19"]);
export const DATA_DIR = process.env.DATA_DIR ?? path.resolve(MODULE_ROOT, "data");
export const TRACES_DIR = path.resolve(DATA_DIR, "traces");
@@ -5,9 +5,12 @@ const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-_:#]*?(\d{1,3})/iu;
const COUNTERPARTY_PATTERN =
/(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const CONTRACT_PATTERN =
/(?:по\s+(?:договору|контракту)|(?:договор|контракт)(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
const DATE_DMY_MONTH_NAME_PATTERN =
/(?:^|[\s,.;:!?()\-])(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2}|\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const PERIOD_RANGE_PATTERN_2 =
/(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
@@ -116,6 +119,45 @@ function extractAsOfDate(text: string): string | undefined {
return toIsoDate(year, month, day) ?? undefined;
}
const dmyByMonthName = text.match(DATE_DMY_MONTH_NAME_PATTERN);
if (dmyByMonthName) {
const day = Number(dmyByMonthName[1]);
const month = resolveMonthByName(String(dmyByMonthName[2] ?? ""));
const yearRaw = Number(dmyByMonthName[3]);
const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
if (month) {
return toIsoDate(year, month, day) ?? undefined;
}
}
return undefined;
}
function extractAsOfDateWithCue(text: string): string | undefined {
const source = String(text ?? "");
if (!source) {
return undefined;
}
const numericCue = source.match(
/(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})(?=$|[\s,.;:!?()\-])/iu
);
if (numericCue) {
return parseDateToken(String(numericCue[1] ?? ""));
}
const monthNameCue = source.match(
/(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu
);
if (monthNameCue) {
const day = Number(monthNameCue[1]);
const month = resolveMonthByName(String(monthNameCue[2] ?? ""));
const year = Number(monthNameCue[3]);
if (month && Number.isFinite(year) && Number.isFinite(day)) {
return toIsoDate(year, month, day) ?? undefined;
}
}
return undefined;
}
@@ -163,6 +205,26 @@ function resolveMonthByName(rawMonthName: string): number | undefined {
return undefined;
}
function deriveQuarterWindowForDate(asOfIso: string): { period_from: string; period_to: string } | null {
const token = String(asOfIso ?? "").trim();
const match = token.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) {
return null;
}
const quarterStartMonth = Math.floor((month - 1) / 3) * 3 + 1;
const quarterEndMonth = quarterStartMonth + 2;
const quarterEndDay = new Date(Date.UTC(year, quarterEndMonth, 0)).getUTCDate();
return {
period_from: `${year}-${String(quarterStartMonth).padStart(2, "0")}-01`,
period_to: `${year}-${String(quarterEndMonth).padStart(2, "0")}-${String(quarterEndDay).padStart(2, "0")}`
};
}
function extractMonthPeriod(text: string): { period_from?: string; period_to?: string } {
const numericMonthYearMatch = text.match(MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN);
if (numericMonthYearMatch) {
@@ -321,8 +383,13 @@ function extractYearRangePeriod(text: string): { period_from?: string; period_to
}
function cleanupAnchorValue(value: string): string {
const normalized = String(value ?? "").trim();
if (!normalized) {
const stripOuterQuotes = (text: string): string =>
String(text ?? "")
.replace(/^['"«»`]+|['"«»`]+$/gu, "")
.trim();
let cleaned = stripOuterQuotes(String(value ?? "").trim());
if (!cleaned) {
return "";
}
@@ -330,49 +397,47 @@ function cleanupAnchorValue(value: string): string {
// "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31".
const asOfTailPattern =
/\s+(?:на\s+(?:дат[ауеы]\s+)?\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|as\s+of\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?)(?:\s+|$)[\s\S]*$/iu;
if (asOfTailPattern.test(normalized)) {
return normalized.replace(asOfTailPattern, "").trim();
}
const asOfTruncatedTailPattern = /\s+на\s+дат[ауеы]\s+\d{1,2}(?:\s+|$)[\s\S]*$/iu;
if (asOfTruncatedTailPattern.test(normalized)) {
return normalized.replace(asOfTruncatedTailPattern, "").trim();
}
const asOfReportDateTailPattern =
/\s+на\s+дат[ауеы]\s+(?:отчетност[ьи]|отч[её]тн(?:ую|ой)?\s+дат[ауеы]|конец(?:\s+период[а-яё]*)?)\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?(?:\s+|$)[\s\S]*$/iu;
const periodEndTailPattern =
/\s+на\s+конец(?:\s+период[а-яё]*)?\s+(?:\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|\d{4}|[a-zа-яё]+\s+\d{4})(?:\s+|$)[\s\S]*$/iu;
if (periodEndTailPattern.test(normalized)) {
return normalized.replace(periodEndTailPattern, "").trim();
}
// Remove trailing period qualifiers that can be swallowed by broad anchor regexes:
// "<counterparty> с 2020-07-01 по 2020-07-31", "<counterparty> from 2020-07-01 to 2020-07-31"
const periodTailPattern =
/\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu;
if (periodTailPattern.test(normalized)) {
return normalized.replace(periodTailPattern, "").trim();
}
const allTimeTailPattern =
/\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPattern.test(normalized)) {
return normalized.replace(allTimeTailPattern, "").trim();
}
const allTimeTailPatternEn =
/\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
for (const tailPattern of [
asOfTailPattern,
asOfTruncatedTailPattern,
asOfReportDateTailPattern,
periodEndTailPattern,
periodTailPattern,
allTimeTailPattern,
allTimeTailPatternEn
]) {
if (tailPattern.test(cleaned)) {
cleaned = stripOuterQuotes(cleaned.replace(tailPattern, "").trim());
}
}
const trailingYearTailPattern =
/\s+(?:year\s+)?(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?:\s+|$)[\s\S]*$/iu;
let cleaned = normalized;
if (trailingYearTailPattern.test(normalized)) {
cleaned = normalized.replace(trailingYearTailPattern, "").trim();
if (trailingYearTailPattern.test(cleaned)) {
cleaned = stripOuterQuotes(cleaned.replace(trailingYearTailPattern, "").trim());
}
return cleaned
cleaned = cleaned
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim();
return stripOuterQuotes(cleaned);
}
function cleanupContractAnchorValue(value: string): string {
@@ -442,6 +507,8 @@ function extractLooseByAnchorValue(text: string): string | undefined {
"партнера",
"договору",
"договора",
"контракту",
"контракта",
"счету",
"счёту",
"дате",
@@ -489,6 +556,7 @@ function extractLooseByAnchorValue(text: string): string | undefined {
"linked",
"нему",
"ней",
"нее",
"ним",
"этому",
"тому",
@@ -552,10 +620,51 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
"каких",
"какому",
"какую",
"кто",
"что",
"чего",
"где",
"когда",
"почему",
"зачем",
"сколько",
"чьи",
"чья",
"чей",
"чью",
"самый",
"самая",
"самое",
"самые",
"крупный",
"крупная",
"крупное",
"крупные",
"жирный",
"жирная",
"жирное",
"жирные",
"больше",
"меньше",
"платит",
"платят",
"прогноз",
"forecast",
"план",
"плана",
"ндс",
"vat",
"налог",
"оплата",
"оплаты",
"платеж",
"платёж",
"платежа",
"платежи",
"денег",
"деньги",
"объем",
"объём",
"док",
"доки",
"документ",
@@ -685,6 +794,14 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
if (tokens.length === 0) {
return true;
}
const questionCue =
/(?:кто|что|какой|какая|какие|какого|сколько|где|когда|почему|зачем|which|who|what|how\s+many)/iu.test(value) ||
/[?]/u.test(String(rawValue ?? ""));
const rankingCue = /(?:больше|меньше|сам(?:ый|ая|ое|ые)|крупн|жирн|максим|миним)/iu.test(value);
const paymentCue = /(?:плат(?:ит|ят|еж|ёж|ежн|ежей|ежа)|денег|деньг|money|payment)/iu.test(value);
if (questionCue && (rankingCue || paymentCue)) {
return true;
}
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0;
}
@@ -723,7 +840,9 @@ function isLowQualityContractAnchorValue(rawValue: string): boolean {
"период",
"периоду",
"договор",
"договору"
"договору",
"контракт",
"контракту"
]);
const meaningfulTokens = tokens.filter((token) => !lowQualityTokens.has(token));
return meaningfulTokens.length === 0;
@@ -941,7 +1060,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
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";
const filters: AddressFilterSet = {
sort: "period_desc"
};
@@ -949,6 +1069,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
filters.limit = 20;
}
const warnings: string[] = [];
const explicitAsOfDate = extractAsOfDate(text);
const explicitAsOfDateWithCue = extractAsOfDateWithCue(text);
const accountMatch = text.match(ACCOUNT_PATTERN);
if (accountMatch) {
@@ -1071,12 +1193,27 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
}
const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate;
if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) {
const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate);
if (quarterWindow) {
filters.period_from = quarterWindow.period_from;
warnings.push("period_from_derived_from_quarter_for_vat_forecast");
filters.period_to = vatAsOfDate;
warnings.push("period_to_derived_from_as_of_date_for_vat_forecast");
if (filters.period_from && filters.period_to && filters.period_from > filters.period_to) {
filters.period_from = quarterWindow.period_from;
warnings.push("period_from_adjusted_for_vat_as_of_window");
}
}
}
if (isManagementProfileIntent && !filters.period_to && !filters.as_of_date) {
filters.period_to = new Date().toISOString().slice(0, 10);
warnings.push("period_to_defaulted_today_for_management_profile");
}
const explicitAsOfDate = extractAsOfDate(text);
if (usesAsOfPrimaryWindow(intent) && explicitAsOfDate) {
filters.as_of_date = explicitAsOfDate;
const periodWasDerivedHeuristically =
@@ -57,7 +57,8 @@ const OPEN_CONTRACTS_HINTS = [
"незакрыт",
"не закрыт",
"открыт",
"договор"
"договор",
"контракт"
];
const OPEN_ITEMS_HINTS = [
@@ -130,7 +131,10 @@ const DOCUMENTS_BY_CONTRACT_HINTS = [
"доки по договору",
"док по договору",
"документы договор",
"договор"
"договор",
"документы по контракту",
"доки по контракту",
"контракт"
];
const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank operations by contract",
@@ -140,7 +144,10 @@ const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank ops by contract",
"банковские операции по договору",
"платежи по договору",
"выписка по договору"
"выписка по договору",
"банковские операции по контракту",
"платежи по контракту",
"выписка по контракту"
];
const BANK_OPERATION_CORE_HINTS = [
@@ -337,14 +344,22 @@ const CONTRACT_USAGE_AND_VALUE_HINTS = [
"договоры по обороту",
"договоры по сумме оборота",
"топ договоров по обороту",
"контракты по обороту",
"контракты по сумме оборота",
"топ контрактов по обороту",
"договоры с минимальным бюджетом",
"договоры с самым маленьким бюджетом",
"контракты с минимальным бюджетом",
"контракты с самым маленьким бюджетом",
"активные договоры по бюджету",
"активные контракты по бюджету",
"контрагенты с несколькими договорами",
"несколько договоров у контрагента",
"мультидоговорные контрагенты",
"какие договоры активны",
"какие контракты активны",
"рабочие договоры",
"рабочие контракты",
"contracts by turnover",
"contracts by budget"
];
@@ -355,6 +370,10 @@ const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
"список договоров по",
"покажи договоры по",
"выведи договоры по",
"контракты по",
"список контрактов по",
"покажи контракты по",
"выведи контракты по",
"contracts by counterparty",
"list contracts by counterparty",
"show contracts by counterparty"
@@ -453,8 +472,25 @@ function hasFuzzyLexeme(text: string, lexemeRoots: string[]): boolean {
}
function hasCompactAccountCodeToken(text: string): boolean {
// Match compact account tokens like 60.01 / 62, while avoiding date fragments.
return /(?<![\d-])\d{2}(?:[.,]\d{1,2})?(?![\d-])/u.test(text);
// Match compact account tokens while reducing false positives on short-year literals like "22 год".
const source = String(text ?? "");
if (!source) {
return false;
}
// Safe compact form: 60.01 / 62.1
if (/(?<![\d-])\d{2}[.,]\d{1,2}(?![\d-])/u.test(source)) {
return true;
}
// Plain two-digit code is accepted only in explicit account context.
if (/(?:сч[её]т|account)\D{0,12}\d{2}(?![\d-])/iu.test(source)) {
return true;
}
if (/(?:^|\s)по\s+\d{2}(?=$|[\s,.;:!?])/iu.test(source)) {
if (!/(?:^|\s)(?:за|в)\s+\d{2}\s*(?:г(?:од|ода)?|year)\b/iu.test(source)) {
return true;
}
}
return false;
}
function hasDocumentsFormingBalanceSignal(text: string): boolean {
@@ -517,6 +553,18 @@ function hasAccountBalanceSignal(text: string): boolean {
return hasAccountLexeme && hasAsOfStyleDate && hasFollowupBalanceVerb;
}
function hasForecastTaxSignal(text: string): boolean {
const hasForecastLexeme =
/(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text);
const hasVatLexeme = /(?:ндс|vat)/iu.test(text);
const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text);
const hasVatPayableEstimatePattern =
/(?:(?:сколько|скока|скок).{0,48}(?:ндс|vat).{0,48}(?:надо|нужно|к\s+уплате|заплатить|уплатить|платеж|платежа|платежей|платежку)|(?:ндс|vat).{0,48}(?:к\s+уплате|надо|нужно|заплатить|уплатить)|(?:сколько|скока|скок).{0,32}(?:надо|нужно).{0,32}(?:заплатить|уплатить).{0,32}(?:ндс|vat))/iu.test(
text
);
return (hasForecastLexeme && hasTaxLexeme) || (hasVatLexeme && hasVatPayableEstimatePattern);
}
function hasPeriodCoverageProfileSignal(text: string): boolean {
if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) {
return true;
@@ -670,21 +718,21 @@ function hasContractUsageOverviewSignal(text: string): boolean {
return true;
}
if (
/(?:сколько\s+(?:всего\s+)?договор(?:ов|а)?(?:\s+заведен[оы])?|договорн(?:ая|ой)\s+баз[аы]).*(?:сколько|used|использ)/iu.test(
/(?:сколько\s+(?:всего\s+)?(?:договор|контракт)(?:ов|а)?(?:\s+заведен[оы])?|(?:договорн(?:ая|ой)|контрактн(?:ая|ой))\s+баз[аы]).*(?:сколько|used|использ)/iu.test(
text
)
) {
return true;
}
if (
/(?:сколько\s+из\s+договор(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)
/(?:сколько\s+из\s+(?:договор|контракт)(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)
) {
return true;
}
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|contract)?/iu.test(text)) {
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|контракт|contract)?/iu.test(text)) {
return true;
}
if (/(?:какие\s+договор(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
if (/(?:какие\s+(?:договор|контракт)(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
return true;
}
return false;
@@ -699,14 +747,18 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
}
const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]);
const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]);
const hasCounterpartyLexeme = /(?:контрагент(?:ов|а|ы)?|counterpart(?:y|ies)|компан(?:и|ия|ии|ию)|организац(?:и|ия|ии|ию)|partner(?:s)?)/iu.test(
text
);
const hasSpecificCounterpartyAnchor =
hasLooseByAnchorMention(text) ||
hasHeuristicCounterpartyAnchor(text) ||
/(?:по\s+(?:клиент(?:у|а)?|заказчик(?:у|а)?|покупател(?:ю|я)|customer|client)\s+[a-zа-яё0-9])/iu.test(text);
const asksWhoPays = /(?:кто\s+(?:нам\s+)?(?:(?:больше|чаще)\s+)?плат(?:ит|ят)?)/iu.test(text);
const asksCustomerGroup =
/(?:клиент(?:ов|а|ы)?|заказчик(?:ов|а|и)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(text) ||
hasFuzzyCustomerLexeme ||
/(?:кто\s+нам\s+(?:больше|чаще)|кто\s+платит)/iu.test(text);
asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksDealBudgetRanking =
@@ -714,8 +766,10 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(
text
);
const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text);
const asksOverallTurnover = /(?:общ(?:ий|ие|ая)\s+оборот|общ(?:ая|ий)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text);
const asksValue =
/(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(
/(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal|turnover)/iu.test(
text
);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(
@@ -731,6 +785,15 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
if (asksCustomerGroup && (asksValue || asksRankOrTop)) {
return true;
}
if (!hasFuzzySupplierLexeme && hasCounterpartyLexeme && asksRankOrTop && (asksValue || asksWhoPays)) {
return true;
}
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true;
}
if (asksCounterpartySource && asksValue) {
return true;
}
@@ -780,13 +843,13 @@ function hasContractUsageAndValueSignal(text: string): boolean {
if (hasAny(text, CONTRACT_USAGE_AND_VALUE_HINTS)) {
return true;
}
if (!/(?:договор(?:ов|а|ы)?|contract(?:s)?)/iu.test(text)) {
if (!/(?:договор(?:ов|а|ы)?|контракт(?:ов|а|ы|у|ом|е)?|contract(?:s)?)/iu.test(text)) {
return false;
}
if (hasContractUsageOverviewSignal(text)) {
return false;
}
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+договор|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+договор|какие\s+договор(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+договор)/iu.test(
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+(?:договор|контракт)|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+(?:договор|контракт)|какие\s+(?:договор|контракт)(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+(?:договор|контракт))/iu.test(
text
);
const asksValue =
@@ -796,7 +859,7 @@ function hasContractUsageAndValueSignal(text: string): boolean {
}
function hasContractListByCounterpartySignal(text: string): boolean {
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|контракт(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
if (!hasContractLexeme) {
return false;
}
@@ -841,7 +904,7 @@ function hasDocumentsByAccountDrilldownSignal(text: string): boolean {
}
function hasOpenContractsListSignal(text: string): boolean {
const hasContractLexeme = text.includes("договор") || text.includes("contract") || text.includes("dogovor");
const hasContractLexeme = text.includes("договор") || text.includes("контракт") || text.includes("contract") || text.includes("dogovor");
const hasOpenLexeme = /(?:незакрыт|не\s+закрыт|открыт|open|unclosed)/iu.test(text);
if (!hasContractLexeme || !hasOpenLexeme) {
return false;
@@ -1186,6 +1249,14 @@ function hasAccountNumberAnchor(text: string): boolean {
export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
const text = String(userMessage ?? "").trim().toLowerCase();
if (hasForecastTaxSignal(text)) {
return {
intent: "vat_payable_forecast",
confidence: "high",
reasons: ["forecast_tax_signal_detected"]
};
}
if (hasAny(text, RECEIVABLES_STRONG)) {
return {
intent: "list_receivables_counterparties",
@@ -1228,7 +1299,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (
hasAny(text, OPEN_ITEMS_HINTS) &&
(text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))
(text.includes("контраг") || text.includes("договор") || text.includes("контракт") || text.includes("counterparty") || text.includes("contract"))
) {
return {
intent: "open_items_by_counterparty_or_contract",
@@ -1398,7 +1469,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("contract"))) {
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) {
return {
intent: "list_open_contracts",
confidence: "medium",
@@ -20,6 +20,10 @@ const ADDRESS_ACTION_TOKENS = [
"вывед",
"кто",
"кому",
"какой",
"какая",
"какое",
"какую",
"какие",
"каких",
"что по",
@@ -67,6 +71,7 @@ const ADDRESS_ENTITY_TOKENS = [
"клиент",
"покупател",
"партнер",
"контракт",
"банк",
"выписк",
"операц",
@@ -236,7 +241,17 @@ function hasLooseByAnchorMention(text: string): boolean {
"активности",
"пассивности",
"наименее",
"минимум"
"минимум",
"запрос",
"запросу",
"запроса",
"запросом",
"запросе",
"вопрос",
"вопросу",
"вопроса",
"вопросом",
"вопросе"
]);
return !stopWords.has(token);
}
@@ -319,7 +334,17 @@ function hasLikelyCounterpartyToken(text: string): boolean {
"пассивный",
"наименее",
"минимум",
"реже"
"реже",
"запрос",
"запросу",
"запроса",
"запросом",
"запросе",
"вопрос",
"вопросу",
"вопроса",
"вопросом",
"вопросе"
]);
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
@@ -386,11 +411,11 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
};
}
if ((hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
if (hasAccountCode && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["address_entity_detected"]
reasons: ["account_code_detected"]
};
}
@@ -1037,6 +1037,11 @@ export class AddressQueryService {
return null;
}
const { mode, shape, intent, filters, baseReasons } = decompose;
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined
});
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
@@ -1273,7 +1278,7 @@ export class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) {
const factual = composeFactualReply(intent.intent, recoveredRows, { userMessage });
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters));
const recoveryReason =
recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback"
@@ -1392,7 +1397,11 @@ export class AddressQueryService {
rowsAnchorMatched: expandedRowsByAnchor.length,
rowsMatched: expandedFilteredRows.length
});
const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows, { userMessage });
const expandedFactual = composeFactualReply(
intent.intent,
expandedFilteredRows,
composeOptionsFromFilters(expandedLimitFilters)
);
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"];
@@ -1501,7 +1510,11 @@ export class AddressQueryService {
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows, { userMessage });
const broadenedFactual = composeFactualReply(
intent.intent,
broadenedFilteredRows,
composeOptionsFromFilters(autoBroadenedFilters)
);
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
@@ -1616,14 +1629,21 @@ export class AddressQueryService {
rowsAnchorMatched: historicalRowsByAnchor.length,
rowsMatched: historicalFilteredRows.length
});
const historicalFactual = composeFactualReply(intent.intent, historicalFilteredRows, { userMessage });
const historicalPrefix =
"В последних доступных записях якорь не подтвердился; показаны найденные строки по историческому окну.";
const historicalFactual = composeFactualReply(
intent.intent,
historicalFilteredRows,
composeOptionsFromFilters(historicalFilters)
);
const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
const historicalSuggestion =
intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту."
: "";
const historicalLimitations = [...filters.warnings, "historical_window_sort_recovery_applied"];
const historicalReasons = [...baseReasons, "historical_window_sort_recovery_applied"];
return {
handled: true,
reply_text: `${historicalPrefix}\n${historicalFactual.text}`,
reply_text: `${historicalPrefix}\n${historicalFactual.text}${historicalSuggestion}`,
reply_type: inferReplyType(historicalFactual.responseType),
response_type: historicalFactual.responseType,
debug: {
@@ -1681,14 +1701,21 @@ export class AddressQueryService {
) {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) {
const fallbackFactual = composeFactualReply(intent.intent, documentBankFallbackRows, { userMessage });
const fallbackFactual = composeFactualReply(
intent.intent,
documentBankFallbackRows,
composeOptionsFromFilters(filters.extracted_filters)
);
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion =
intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
: "";
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
return {
handled: true,
reply_text:
"Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные документы/операции по выбранному типу.\n" +
fallbackFactual.text,
reply_text: `${fallbackPrefix}\n${fallbackFactual.text}${fallbackSuggestion}`,
reply_type: inferReplyType(fallbackFactual.responseType),
response_type: fallbackFactual.responseType,
debug: {
@@ -1751,11 +1778,24 @@ export class AddressQueryService {
Array.isArray(filters.warnings) &&
(filters.warnings.includes("counterparty_from_followup_context") ||
filters.warnings.includes("contract_from_followup_context"));
const anchorMismatchByCounterparty =
isAnchorMismatch && String(matchFailureReason ?? "").includes("counterparty_anchor_not_matched");
const anchorMismatchByContract = isAnchorMismatch && String(matchFailureReason ?? "").includes("contract_anchor_not_matched");
const isLowQualityPartyAnchor =
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") &&
isLikelyLowQualityPartyAnchor(anchor.anchor_value_raw);
const anchorMismatchCategory: AddressLimitedReasonCategory =
isFollowupAnchorCarryover || !isLowQualityPartyAnchor ? "empty_match" : "missing_anchor";
const requestedPeriodFrom =
typeof filters.extracted_filters.period_from === "string" ? filters.extracted_filters.period_from : null;
const requestedPeriodTo = typeof filters.extracted_filters.period_to === "string" ? filters.extracted_filters.period_to : null;
const requestedPeriodHint =
requestedPeriodFrom && requestedPeriodTo ? ` (период ${requestedPeriodFrom}..${requestedPeriodTo} сохранен)` : "";
const anchorMismatchCategory: AddressLimitedReasonCategory = isFollowupAnchorCarryover
? "empty_match"
: anchorMismatchByCounterparty || anchorMismatchByContract
? "missing_anchor"
: !isLowQualityPartyAnchor
? "empty_match"
: "missing_anchor";
const category: AddressLimitedReasonCategory = isAnchorMismatch
? anchorMismatchCategory
: isRecipeFilteredOut
@@ -1764,18 +1804,26 @@ export class AddressQueryService {
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
? anchorMismatchCategory === "missing_anchor"
? "якорь контрагента/договора не найден в материализованных live-строках"
: "по указанному якорю и фильтрам в live-выборке нет строк"
? anchorMismatchByCounterparty
? "контрагент по указанному имени/алиасу не найден в materialized live-строках"
: anchorMismatchByContract
? "договор по указанному номеру/названию не найден в materialized live-строках"
: anchorMismatchCategory === "missing_anchor"
? "якорь контрагента/договора не найден в materialized live-строках"
: "по указанному якорю и фильтрам в live-выборке нет строк"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
? anchorMismatchCategory === "missing_anchor"
? "уточните контрагента точным именем или добавьте ИНН/договор"
: "уточните период или снимите часть фильтров"
? anchorMismatchByCounterparty
? `уточните точное имя контрагента или добавьте ИНН${requestedPeriodHint}`
: anchorMismatchByContract
? `уточните номер/наименование договора${requestedPeriodHint}`
: anchorMismatchCategory === "missing_anchor"
? "уточните контрагента точным именем или добавьте ИНН/договор"
: "уточните период или снимите часть фильтров"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
@@ -1783,9 +1831,13 @@ export class AddressQueryService {
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? [
anchorMismatchCategory === "missing_anchor"
? "anchor_not_matched_after_materialization"
: "no_rows_for_anchor_after_materialization"
anchorMismatchByCounterparty
? "counterparty_anchor_not_matched_after_materialization"
: anchorMismatchByContract
? "contract_anchor_not_matched_after_materialization"
: anchorMismatchCategory === "missing_anchor"
? "anchor_not_matched_after_materialization"
: "no_rows_for_anchor_after_materialization"
]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]
@@ -1824,7 +1876,7 @@ export class AddressQueryService {
});
}
const factual = composeFactualReply(intent.intent, filteredRows, { userMessage });
const factual = composeFactualReply(intent.intent, filteredRows, composeOptionsFromFilters(filters.extracted_filters));
return {
handled: true,
reply_text: factual.text,
@@ -4,6 +4,7 @@
AddressRecipeDefinition,
AddressRecipeSelection
} from "../types/addressQuery";
import { VAT_PAYABLE_19_PREFIXES, VAT_PAYABLE_68_PREFIXES } from "../config";
const MOVEMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
@@ -347,6 +348,66 @@ const CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE = `
Справочник.ДоговорыКонтрагентов КАК Договоры
`;
const VAT_PAYABLE_FORECAST_QUERY_TEMPLATE = `
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_68_CREDIT" КАК Регистратор,
"68" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT68_KT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_68_DEBIT" КАК Регистратор,
"68" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT68_DT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_19_DEBIT" КАК Регистратор,
"19" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT19_DT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_19_CREDIT" КАК Регистратор,
"19" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT19_KT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Регистратор
`;
const BASE_RECIPES: AddressRecipeDefinition[] = [
{
recipe_id: "address_period_coverage_profile_v1",
@@ -428,6 +489,16 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred",
query_template: "contract_value_profile"
},
{
recipe_id: "address_vat_payable_forecast_v1",
intent: "vat_payable_forecast",
purpose: "Estimate VAT payable from factual turnovers on accounts 68 and 19 for selected period",
required_filters: [],
optional_filters: ["period_from", "period_to", "as_of_date", "organization"],
default_limit: 32,
account_scope_mode: "preferred",
query_template: "vat_payable_forecast_profile"
},
{
recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty",
@@ -669,6 +740,64 @@ function buildMovementAccountCondition(filters: AddressFilterSet): string | null
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function normalizeAccountPrefixForQuery(value: string): string | null {
const normalized = String(value ?? "")
.trim()
.replace(",", ".")
.replace(/[^0-9.]+/g, "");
if (!normalized) {
return null;
}
if (!/^\d{2}(?:\.\d{1,3})*$/.test(normalized)) {
return null;
}
return normalized;
}
function accountPrefixVariants(prefix: string): string[] {
const value = normalizeAccountPrefixForQuery(prefix);
if (!value) {
return [];
}
const variants = new Set<string>([value]);
const segments = value.split(".");
if (segments.length <= 1) {
return Array.from(variants);
}
const base = segments[0];
const normalizedTail = segments.slice(1).map((segment) => {
const trimmed = segment.replace(/^0+(?=\d)/, "");
return trimmed.length > 0 ? trimmed : "0";
});
const compact = [base, ...normalizedTail].join(".");
if (compact !== value) {
variants.add(compact);
}
return Array.from(variants);
}
function buildAccountPrefixPredicate(fieldPath: string, prefixes: string[]): string {
const normalizedPrefixes = Array.from(
new Set(
(prefixes ?? [])
.flatMap((item) => accountPrefixVariants(item))
.filter((item): item is string => Boolean(item))
)
);
if (normalizedPrefixes.length === 0) {
return "ЛОЖЬ";
}
const clauses = normalizedPrefixes.map(
(prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`
);
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
const hasAnchor =
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
@@ -694,6 +823,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
@@ -742,7 +872,8 @@ export function buildAddressRecipePlan(
recipe.query_template === "period_profile" ||
recipe.query_template === "document_section_profile" ||
recipe.query_template === "counterparty_roles_profile" ||
recipe.query_template === "contract_usage_profile";
recipe.query_template === "contract_usage_profile" ||
recipe.query_template === "vat_payable_forecast_profile";
const baseLimit =
typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
@@ -830,6 +961,13 @@ export function buildAddressRecipePlan(
buildContractValueWhereClause(filters, "БанкСписание.Дата", "БанкСписание.ДоговорКонтрагента")
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "vat_payable_forecast_profile"
? VAT_PAYABLE_FORECAST_QUERY_TEMPLATE
.replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период"))
.replaceAll("__VAT68_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_19_PREFIXES))
.replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES))
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: MOVEMENTS_QUERY_TEMPLATE
@@ -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";
}
File diff suppressed because it is too large Load Diff
@@ -203,6 +203,103 @@ function buildBaseUrlCandidates(config: OpenAIRequestConfig): string[] {
}
export class OpenAIResponsesClient {
public async chat(
config: OpenAIRequestConfig,
prompt: {
systemPrompt?: string;
developerPrompt?: string;
userMessage: string;
maxOutputTokens?: number;
temperature?: number;
}
): Promise<OpenAIResponseEnvelope> {
const responsesPayload = {
model: config.model,
temperature: prompt.temperature ?? config.temperature ?? 0.2,
max_output_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
input: [
...(String(prompt.systemPrompt ?? "").trim().length > 0
? [
{
role: "system",
content: [{ type: "input_text", text: String(prompt.systemPrompt ?? "").trim() }]
}
]
: []),
...(String(prompt.developerPrompt ?? "").trim().length > 0
? [
{
role: "developer",
content: [{ type: "input_text", text: String(prompt.developerPrompt ?? "").trim() }]
}
]
: []),
{
role: "user",
content: [{ type: "input_text", text: String(prompt.userMessage ?? "") }]
}
]
};
const provider = resolveProvider(config);
if (provider === "openai") {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
try {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
} catch (error) {
if (!shouldFallbackToChatCompletions(error)) {
throw error;
}
}
const chatPayload = {
model: config.model,
temperature: prompt.temperature ?? config.temperature ?? 0.2,
max_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
messages: [
...(String(prompt.systemPrompt ?? "").trim().length > 0
? [
{
role: "system",
content: String(prompt.systemPrompt ?? "").trim()
}
]
: []),
...(String(prompt.developerPrompt ?? "").trim().length > 0
? [
{
role: "developer",
content: String(prompt.developerPrompt ?? "").trim()
}
]
: []),
{
role: "user",
content: String(prompt.userMessage ?? "")
}
]
};
const raw = await this.postChatCompletions(config, chatPayload);
return {
raw,
outputText: extractOutputTextFromChatCompletions(raw),
usage: extractUsage(raw)
};
}
public async listModels(config: OpenAIRequestConfig): Promise<string[]> {
const payload = await this.getModels(config);
const data = Array.isArray(payload.data) ? payload.data : [];
@@ -9,6 +9,7 @@ export type AddressIntent =
| "customer_revenue_and_payments"
| "supplier_payouts_profile"
| "contract_usage_and_value"
| "vat_payable_forecast"
| "list_contracts_by_counterparty"
| "list_open_contracts"
| "list_payables_counterparties"
@@ -119,7 +120,8 @@ export interface AddressRecipeDefinition {
| "customer_revenue_profile"
| "supplier_payout_profile"
| "contract_value_profile"
| "contracts_by_counterparty_profile";
| "contracts_by_counterparty_profile"
| "vat_payable_forecast_profile";
required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>;
default_limit: number;