АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 1 - ЛЛМ ФЕРСТ + feat(address): стабилизация wave1 dynamic resolver контрагентов, follow-up carryover и актуализация docs/tests

This commit is contained in:
2026-04-02 23:11:49 +03:00
parent 53716e548e
commit 88094c09f8
243 changed files with 94296 additions and 181 deletions
@@ -19,6 +19,8 @@ const YEAR_PERIOD_PATTERN =
const YEAR_PERIOD_SHORT_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2})\s*(?:г(?:од|ода)?\.?|year|god)(?=$|[\s,.;:!?()\-])/iu;
const YEAR_PERIOD_SHORT_ORDINAL_PATTERN =
/(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(\d{2})\s*(?:[-\s]?(?:й|ый|ой|th))(?:\s*(?:г(?:од|ода)?\.?|year|period|период))?(?=$|[\s,.;:!?()\-])/iu;
const YEAR_PERIOD_SHORT_BARE_PATTERN =
/(?:^|[\s,.;:!?()\-])(?:за|for|на|in)\s*(\d{2})(?=$|[\s,.;:!?()\-])/iu;
const YEAR_PERIOD_ANY_PATTERN =
/(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN =
@@ -278,6 +280,18 @@ function extractYearPeriod(text: string): { period_from?: string; period_to?: st
}
}
const shortBareMatch = text.match(YEAR_PERIOD_SHORT_BARE_PATTERN);
if (shortBareMatch) {
const shortYear = Number(shortBareMatch[1]);
if (Number.isFinite(shortYear) && shortYear >= 0 && shortYear <= 99) {
const year = 2000 + shortYear;
return {
period_from: `${year}-01-01`,
period_to: `${year}-12-31`
};
}
}
return {};
}
@@ -380,7 +394,9 @@ function hasAllTimeHint(text: string): boolean {
}
function extractLooseByAnchorValue(text: string): string | undefined {
const match = String(text ?? "").match(/(?:^|\s)по\s+([\p{L}][\p{L}\p{N}._-]{1,})(?=[\s,.;:!?)]|$)/iu);
const match = String(text ?? "").match(
/(?:^|\s)по\s+([\p{L}][\p{L}\p{N}._-]{1,}(?:\s+\d{1,6})?)(?=[\s,.;:!?)]|$)/iu
);
if (!match) {
return undefined;
}
@@ -757,7 +773,11 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return ["account", "as_of_date"];
}
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
if (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty"
) {
return ["counterparty"];
}
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
@@ -773,10 +793,18 @@ function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction {
const rawText = String(userMessage ?? "").trim();
const text = normalizeMojibakeString(rawText);
const isManagementProfileIntent =
intent === "period_coverage_profile" ||
intent === "document_type_and_account_section_profile" ||
intent === "counterparty_population_and_roles" ||
intent === "counterparty_activity_lifecycle" ||
intent === "contract_usage_overview";
const filters: AddressFilterSet = {
sort: "period_desc",
limit: 20
sort: "period_desc"
};
if (!isManagementProfileIntent) {
filters.limit = 20;
}
const warnings: string[] = [];
const accountMatch = text.match(ACCOUNT_PATTERN);
@@ -803,28 +831,48 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
if (counterpartyMatch) {
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
if (
!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
) {
const fallbackCounterparty = extractLooseByAnchorValue(text);
if (fallbackCounterparty) {
filters.counterparty = cleanupAnchorValue(fallbackCounterparty);
warnings.push("counterparty_anchor_derived_from_loose_by_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
if (
!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
) {
const implicitCounterparty = extractImplicitCounterpartyValue(text);
if (implicitCounterparty) {
filters.counterparty = cleanupAnchorValue(implicitCounterparty);
warnings.push("counterparty_anchor_derived_from_implicit_phrase");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
if (
!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
) {
const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text);
if (heuristicCounterparty) {
filters.counterparty = cleanupAnchorValue(heuristicCounterparty);
warnings.push("counterparty_anchor_derived_from_free_text_heuristic");
}
}
if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty")) {
if (
!filters.counterparty &&
(intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty")
) {
const leadingCounterparty = extractLeadingCounterpartyTokenHeuristic(text);
if (leadingCounterparty) {
filters.counterparty = cleanupAnchorValue(leadingCounterparty);
@@ -832,7 +880,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
}
const contractMatch = text.match(CONTRACT_PATTERN);
const shouldExtractContractAnchor = intent !== "list_contracts_by_counterparty";
const contractMatch = shouldExtractContractAnchor ? text.match(CONTRACT_PATTERN) : null;
if (contractMatch) {
filters.contract = cleanupContractAnchorValue(String(contractMatch[1]));
}
@@ -879,6 +928,11 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
}
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;
@@ -162,6 +162,123 @@ const BANK_OPERATION_CORE_HINTS = [
"wire"
];
const PERIOD_COVERAGE_PROFILE_HINTS = [
"за какие годы",
"за какие года",
"в базе есть данные",
"покрытие периодов",
"диапазон лет",
"профиль данных",
"самый активный год",
"самый активный месяц",
"самый пассивный год",
"самый пассивный месяц",
"наименее активный год",
"наименее активный месяц",
"минимум документов по году",
"минимум операций по месяцу",
"год с минимальным количеством документов",
"месяц с минимальным количеством операций",
"активный год по количеству документов",
"активный месяц по количеству операций",
"most active year",
"most active month",
"least active year",
"least active month",
"year coverage",
"data coverage"
];
const DOCUMENT_TYPE_AND_ACCOUNT_SECTION_PROFILE_HINTS = [
"типы документов",
"типы доков",
"документы чаще всего",
"документы реже всего",
"редкие типы документов",
"наименее используемые типы документов",
"частые типы документов",
"сводка по типам документов",
"доля типов документов",
"разделы учета",
"разделы учёта",
"наиболее заполнены",
"наименее заполнены",
"почти не используются",
"account section",
"document types usage",
"document type profile"
];
const COUNTERPARTY_POPULATION_AND_ROLES_HINTS = [
"сколько всего контрагентов",
"сколько уникальных контрагентов",
"сколько контрагентов в базе",
"сколько заказчиков",
"сколько поставщиков",
"сколько клиентов",
"сколько покупателей",
"скока всего контрагентов",
"скока уникальных контрагентов",
"скока контрагентов в базе",
"скока заказчиков",
"скока поставщиков",
"скока клиентов",
"скока покупателей",
"скок контрагентов",
"скок контрагентов в базе",
"скок заказчиков",
"скок поставщиков",
"скок клиентов",
"скок покупателей",
"сколько смешанных контрагентов",
"типы контрагентов",
"разбей контрагентов",
"раздели контрагентов",
"counterparty population",
"counterparty roles",
"customer supplier split"
];
const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [
"какие заказчики работали",
"какие заказчики активны",
"какие клиенты работали",
"какие клиенты активны",
"список заказчиков",
"список клиентов",
"список заказчиков за все время",
"список клиентов за все время",
"список активных заказчиков",
"список активных клиентов",
"active customers",
"customer activity list",
"counterparty lifecycle"
];
const CONTRACT_USAGE_OVERVIEW_HINTS = [
"сколько всего договоров",
"сколько договоров заведено",
"сколько договоров в базе",
"сколько договоров использовались",
"сколько договоров использовалось",
"договоры total vs used",
"обзор договорной базы",
"договорная база total used",
"contracts total used",
"contract usage overview"
];
const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
"договоры по",
"договора по",
"список договоров по",
"покажи договоры по",
"выведи договоры по",
"contracts by counterparty",
"list contracts by counterparty",
"show contracts by counterparty"
];
function hasAny(text: string, patterns: string[]): boolean {
return patterns.some((item) => text.includes(item));
}
@@ -231,6 +348,162 @@ function hasAccountBalanceSignal(text: string): boolean {
return hasAccountLexeme && hasAsOfStyleDate && hasFollowupBalanceVerb;
}
function hasPeriodCoverageProfileSignal(text: string): boolean {
if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) {
return true;
}
if (/(?:за\s+какие\s+год[а-яё]*\s+в\s+баз[еы]\s+есть\s+данн)/iu.test(text)) {
return true;
}
if (
/(?:какой\s+год[а-яё]*\s+сам(?:ый|ая|ое)\s+(?:актив|пассив)|какой\s+год[а-яё]*\s+наименее\s+актив|год\s+с\s+минимальн)/iu.test(text) &&
/(?:документ|doc)/iu.test(text)
) {
return true;
}
if (
/(?:какой\s+месяц[а-яё]*\s+сам(?:ый|ая|ое)\s+(?:актив|пассив)|какой\s+месяц[а-яё]*\s+наименее\s+актив|месяц\s+с\s+минимальн)/iu.test(text) &&
/(?:операц|operation|ops?)/iu.test(text)
) {
return true;
}
if (/(?:профил[ья]\s+данн|покрыт(?:ие|ия)\s+период|диапазон\s+лет)/iu.test(text)) {
return true;
}
return false;
}
function hasDocumentTypeAndAccountSectionProfileSignal(text: string): boolean {
if (hasAny(text, DOCUMENT_TYPE_AND_ACCOUNT_SECTION_PROFILE_HINTS)) {
return true;
}
if (/(?:какие\s+тип[аы]\s+док(?:умент|ов|и)?\s+(?:использ|чаще|больш))/iu.test(text)) {
return true;
}
if (/(?:какие\s+тип[аы]\s+док(?:умент|ов|и)?\s+(?:реже|редк|наименее|миним))/iu.test(text)) {
return true;
}
if (/(?:типы?\s+док(?:умент|ов|и)?\s+и\s+их\s+дол[ья])/iu.test(text)) {
return true;
}
if (/(?:какие\s+раздел[ыа]\s+уч[её]та\s+(?:наибол|наимен|заполн|почти\s+не))/iu.test(text)) {
return true;
}
if (/(?:раздел[ыа]\s+уч[её]та).*(?:жирн|мертв|пуст|использ)/iu.test(text)) {
return true;
}
return false;
}
function hasCounterpartyPopulationAndRolesSignal(text: string): boolean {
if (hasAny(text, COUNTERPARTY_POPULATION_AND_ROLES_HINTS)) {
return true;
}
if (
/(?:(?:сколько|скока|скок)\s+(?:всего\s+)?уникальн(?:ых|ые|ого)?\s+контрагент|(?:сколько|скока|скок)\s+(?:всего\s+)?контрагент(?:ов|а)?(?:\s+в\s+баз[еы])?)/iu.test(
text
)
) {
return true;
}
if (
/(?:(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?заказчик(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?поставщик(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?клиент(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?покупател(?:ей|я)|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?смешан(?:ных|ые)\s+контрагент(?:ов|а)?|заказчик(?:и|ов)\s*,?\s*поставщик(?:и|ов))/iu.test(
text
)
) {
return true;
}
if (/(?:разбей|раздели|сформируй\s+сводк).*(?:контрагент|заказчик|поставщик|клиент|покупател)/iu.test(text)) {
return true;
}
return false;
}
function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
if (hasDocumentSignal(text) || hasBankOperationSignal(text)) {
return false;
}
if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) {
return true;
}
if (/(?:сколько|скока|скок)\s+/iu.test(text)) {
return false;
}
const hasCustomerLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(
text
);
const hasActivityLexeme = /(?:работал(?:и)?|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active)/iu.test(text);
const hasTimeWindowLexeme =
/(?:за\s+вс[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|в\s+конкретн(?:ом|ый)\s+год|за\s+год|в\s+году)/iu.test(
text
);
const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text);
const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text);
const hasListWithWindow = hasCustomerLexeme && hasListVerb && hasTimeWindowLexeme;
if (hasListWithWindow) {
return true;
}
if (hasCustomerLexeme && hasListVerb && hasRosterQualifier) {
return true;
}
return hasCustomerLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
}
function hasContractUsageOverviewSignal(text: string): boolean {
if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) {
return true;
}
if (
/(?:сколько\s+(?:всего\s+)?договор(?:ов|а)?(?:\s+заведен[оы])?|договорн(?:ая|ой)\s+баз[аы]).*(?:сколько|used|использ)/iu.test(
text
)
) {
return true;
}
if (
/(?:сколько\s+из\s+договор(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)
) {
return true;
}
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|contract)?/iu.test(text)) {
return true;
}
return false;
}
function hasContractListByCounterpartySignal(text: string): boolean {
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
if (!hasContractLexeme) {
return false;
}
if (hasContractUsageOverviewSignal(text) || hasOpenContractsListSignal(text)) {
return false;
}
if (hasContractNumberLikeToken(text)) {
return false;
}
if (hasBankOperationSignal(text)) {
return false;
}
const hasListVerb = /(?:покажи|выведи|список|какие|show|list)/iu.test(text);
const hasAllQualifier = /(?:\ball\b|\bвсе\b|всё)/iu.test(text);
const hasCounterpartyAnchor =
hasPartyAnchorMention(text) ||
hasLooseByAnchorMention(text) ||
hasHeuristicCounterpartyAnchor(text);
if (!hasCounterpartyAnchor) {
return false;
}
return hasListVerb || hasAllQualifier || hasAny(text, CONTRACT_LIST_BY_COUNTERPARTY_HINTS);
}
function hasDocumentsByAccountDrilldownSignal(text: string): boolean {
const hasAccountLexeme = hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text);
const hasDocLexeme = /(?:документ|док(?:и|ам|ах|ов|а)?|docs?|documents?)/iu.test(text);
@@ -287,6 +560,21 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
"документах",
"докам",
"доками",
"количество",
"количеству",
"количества",
"количеством",
"активный",
"активного",
"активности",
"пассивный",
"пассивного",
"пассивности",
"наименее",
"минимальный",
"минимум",
"реже",
"редкий",
"банк",
"банковские",
"операции",
@@ -450,7 +738,15 @@ function hasLooseByAnchorMention(text: string): boolean {
"периоду",
"период",
"документам",
"докам"
"докам",
"количество",
"количеству",
"количества",
"количеством",
"активности",
"пассивности",
"наименее",
"минимум"
]);
return !stopWords.has(token);
}
@@ -589,6 +885,76 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (
hasPeriodCoverageProfileSignal(text) &&
!hasPartyAnchorMention(text) &&
!hasContractAnchorSignal(text) &&
!hasAccountBalanceSignal(text)
) {
return {
intent: "period_coverage_profile",
confidence: "high",
reasons: ["period_coverage_profile_signal_detected"]
};
}
if (
hasDocumentTypeAndAccountSectionProfileSignal(text) &&
!hasPartyAnchorMention(text) &&
!hasContractAnchorSignal(text) &&
!hasAccountBalanceSignal(text)
) {
return {
intent: "document_type_and_account_section_profile",
confidence: "high",
reasons: ["document_type_and_account_section_profile_signal_detected"]
};
}
if (
hasCounterpartyPopulationAndRolesSignal(text) &&
!hasContractAnchorSignal(text) &&
!hasAccountBalanceSignal(text)
) {
return {
intent: "counterparty_population_and_roles",
confidence: "high",
reasons: ["counterparty_population_and_roles_signal_detected"]
};
}
if (
hasCounterpartyActivityLifecycleSignal(text) &&
!hasContractAnchorSignal(text) &&
!hasAccountBalanceSignal(text)
) {
return {
intent: "counterparty_activity_lifecycle",
confidence: "high",
reasons: ["counterparty_activity_lifecycle_signal_detected"]
};
}
if (
hasContractUsageOverviewSignal(text) &&
!hasAccountBalanceSignal(text) &&
!hasOpenContractsListSignal(text)
) {
return {
intent: "contract_usage_overview",
confidence: "high",
reasons: ["contract_usage_overview_signal_detected"]
};
}
if (hasContractListByCounterpartySignal(text)) {
return {
intent: "list_contracts_by_counterparty",
confidence: "medium",
reasons: ["contracts_by_counterparty_signal_detected"]
};
}
if (
hasContractAnchorSignal(text) &&
hasBankOperationSignal(text)
@@ -62,6 +62,7 @@ const ADDRESS_ENTITY_TOKENS = [
"компан",
"организац",
"поставщик",
"заказчик",
"клиент",
"покупател",
"партнер",
@@ -106,6 +107,62 @@ const DEEP_REASONING_TOKENS = [
"ошибк"
];
function hasManagementProfileSignal(text: string): boolean {
if (/(?:за\s+какие\s+год[а-яё]*\s+в\s+баз[еы]\s+есть\s+данн)/iu.test(text)) {
return true;
}
if (/(?:какой\s+год[а-яё]*\s+сам(?:ый|ая|ое)\s+актив)/iu.test(text)) {
return true;
}
if (/(?:какой\s+год[а-яё]*\s+сам(?:ый|ая|ое)\s+пассив|какой\s+год[а-яё]*\s+наименее\s+актив|год\s+с\s+минимальн)/iu.test(text)) {
return true;
}
if (/(?:какой\s+месяц[а-яё]*\s+сам(?:ый|ая|ое)\s+актив)/iu.test(text)) {
return true;
}
if (/(?:какой\s+месяц[а-яё]*\s+сам(?:ый|ая|ое)\s+пассив|какой\s+месяц[а-яё]*\s+наименее\s+актив|месяц\s+с\s+минимальн)/iu.test(text)) {
return true;
}
if (/(?:профил[ья]\s+данн|покрыт(?:ие|ия)\s+период|диапазон\s+лет)/iu.test(text)) {
return true;
}
if (/(?:какие\s+тип[аы]\s+док(?:умент|ов|и)?\s+(?:использ|чаще|больш))/iu.test(text)) {
return true;
}
if (/(?:какие\s+тип[аы]\s+док(?:умент|ов|и)?\s+(?:реже|редк|наименее|миним))/iu.test(text)) {
return true;
}
if (/(?:типы?\s+док(?:умент|ов|и)?\s+и\s+их\s+дол[ья])/iu.test(text)) {
return true;
}
if (/(?:какие\s+раздел[ыа]\s+уч[её]та\s+(?:наибол|наимен|заполн|почти\s+не))/iu.test(text)) {
return true;
}
if (/(?:раздел[ыа]\s+уч[её]та).*(?:жирн|мертв|пуст|использ)/iu.test(text)) {
return true;
}
if (/(?:(?:сколько|скока|скок)\s+(?:всего\s+)?(?:уникальн(?:ых|ые|ого)?\s+)?контрагент(?:ов|а)?(?:\s+в\s+баз[еы])?)/iu.test(text)) {
return true;
}
if (/(?:(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?заказчик(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?поставщик(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?клиент(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?покупател(?:ей|я)|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?смешан(?:ных|ые)\s+контрагент(?:ов|а)?|разбей\s+контр)/iu.test(text)) {
return true;
}
if (/(?:покажи|выведи|список|какие|кто).*(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?).*(?:за\s+вс[её]\s+время|all\s+time|(?:^|[^\d])(19|20)\d{2}(?:[^\d]|$)|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|за\s+год|в\s+году)/iu.test(text)) {
return true;
}
if (/(?:какие|кто|покажи|выведи|список).*(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?).*(?:работал(?:и)?|активн(?:ые|ых|а|о)?).*(?:за\s+вс[её]\s+время|(?:19|20)\d{2}|за\s+год|в\s+году)|(?:active\s+customers?|customer\s+activity)/iu.test(text)) {
return true;
}
if (
/(?:сколько\s+(?:всего\s+)?договор(?:ов|а)?(?:\s+заведен[оы])?|договорн(?:ая|ой)\s+баз[аы]|total\s+vs\s+used).*(?:использ|used|договор|contract)?/iu.test(
text
)
) {
return true;
}
return false;
}
function hasLooseByAnchorMention(text: string): boolean {
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (!match) {
@@ -141,7 +198,14 @@ function hasLooseByAnchorMention(text: string): boolean {
"документам",
"докам",
"взаиморасчетам",
"взаиморасчётам"
"взаиморасчётам",
"количество",
"количеству",
"количества",
"активности",
"пассивности",
"наименее",
"минимум"
]);
return !stopWords.has(token);
}
@@ -216,7 +280,15 @@ function hasLikelyCounterpartyToken(text: string): boolean {
"list",
"please",
"all",
"vse"
"vse",
"количество",
"количеству",
"количества",
"активный",
"пассивный",
"наименее",
"минимум",
"реже"
]);
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
@@ -254,6 +326,7 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
const hasAddressAction = hasAnyToken(text, ADDRESS_ACTION_TOKENS);
const hasAddressEntity = hasAnyToken(text, ADDRESS_ENTITY_TOKENS);
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
const hasManagementSignal = hasManagementProfileSignal(text);
const hasLooseByAnchor = hasLooseByAnchorMention(text);
const hasFollowupSignal = hasAddressFollowupSignal(text);
const hasAccountCode = hasAccountCodeAnchor(text);
@@ -266,6 +339,14 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
};
}
if (hasManagementSignal && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["management_profile_signal_detected"]
};
}
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal || hasAccountCode) && !hasDeepReasoning) {
return {
mode: "address_query",
@@ -35,6 +35,8 @@ interface AddressTryHandleOptions {
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
@@ -85,6 +87,26 @@ const ACCOUNT_ALIAS_MAP: Record<string, string[]> = {
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
};
const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
ПРЕДСТАВЛЕНИЕ(Контрагенты.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Контрагенты.Ссылка) КАК Контрагент
ИЗ
Справочник.Контрагенты КАК Контрагенты
`;
interface CounterpartyCatalogResolution {
tried: boolean;
resolvedValue: string | null;
confidence: "high" | "medium" | "low" | null;
ambiguityCount: number;
}
let counterpartyCatalogCache: { names: string[]; loadedAt: number } | null = null;
function parseFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
@@ -165,6 +187,28 @@ function tokenizeAnchor(value: string): string[] {
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function anchorTokenVariants(token: string): string[] {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
return [];
}
const variants = new Set<string>([source]);
if (/^[а-яё]+$/iu.test(source) && source.length >= 4) {
const withoutEnding = source.replace(
/(?:ами|ями|ого|ему|ому|ыми|ими|иях|ях|ах|ей|ой|ом|ем|ам|ям|ую|юю|ая|яя|ое|ее|ые|ие|ов|ев|ий|ый|ой|е|у|ы|а|я|и|ю)$/iu,
""
);
if (withoutEnding.length >= 3) {
variants.add(withoutEnding);
}
const withoutTrailingVowel = source.replace(/[аеёиоуыэюя]$/iu, "");
if (withoutTrailingVowel.length >= 3) {
variants.add(withoutTrailingVowel);
}
}
return Array.from(variants);
}
function matchesAnchorText(searchable: string, anchor: string): boolean {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
@@ -177,8 +221,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
const variants = anchorTokenVariants(token);
return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin);
});
});
}
@@ -260,6 +307,172 @@ function uniqueStrings(values: string[]): string[] {
);
}
function normalizeCounterpartyName(value: string): string {
return normalizeSearchText(String(value ?? ""))
.replace(/\s+/g, " ")
.trim();
}
function extractCounterpartyCatalogNames(rows: Array<Record<string, unknown>>): string[] {
return uniqueStrings(
rows
.map((row) => {
const direct =
valueAsString(row.Контрагент ?? row.counterparty ?? row.Counterparty).trim() ||
valueAsString(row.Регистратор ?? row.registrator ?? row.Registrator).trim();
return direct;
})
.map((value) => value.trim())
.filter((value) => value.length >= 2)
);
}
function scoreCounterpartyCandidate(name: string, anchor: string): number | null {
if (!matchesAnchorText(name, anchor)) {
return null;
}
const normalizedName = normalizeCounterpartyName(name);
const normalizedAnchor = normalizeCounterpartyName(anchor);
if (!normalizedName || !normalizedAnchor) {
return null;
}
let score = 0;
if (normalizedName === normalizedAnchor) {
score += 10_000;
} else if (normalizedName.includes(normalizedAnchor)) {
score += 5_000;
} else if (normalizedAnchor.includes(normalizedName) && normalizedName.length >= 4) {
score += 2_000;
}
const anchorTokens = tokenizeAnchor(anchor);
for (const token of anchorTokens) {
const variants = anchorTokenVariants(token);
let tokenScore = 0;
for (const variant of variants) {
if (normalizedName.includes(variant)) {
tokenScore = Math.max(tokenScore, Math.max(2, variant.length) * 20);
}
}
if (tokenScore === 0) {
return null;
}
score += tokenScore;
}
const lengthPenalty = Math.abs(normalizedName.length - normalizedAnchor.length);
score -= lengthPenalty;
return score;
}
function shouldAttemptCounterpartyCatalogResolution(intent: AddressIntent, filters: AddressFilterSet): boolean {
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
if (!counterparty || isLikelyLowQualityPartyAnchor(counterparty)) {
return false;
}
return (
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_payables_counterparties" ||
intent === "list_receivables_counterparties"
);
}
async function resolveCounterpartyViaCatalog(anchorRaw: string): Promise<CounterpartyCatalogResolution> {
const requested = String(anchorRaw ?? "").trim();
if (!requested || isLikelyLowQualityPartyAnchor(requested)) {
return {
tried: false,
resolvedValue: null,
confidence: null,
ambiguityCount: 0
};
}
const now = Date.now();
const cacheFresh =
counterpartyCatalogCache !== null && now - counterpartyCatalogCache.loadedAt <= COUNTERPARTY_CATALOG_CACHE_TTL_MS;
let names: string[] = cacheFresh ? [...counterpartyCatalogCache!.names] : [];
if (!cacheFresh) {
const mcp = await executeAddressMcpQuery({
query: COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(COUNTERPARTY_CATALOG_LOOKUP_LIMIT)),
limit: COUNTERPARTY_CATALOG_LOOKUP_LIMIT
});
if (!mcp.error) {
names = extractCounterpartyCatalogNames(mcp.raw_rows);
if (names.length > 0) {
counterpartyCatalogCache = {
names: [...names],
loadedAt: now
};
}
} else if (counterpartyCatalogCache && counterpartyCatalogCache.names.length > 0) {
names = [...counterpartyCatalogCache.names];
} else {
return {
tried: true,
resolvedValue: null,
confidence: null,
ambiguityCount: 0
};
}
}
if (names.length === 0) {
return {
tried: true,
resolvedValue: null,
confidence: null,
ambiguityCount: 0
};
}
const scored = names
.map((name) => {
const score = scoreCounterpartyCandidate(name, requested);
return score === null ? null : { name, score };
})
.filter((item): item is { name: string; score: number } => Boolean(item))
.sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name, "ru"));
if (scored.length === 0) {
return {
tried: true,
resolvedValue: null,
confidence: null,
ambiguityCount: 0
};
}
const topScore = scored[0].score;
const topCandidates = scored.filter((item) => item.score === topScore);
const bestCandidate = topCandidates[0];
const normalizedRequested = normalizeCounterpartyName(requested);
const normalizedBest = normalizeCounterpartyName(bestCandidate.name);
const isExact = normalizedBest === normalizedRequested;
const isStrongContains = normalizedBest.includes(normalizedRequested);
if (topCandidates.length > 1 && !isExact && !isStrongContains) {
return {
tried: true,
resolvedValue: null,
confidence: "low",
ambiguityCount: topCandidates.length - 1
};
}
return {
tried: true,
resolvedValue: bestCandidate.name,
confidence: isExact ? "high" : isStrongContains ? "medium" : topCandidates.length === 1 ? "medium" : "low",
ambiguityCount: topCandidates.length - 1
};
}
function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
const fixedKeys = [
"СубконтоДт1",
@@ -466,10 +679,15 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte
);
}
function invertSort(sort: AddressFilterSet["sort"]): AddressFilterSet["sort"] {
return sort === "period_asc" ? "period_desc" : "period_asc";
}
function isAnchorRecoveryIntent(intent: AddressIntent): boolean {
return (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract" ||
intent === "open_items_by_counterparty_or_contract" ||
@@ -923,6 +1141,45 @@ export class AddressQueryService {
});
}
const rawCounterpartyAnchor =
typeof filters.extracted_filters.counterparty === "string" ? filters.extracted_filters.counterparty.trim() : "";
if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) {
const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor);
if (catalogResolution.resolvedValue) {
if (normalizeCounterpartyName(rawCounterpartyAnchor) !== normalizeCounterpartyName(catalogResolution.resolvedValue)) {
filters.warnings.push("counterparty_anchor_resolved_via_catalog_lookup");
}
} else if (catalogResolution.tried) {
filters.warnings.push(
catalogResolution.ambiguityCount > 0
? "counterparty_anchor_catalog_lookup_ambiguous"
: "counterparty_anchor_catalog_lookup_no_match"
);
}
anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
if (anchor.anchor_type === "counterparty") {
anchor = {
...anchor,
anchor_value_raw: rawCounterpartyAnchor || anchor.anchor_value_raw
};
if (catalogResolution.resolvedValue) {
anchor = {
...anchor,
anchor_value_resolved: catalogResolution.resolvedValue,
resolver_confidence: catalogResolution.confidence ?? anchor.resolver_confidence,
ambiguity_count: Math.max(anchor.ambiguity_count, catalogResolution.ambiguityCount)
};
} else if (catalogResolution.ambiguityCount > 0) {
anchor = {
...anchor,
resolver_confidence: "low",
ambiguity_count: Math.max(anchor.ambiguity_count, catalogResolution.ambiguityCount)
};
}
}
}
const plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters);
const mcp = await executeAddressMcpQuery({
query: plan.query,
@@ -1013,7 +1270,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);
const factual = composeFactualReply(intent.intent, recoveredRows, { userMessage });
const recoveryReason =
recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback"
@@ -1132,7 +1389,7 @@ export class AddressQueryService {
rowsAnchorMatched: expandedRowsByAnchor.length,
rowsMatched: expandedFilteredRows.length
});
const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows);
const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows, { userMessage });
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"];
@@ -1241,7 +1498,7 @@ export class AddressQueryService {
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows);
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows, { userMessage });
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
@@ -1295,15 +1552,133 @@ export class AddressQueryService {
}
}
if (
filteredRows.length === 0 &&
isDocumentOrBankAnchorIntent(intent.intent) &&
!hasExplicitPeriodWindow(filters.extracted_filters) &&
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")
) {
const currentLimit =
typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit)
? Math.max(1, Math.trunc(filters.extracted_filters.limit))
: plan.limit;
const historicalFilters: AddressFilterSet = {
...filters.extracted_filters,
sort: invertSort(filters.extracted_filters.sort),
limit: Math.max(currentLimit, ADDRESS_ANCHOR_RECOVERY_LIMIT)
};
const historicalSelection = selectAddressRecipe(intent.intent, historicalFilters);
if (historicalSelection.selected_recipe && historicalSelection.missing_required_filters.length === 0) {
const historicalPlan = buildAddressRecipePlan(historicalSelection.selected_recipe, historicalFilters);
const historicalMcp = await executeAddressMcpQuery({
query: historicalPlan.query,
limit: historicalPlan.limit
});
if (!historicalMcp.error) {
const historicalRawRows = toNormalizedRows(historicalMcp.raw_rows);
const historicalScopedRows = applyAccountScopeFilter(historicalRawRows, historicalPlan.account_scope);
const historicalAccountScopeFallbackApplied =
historicalPlan.account_scope_mode === "preferred" &&
historicalPlan.account_scope.length > 0 &&
historicalRawRows.length > 0 &&
historicalScopedRows.length === 0;
const historicalNormalizedRows = historicalAccountScopeFallbackApplied ? historicalRawRows : historicalScopedRows;
let historicalAnchor = resolvePrimaryAnchor(intent.intent, historicalFilters);
historicalAnchor = refineAnchorFromRows(historicalAnchor, historicalNormalizedRows);
const historicalFiltersForMatching: AddressFilterSet =
historicalAnchor.anchor_type === "counterparty" && historicalAnchor.anchor_value_resolved
? { ...historicalFilters, counterparty: historicalAnchor.anchor_value_resolved }
: historicalAnchor.anchor_type === "contract" && historicalAnchor.anchor_value_resolved
? { ...historicalFilters, contract: historicalAnchor.anchor_value_resolved }
: historicalFilters;
const historicalAccountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: historicalFiltersForMatching,
accountScope: historicalPlan.account_scope,
rowsBeforeScope: historicalRawRows.length,
rowsAfterScope: historicalNormalizedRows.length
});
const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching);
const historicalRowsByAnchor = historicalAnchorFilter.rows;
const historicalFilteredRows = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor);
if (historicalFilteredRows.length > 0) {
const historicalRowDiagnostics = deriveRowStageDiagnostics(
historicalMcp.raw_rows,
historicalNormalizedRows.length,
historicalNormalizedRows.length
);
const historicalStageStatus = deriveMcpStageStatus({
rawRowsReceived: historicalMcp.raw_rows.length,
rowsMaterialized: historicalNormalizedRows.length,
rowsAnchorMatched: historicalRowsByAnchor.length,
rowsMatched: historicalFilteredRows.length
});
const historicalFactual = composeFactualReply(intent.intent, historicalFilteredRows, { userMessage });
const historicalPrefix =
"В последних доступных записях якорь не подтвердился; показаны найденные строки по историческому окну.";
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_type: inferReplyType(historicalFactual.responseType),
response_type: historicalFactual.responseType,
debug: {
detected_mode: mode.mode,
detected_mode_confidence: mode.confidence,
query_shape: shape.shape,
query_shape_confidence: shape.confidence,
detected_intent: intent.intent,
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: historicalSelection.selected_recipe.recipe_id,
mcp_call_status_legacy: toLegacyMcpStatus(historicalStageStatus),
account_scope_mode: historicalPlan.account_scope_mode,
account_scope_fallback_applied: historicalAccountScopeFallbackApplied,
anchor_type: historicalAnchor.anchor_type,
anchor_value_raw: historicalAnchor.anchor_value_raw,
anchor_value_resolved: historicalAnchor.anchor_value_resolved,
resolver_confidence: historicalAnchor.resolver_confidence,
ambiguity_count: historicalAnchor.ambiguity_count,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: historicalStageStatus,
rows_fetched: historicalMcp.fetched_rows,
raw_rows_received: historicalMcp.raw_rows.length,
rows_after_account_scope: historicalNormalizedRows.length,
rows_after_recipe_filter: historicalRowsByAnchor.length,
rows_materialized: historicalNormalizedRows.length,
rows_matched: historicalFilteredRows.length,
raw_row_keys_sample: historicalRowDiagnostics.rawRowKeysSample,
materialization_drop_reason: historicalRowDiagnostics.materializationDropReason,
account_token_raw: historicalAccountScopeAudit.accountTokenRaw,
account_token_normalized: historicalAccountScopeAudit.accountTokenNormalized,
account_scope_fields_checked: historicalAccountScopeAudit.accountScopeFieldsChecked,
account_scope_match_strategy: historicalAccountScopeAudit.accountScopeMatchStrategy,
account_scope_drop_reason: historicalAccountScopeAudit.accountScopeDropReason,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: historicalFactual.responseType,
limitations: historicalLimitations,
reasons: historicalReasons
}
};
}
}
}
}
if (
filteredRows.length === 0 &&
isDocumentOrBankAnchorIntent(intent.intent) &&
normalizedRows.length > 0 &&
filterByAnchors.length > 0 &&
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")
) {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) {
const fallbackFactual = composeFactualReply(intent.intent, documentBankFallbackRows);
const fallbackFactual = composeFactualReply(intent.intent, documentBankFallbackRows, { userMessage });
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
return {
@@ -1446,7 +1821,7 @@ export class AddressQueryService {
});
}
const factual = composeFactualReply(intent.intent, filteredRows);
const factual = composeFactualReply(intent.intent, filteredRows, { userMessage });
return {
handled: true,
reply_text: factual.text,
@@ -16,7 +16,7 @@ const MOVEMENTS_QUERY_TEMPLATE = `
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Движения.Период УБЫВ
Движения.Период __ORDER_DIRECTION__
`;
const BANK_DOCS_QUERY_TEMPLATE = `
@@ -42,10 +42,310 @@ __WHERE_OUT__
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
УПОРЯДОЧИТЬ ПО
Период __ORDER_DIRECTION__
`;
const PERIOD_COVERAGE_PROFILE_QUERY_TEMPLATE = `
ВЫБРАТЬ
МИНИМУМ(Движения.Период) КАК Период,
"MIN_DATE" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
МАКСИМУМ(Движения.Период) КАК Период,
"MAX_DATE" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
НАЧАЛОПЕРИОДА(Движения.Период, ГОД) КАК Период,
"YEAR_OPS" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
СГРУППИРОВАТЬ ПО
НАЧАЛОПЕРИОДА(Движения.Период, ГОД)
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
НАЧАЛОПЕРИОДА(Движения.Период, ГОД) КАК Период,
"YEAR_DOCS" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Движения.Регистратор) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
СГРУППИРОВАТЬ ПО
НАЧАЛОПЕРИОДА(Движения.Период, ГОД)
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
НАЧАЛОПЕРИОДА(Движения.Период, МЕСЯЦ) КАК Период,
"MONTH_OPS" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
СГРУППИРОВАТЬ ПО
НАЧАЛОПЕРИОДА(Движения.Период, МЕСЯЦ)
УПОРЯДОЧИТЬ ПО
Регистратор,
Период
`;
const DOCUMENT_TYPE_AND_SECTION_PROFILE_QUERY_TEMPLATE = `
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"DOC_TYPE_DOCS" КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(ТИПЗНАЧЕНИЯ(Движения.Регистратор)) КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Движения.Регистратор) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
СГРУППИРОВАТЬ ПО
ПРЕДСТАВЛЕНИЕ(ТИПЗНАЧЕНИЯ(Движения.Регистратор))
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"SECTION_DT_OPS" КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
"DT" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
СГРУППИРОВАТЬ ПО
Движения.СчетДт
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"SECTION_KT_OPS" КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетДт,
"KT" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
СГРУППИРОВАТЬ ПО
Движения.СчетКт
УПОРЯДОЧИТЬ ПО
Регистратор,
Сумма УБЫВ
`;
const COUNTERPARTY_POPULATION_AND_ROLES_QUERY_TEMPLATE = `
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"CP_TOTAL" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма
ИЗ
Справочник.Контрагенты КАК Контрагенты
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"CP_CUSTOMER_ACTIVE" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ БанкПоступление.Контрагент) КАК Сумма
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"CP_SUPPLIER_ACTIVE" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ БанкСписание.Контрагент) КАК Сумма
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"CP_MIXED_ACTIVE" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма
ИЗ
(ВЫБРАТЬ РАЗЛИЧНЫЕ
БанкПоступление.Контрагент КАК Контрагент
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
) КАК Входящие
ВНУТРЕННЕЕ СОЕДИНЕНИЕ
(ВЫБРАТЬ РАЗЛИЧНЫЕ
БанкСписание.Контрагент КАК Контрагент
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
) КАК Исходящие
ПО Входящие.Контрагент = Исходящие.Контрагент
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"CP_ACTIVE_UNION" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Источник.Контрагент) КАК Сумма
ИЗ
(ВЫБРАТЬ
БанкПоступление.Контрагент КАК Контрагент
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
БанкСписание.Контрагент КАК Контрагент
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
) КАК Источник
УПОРЯДОЧИТЬ ПО
Регистратор
`;
const COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE = `
ВЫБРАТЬ
МАКСИМУМ(БанкПоступление.Дата) КАК Период,
"CP_CUSTOMER_ACTIVITY" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
СГРУППИРОВАТЬ ПО
БанкПоступление.Контрагент
УПОРЯДОЧИТЬ ПО
Сумма УБЫВ,
Период УБЫВ
`;
const CONTRACT_USAGE_OVERVIEW_QUERY_TEMPLATE = `
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"CT_TOTAL" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма
ИЗ
Справочник.ДоговорыКонтрагентов КАК Договоры
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"CT_USED" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Источник.Договор) КАК Сумма
ИЗ
(ВЫБРАТЬ
БанкПоступление.ДоговорКонтрагента КАК Договор
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN_USED__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
БанкСписание.ДоговорКонтрагента КАК Договор
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT_USED__
) КАК Источник
УПОРЯДОЧИТЬ ПО
Регистратор
`;
const CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
ПРЕДСТАВЛЕНИЕ(Договоры.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Договоры.Владелец) КАК Контрагент
ИЗ
Справочник.ДоговорыКонтрагентов КАК Договоры
`;
const BASE_RECIPES: AddressRecipeDefinition[] = [
{
recipe_id: "address_period_coverage_profile_v1",
intent: "period_coverage_profile",
purpose: "Build period coverage profile and top active year/month metrics from movements",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "limit"],
default_limit: 600,
account_scope_mode: "preferred",
query_template: "period_profile"
},
{
recipe_id: "address_document_type_and_account_section_profile_v1",
intent: "document_type_and_account_section_profile",
purpose: "Build document type usage ranking and account section activity profile",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "limit"],
default_limit: 800,
account_scope_mode: "preferred",
query_template: "document_section_profile"
},
{
recipe_id: "address_counterparty_population_roles_v1",
intent: "counterparty_population_and_roles",
purpose: "Build total counterparties and role split (customer/supplier/mixed) from catalog + bank docs",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "limit"],
default_limit: 300,
account_scope_mode: "preferred",
query_template: "counterparty_roles_profile"
},
{
recipe_id: "address_counterparty_activity_lifecycle_v1",
intent: "counterparty_activity_lifecycle",
purpose: "Build active customer list for requested period/all-time using bank inflow docs",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "limit", "sort"],
default_limit: 200,
account_scope_mode: "preferred",
query_template: "counterparty_lifecycle_profile"
},
{
recipe_id: "address_contract_usage_overview_v1",
intent: "contract_usage_overview",
purpose: "Build total-vs-used contract overview from catalog + bank docs",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "limit"],
default_limit: 300,
account_scope_mode: "preferred",
query_template: "contract_usage_profile"
},
{
recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty",
purpose: "List contracts by counterparty from contract catalog",
required_filters: ["counterparty"],
optional_filters: ["limit", "sort"],
default_limit: 300,
account_scope_mode: "preferred",
query_template: "contracts_by_counterparty_profile"
},
{
recipe_id: "address_movements_payables_v1",
intent: "list_payables_counterparties",
@@ -213,6 +513,16 @@ function buildWhereClause(filters: AddressFilterSet, fieldPath: string, extraCon
return "";
}
function buildManagementWhereClause(filters: AddressFilterSet, fieldPath: string): string {
return buildWhereClause(filters, fieldPath);
}
function buildUsedContractWhereClause(filters: AddressFilterSet, fieldPath: string, contractFieldPath: string): string {
return buildWhereClause(filters, fieldPath, [
`${contractFieldPath} <> ЗНАЧЕНИЕ(Справочник.ДоговорыКонтрагентов.ПустаяСсылка)`
]);
}
function normalizeAccountTokenForQuery(value: string): string {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/^(\d{2})(?:\.(\d{1,2}))?/);
@@ -277,6 +587,12 @@ function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): bool
function maxLimitForIntent(intent: AddressIntent): number {
if (
intent === "period_coverage_profile" ||
intent === "document_type_and_account_section_profile" ||
intent === "counterparty_population_and_roles" ||
intent === "counterparty_activity_lifecycle" ||
intent === "contract_usage_overview" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_documents_by_contract" ||
@@ -289,6 +605,10 @@ function maxLimitForIntent(intent: AddressIntent): number {
return ADDRESS_MAX_LIMIT_DEFAULT;
}
function resolveOrderDirection(sort: AddressFilterSet["sort"]): "УБЫВ" | "ВОЗР" {
return sort === "period_asc" ? "ВОЗР" : "УБЫВ";
}
export function selectAddressRecipe(intent: AddressIntent, filters: AddressFilterSet): AddressRecipeSelection {
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
if (!recipe) {
@@ -316,22 +636,30 @@ export function buildAddressRecipePlan(
filters: AddressFilterSet
): AddressRecipeExecutionPlan {
const maxLimit = maxLimitForIntent(recipe.intent);
const isManagementAggregateRecipe =
recipe.query_template === "period_profile" ||
recipe.query_template === "document_section_profile" ||
recipe.query_template === "counterparty_roles_profile" ||
recipe.query_template === "contract_usage_profile";
const baseLimit =
typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
: recipe.default_limit;
const normalizedBaseLimit =
isManagementAggregateRecipe && baseLimit < recipe.default_limit ? recipe.default_limit : baseLimit;
const boostedLimit =
(recipe.intent === "list_documents_by_counterparty" ||
recipe.intent === "bank_operations_by_counterparty" ||
recipe.intent === "list_contracts_by_counterparty" ||
recipe.intent === "list_documents_by_contract" ||
recipe.intent === "bank_operations_by_contract") &&
shouldBoostLimitForAllTimeCounterparty(filters)
? Math.max(baseLimit, maxLimit)
? Math.max(normalizedBaseLimit, maxLimit)
: (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") &&
typeof filters.account === "string" &&
filters.account.trim().length > 0
? Math.max(baseLimit, ADDRESS_MAX_LIMIT_DEFAULT)
: baseLimit;
? Math.max(normalizedBaseLimit, ADDRESS_MAX_LIMIT_DEFAULT)
: normalizedBaseLimit;
const resolvedLimit = Math.max(1, Math.min(maxLimit, boostedLimit));
const accountScope =
@@ -348,14 +676,49 @@ export function buildAddressRecipePlan(
.replaceAll("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replace("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
: MOVEMENTS_QUERY_TEMPLATE.replace("__LIMIT__", String(resolvedLimit)).replace("__WHERE_CLAUSE__", (() => {
const extraConditions: string[] = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})());
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "period_profile"
? PERIOD_COVERAGE_PROFILE_QUERY_TEMPLATE.replaceAll(
"__WHERE_CLAUSE__",
buildManagementWhereClause(filters, "Движения.Период")
)
: recipe.query_template === "document_section_profile"
? DOCUMENT_TYPE_AND_SECTION_PROFILE_QUERY_TEMPLATE.replaceAll(
"__WHERE_CLAUSE__",
buildManagementWhereClause(filters, "Движения.Период")
)
: recipe.query_template === "counterparty_roles_profile"
? COUNTERPARTY_POPULATION_AND_ROLES_QUERY_TEMPLATE
.replaceAll("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replaceAll("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
: recipe.query_template === "counterparty_lifecycle_profile"
? COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE.replaceAll(
"__WHERE_IN__",
buildWhereClause(filters, "БанкПоступление.Дата")
)
: recipe.query_template === "contract_usage_profile"
? CONTRACT_USAGE_OVERVIEW_QUERY_TEMPLATE
.replaceAll(
"__WHERE_OUT_USED__",
buildUsedContractWhereClause(filters, "БанкСписание.Дата", "БанкСписание.ДоговорКонтрагента")
)
.replaceAll(
"__WHERE_IN_USED__",
buildUsedContractWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.ДоговорКонтрагента")
)
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {
const extraConditions: string[] = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})())
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
return {
recipe,
@@ -9,6 +9,37 @@ export interface ComposeStageRow {
analytics: string[];
}
interface ComposeFactualReplyOptions {
userMessage?: string;
}
type PeriodProfileFocus =
| "full_profile"
| "coverage_years"
| "top_year_docs"
| "bottom_year_docs"
| "top_month_ops"
| "bottom_month_ops";
type DocumentSectionProfileFocus =
| "full_profile"
| "doc_types_only"
| "doc_types_rare_only"
| "sections_only"
| "sections_rare_only";
type CounterpartyProfileFocus =
| "full_profile"
| "total_only"
| "roles_only"
| "suppliers_only"
| "customers_only"
| "mixed_only";
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time";
interface YearAggPoint {
year: number;
count: number;
}
function uniqueStrings(values: string[]): string[] {
return Array.from(
new Set(
@@ -29,6 +60,280 @@ function formatTopRows(rows: ComposeStageRow[], limit = 6): string[] {
});
}
function extractYearFromIso(value: string | null): number | null {
const source = String(value ?? "");
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
const year = Number(match[1]);
return Number.isFinite(year) ? year : null;
}
function extractYearMonthFromIso(value: string | null): string | null {
const source = String(value ?? "");
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
return `${match[1]}-${match[2]}`;
}
const ACCOUNT_SECTION_LABELS: Record<string, string> = {
"01": "Основные средства",
"04": "Нематериальные активы",
"08": "Вложения во внеоборотные активы",
"10": "Материалы",
"19": "НДС по приобретенным ценностям",
"20": "Основное производство",
"23": "Вспомогательные производства",
"25": "Общепроизводственные расходы",
"26": "Общехозяйственные расходы",
"41": "Товары",
"43": "Готовая продукция",
"44": "Расходы на продажу",
"50": "Касса",
"51": "Расчетные счета",
"52": "Валютные счета",
"55": "Специальные счета в банках",
"58": "Финансовые вложения",
"60": "Расчеты с поставщиками и подрядчиками",
"62": "Расчеты с покупателями и заказчиками",
"66": "Краткосрочные кредиты и займы",
"67": "Долгосрочные кредиты и займы",
"68": "Расчеты по налогам и сборам",
"69": "Расчеты по социальному страхованию",
"70": "Расчеты с персоналом по оплате труда",
"71": "Расчеты с подотчетными лицами",
"73": "Расчеты с персоналом по прочим операциям",
"75": "Расчеты с учредителями",
"76": "Расчеты с разными дебиторами и кредиторами",
"80": "Уставный капитал",
"81": "Собственные акции (доли)",
"84": "Нераспределенная прибыль (непокрытый убыток)",
"90": "Продажи",
"91": "Прочие доходы и расходы"
};
function formatPercent(value: number, total: number): string | null {
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) {
return null;
}
return `${((value / total) * 100).toFixed(1)}%`;
}
function extractAccountSectionCode(value: string | null): string | null {
const source = String(value ?? "").trim();
if (!source) {
return null;
}
const match = source.match(/(^|[^0-9])(\d{2})(?:[.,]\d{1,2})?/);
if (!match) {
return null;
}
return match[2];
}
function normalizeQuestionText(value: string | null | undefined): string {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/\s+/g, " ")
.trim();
}
function detectPeriodProfileFocus(userMessage: string | null | undefined): PeriodProfileFocus {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "full_profile";
}
const asksYear = /(?:\byear\b|год(?:а|у|ом|е|ы)?)/iu.test(text);
const asksMonth = /(?:\bmonth\b|месяц(?:а|у|ем|е|ы)?)/iu.test(text);
const asksDocs = /(?:\bdocument(?:s)?\b|док(?:умент(?:ы|ов|ам|ами|ах|а)?|и|ов)?)/iu.test(text);
const asksOps = /(?:\bops?\b|\boperation(?:s)?\b|операц)/iu.test(text);
const asksTop = /(?:сам(?:ый|ая|ое)\s+актив|наибол[её]е\s+актив|чаще\s+всего|most\s+active|top)/iu.test(text);
const asksBottom = /(?:сам(?:ый|ая|ое)\s+пассив|наимен[её]е\s+актив|least\s+active|миним(?:ум|альн)|наименьш)/iu.test(
text
);
if (asksYear && asksDocs && asksBottom) {
return "bottom_year_docs";
}
if (asksYear && asksDocs && asksTop) {
return "top_year_docs";
}
if (asksMonth && asksOps && asksBottom) {
return "bottom_month_ops";
}
if (asksMonth && asksOps && asksTop) {
return "top_month_ops";
}
if (/(?:за\s+какие\s+год[а-яё]*|годы?\s+с\s+данными|покрыт(?:ие|ия)\s+период|диапазон\s+лет|профил[ья]\s+данн|year\s+coverage|data\s+coverage)/iu.test(text)) {
return "coverage_years";
}
return "full_profile";
}
function detectDocumentSectionProfileFocus(userMessage: string | null | undefined): DocumentSectionProfileFocus {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "full_profile";
}
const asksDocTypes = /(?:тип[аы]?\s+док|типы?\s+документ|document\s+types?)/iu.test(text);
const asksSections = /(?:раздел[ыа]?\s+уч[её]та|account\s+section)/iu.test(text);
const asksRare = /(?:реже|редк|наимен[её]е|почти\s+не|least|rare|миним(?:ум|альн))/iu.test(text);
const asksTop = /(?:чаще\s+всего|наибол[её]е|most|top|максим)/iu.test(text);
if (asksDocTypes && !asksSections) {
if (asksRare && !asksTop) {
return "doc_types_rare_only";
}
return "doc_types_only";
}
if (asksSections && !asksDocTypes) {
if (asksRare && !asksTop) {
return "sections_rare_only";
}
return "sections_only";
}
return "full_profile";
}
function detectCounterpartyProfileFocus(userMessage: string | null | undefined): CounterpartyProfileFocus {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "full_profile";
}
const asksTotal =
/(?:(?:сколько|скока|скок)\s+(?:всего\s+)?(?:уникальн(?:ых|ые|ого)?\s+)?контрагент(?:ов|а)?(?:\s+в\s+баз[еы])?|total\s+counterpart(?:y|ies))/iu.test(
text
);
const hasSupplierToken = /(?:поставщик(?:ов|а)?|supplier(?:s)?)/iu.test(text);
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) {
return "suppliers_only";
}
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken && !asksTotal) {
return "customers_only";
}
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken && !asksTotal) {
return "mixed_only";
}
if (asksTotal && !asksRoles) {
return "total_only";
}
if (asksRoles && !asksTotal) {
return "roles_only";
}
return "full_profile";
}
function detectCounterpartyLifecycleFocus(userMessage: string | null | undefined): CounterpartyLifecycleFocus {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "active_customers_period";
}
if (/(?:за\s+вс[её]\s+время|all\s+time|за\s+всю\s+истори(?:ю|и))/iu.test(text)) {
return "active_customers_all_time";
}
return "active_customers_period";
}
function extractRequestedYearFromQuestion(userMessage: string | null | undefined): number | null {
const text = normalizeQuestionText(userMessage);
if (!text) {
return null;
}
const fullYearMatch = text.match(/\b(19|20)\d{2}\b/);
if (fullYearMatch) {
const parsed = Number(fullYearMatch[0]);
return Number.isFinite(parsed) ? parsed : null;
}
const shortYearMatch = text.match(/(?:^|[^\d])(\d{2})\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)/iu);
if (!shortYearMatch) {
return null;
}
const shortYear = Number(shortYearMatch[1]);
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
return null;
}
return 2000 + shortYear;
}
function extractCounterpartyName(row: ComposeStageRow): string | null {
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
continue;
}
return normalized;
}
return null;
}
function deriveOperationalYearWindow(
yearDocs: YearAggPoint[],
yearOps: YearAggPoint[]
): {
dataFrom: number | null;
dataTo: number | null;
operationalFrom: number | null;
operationalTo: number | null;
tailYears: number[];
} {
const docsSeries = [...yearDocs].sort((a, b) => a.year - b.year);
const fallbackSeries = [...yearOps].sort((a, b) => a.year - b.year);
const series = docsSeries.length > 0 ? docsSeries : fallbackSeries;
if (series.length === 0) {
return {
dataFrom: null,
dataTo: null,
operationalFrom: null,
operationalTo: null,
tailYears: []
};
}
const dataFrom = series[0]?.year ?? null;
const dataTo = series[series.length - 1]?.year ?? null;
const maxCount = Math.max(...series.map((item) => item.count));
const significantThreshold = Math.max(20, Math.ceil(maxCount * 0.02));
const significantYears = series.filter((item) => item.count >= significantThreshold).map((item) => item.year);
const operationalFrom = significantYears[0] ?? dataFrom;
const operationalTo = significantYears[significantYears.length - 1] ?? dataTo;
const tailYears = series
.filter((item) => operationalTo !== null && item.year > operationalTo)
.map((item) => item.year);
return {
dataFrom,
dataTo,
operationalFrom,
operationalTo,
tailYears
};
}
export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] {
const candidates: string[] = [];
for (const row of rows) {
@@ -47,8 +352,492 @@ export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] {
export function composeFactualReply(
intent: AddressIntent,
rows: ComposeStageRow[]
rows: ComposeStageRow[],
options: ComposeFactualReplyOptions = {}
): { responseType: AddressResponseType; text: string } {
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
if (!rowsByMarker.has(marker)) {
rowsByMarker.set(marker, []);
}
rowsByMarker.get(marker)!.push(row);
}
const docTypeRanking = (rowsByMarker.get("DOC_TYPE_DOCS") ?? [])
.map((row) => ({
docType: String(row.account_dt ?? "").trim(),
count: row.amount ?? 0
}))
.filter((item) => item.docType.length > 0)
.sort((a, b) => b.count - a.count);
const docTypeRankingLow = [...docTypeRanking]
.sort((a, b) => a.count - b.count || a.docType.localeCompare(b.docType))
.slice(0, 10);
const sectionCounter = new Map<string, number>();
for (const marker of ["SECTION_DT_OPS", "SECTION_KT_OPS"]) {
for (const row of rowsByMarker.get(marker) ?? []) {
const sectionCode = extractAccountSectionCode(row.account_dt);
if (!sectionCode) {
continue;
}
const nextValue = (sectionCounter.get(sectionCode) ?? 0) + (row.amount ?? 0);
sectionCounter.set(sectionCode, nextValue);
}
}
const sectionRanking = Array.from(sectionCounter.entries())
.map(([section, count]) => ({ section, count }))
.sort((a, b) => b.count - a.count || a.section.localeCompare(b.section));
const sectionRankingLow = [...sectionRanking]
.sort((a, b) => a.count - b.count || a.section.localeCompare(b.section))
.slice(0, 10);
const docTypeTotal = docTypeRanking.reduce((sum, item) => sum + item.count, 0);
const sectionTotal = sectionRanking.reduce((sum, item) => sum + item.count, 0);
const focus = detectDocumentSectionProfileFocus(options.userMessage);
const includeDocTypes =
focus === "full_profile" || focus === "doc_types_only" || focus === "doc_types_rare_only";
const includeSections =
focus === "full_profile" || focus === "sections_only" || focus === "sections_rare_only";
const includeDocTypesLowOnly = focus === "doc_types_rare_only";
const includeSectionsLowOnly = focus === "sections_rare_only";
const lines: string[] = [
"Профиль типов документов и разделов учета собран (movement-based aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (includeDocTypes) {
if (docTypeRanking.length > 0) {
if (includeDocTypesLowOnly) {
lines.push("Наименее используемые типы документов (по числу уникальных регистраторов):");
lines.push(
...docTypeRankingLow.map((item, index) => {
const share = formatPercent(item.count, docTypeTotal);
return share
? `${index + 1}. ${item.docType}: ${item.count} (${share})`
: `${index + 1}. ${item.docType}: ${item.count}`;
})
);
} else {
lines.push("Топ типов документов (по числу уникальных регистраторов):");
lines.push(
...docTypeRanking.slice(0, 10).map((item, index) => {
const share = formatPercent(item.count, docTypeTotal);
return share
? `${index + 1}. ${item.docType}: ${item.count} (${share})`
: `${index + 1}. ${item.docType}: ${item.count}`;
})
);
}
} else {
lines.push("По типам документов агрегатных строк не найдено.");
}
}
if (includeSections) {
if (sectionRanking.length > 0) {
if (includeSectionsLowOnly) {
lines.push("Наименее заполненные разделы учета (по операциям Дт+Кт):");
lines.push(
...sectionRankingLow.map((item, index) => {
const label = ACCOUNT_SECTION_LABELS[item.section];
const sectionTitle = label ? `${item.section} (${label})` : item.section;
const share = formatPercent(item.count, sectionTotal);
return share
? `${index + 1}. ${sectionTitle}: ${item.count} (${share})`
: `${index + 1}. ${sectionTitle}: ${item.count}`;
})
);
} else {
lines.push("Наиболее заполненные разделы учета (по операциям Дт+Кт):");
lines.push(
...sectionRanking.slice(0, 10).map((item, index) => {
const label = ACCOUNT_SECTION_LABELS[item.section];
const sectionTitle = label ? `${item.section} (${label})` : item.section;
const share = formatPercent(item.count, sectionTotal);
return share
? `${index + 1}. ${sectionTitle}: ${item.count} (${share})`
: `${index + 1}. ${sectionTitle}: ${item.count}`;
})
);
lines.push("Разделы с минимальной активностью (среди использованных):");
lines.push(
...sectionRankingLow.map((item, index) => {
const label = ACCOUNT_SECTION_LABELS[item.section];
const sectionTitle = label ? `${item.section} (${label})` : item.section;
return `${index + 1}. ${sectionTitle}: ${item.count}`;
})
);
}
} else {
lines.push("По разделам учета агрегатных строк не найдено.");
}
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "period_coverage_profile") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
if (!rowsByMarker.has(marker)) {
rowsByMarker.set(marker, []);
}
rowsByMarker.get(marker)!.push(row);
}
const minDate = rowsByMarker.get("MIN_DATE")?.[0]?.period ?? null;
const maxDate = rowsByMarker.get("MAX_DATE")?.[0]?.period ?? null;
const yearOps: YearAggPoint[] = (rowsByMarker.get("YEAR_OPS") ?? [])
.map((row) => ({
year: extractYearFromIso(row.period),
count: row.amount ?? 0
}))
.filter((item): item is YearAggPoint => item.year !== null)
.sort((a, b) => b.count - a.count);
const yearDocs: YearAggPoint[] = (rowsByMarker.get("YEAR_DOCS") ?? [])
.map((row) => ({
year: extractYearFromIso(row.period),
count: row.amount ?? 0
}))
.filter((item): item is YearAggPoint => item.year !== null)
.sort((a, b) => b.count - a.count);
const monthOps = (rowsByMarker.get("MONTH_OPS") ?? [])
.map((row) => ({
month: extractYearMonthFromIso(row.period),
count: row.amount ?? 0
}))
.filter((item): item is { month: string; count: number } => item.month !== null)
.sort((a, b) => b.count - a.count);
const focus = detectPeriodProfileFocus(options.userMessage);
const includeCoverage = focus === "full_profile" || focus === "coverage_years";
const includeTopYear = focus === "full_profile" || focus === "top_year_docs";
const includeBottomYear = focus === "bottom_year_docs";
const includeTopMonth = focus === "full_profile" || focus === "top_month_ops";
const includeBottomMonth = focus === "bottom_month_ops";
const operationalWindow = deriveOperationalYearWindow(yearDocs, yearOps);
const yearsCoverage = (yearOps.length > 0 ? yearOps : yearDocs).map((item) => item.year).sort((a, b) => a - b);
const yearDocsWithinOperational =
operationalWindow.operationalFrom !== null && operationalWindow.operationalTo !== null
? yearDocs.filter(
(item) => item.year >= operationalWindow.operationalFrom! && item.year <= operationalWindow.operationalTo!
)
: yearDocs;
const yearDocsForRanking = yearDocsWithinOperational.length > 0 ? yearDocsWithinOperational : yearDocs;
const yearDocsTop = [...yearDocsForRanking].sort((a, b) => b.count - a.count || a.year - b.year);
const yearDocsBottom = [...yearDocsForRanking].sort((a, b) => a.count - b.count || a.year - b.year);
const monthOpsWithinOperational =
operationalWindow.operationalFrom !== null && operationalWindow.operationalTo !== null
? monthOps.filter((item) => {
const year = Number(item.month.slice(0, 4));
return (
Number.isFinite(year) &&
year >= operationalWindow.operationalFrom! &&
year <= operationalWindow.operationalTo!
);
})
: monthOps;
const monthOpsForRanking = monthOpsWithinOperational.length > 0 ? monthOpsWithinOperational : monthOps;
const monthOpsTop = [...monthOpsForRanking].sort((a, b) => b.count - a.count || a.month.localeCompare(b.month));
const monthOpsBottom = [...monthOpsForRanking].sort((a, b) => a.count - b.count || a.month.localeCompare(b.month));
const topYearByDocs = yearDocsTop[0] ?? null;
const bottomYearByDocs = yearDocsBottom[0] ?? null;
const topMonthByOps = monthOpsTop[0] ?? null;
const bottomMonthByOps = monthOpsBottom[0] ?? null;
const hasTailYears =
operationalWindow.tailYears.length > 0 &&
operationalWindow.operationalTo !== null &&
operationalWindow.dataTo !== null &&
operationalWindow.operationalTo < operationalWindow.dataTo;
const lines: string[] = [
"Профиль периодов базы собран (movement-based aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (includeCoverage) {
if (
hasTailYears &&
operationalWindow.operationalFrom !== null &&
operationalWindow.operationalTo !== null
) {
lines.push(
`Операционный период с выраженной активностью: ${operationalWindow.operationalFrom}..${operationalWindow.operationalTo}.`
);
lines.push(`Низкоактивный хвост (единичные записи): ${operationalWindow.tailYears.join(", ")}.`);
lines.push(`Полный технический диапазон дат: ${minDate ?? "н/д"} .. ${maxDate ?? "н/д"}.`);
} else {
lines.push(`Покрытие по датам: ${minDate ?? "н/д"} .. ${maxDate ?? "н/д"}.`);
if (yearsCoverage.length > 0) {
lines.push(
`Годы с данными: ${yearsCoverage[0]}..${yearsCoverage[yearsCoverage.length - 1]} (уникальных: ${yearsCoverage.length}).`
);
}
}
}
if (includeTopYear && topYearByDocs) {
lines.push(`Самый активный год по документам: ${topYearByDocs.year} (${topYearByDocs.count}).`);
lines.push(
...yearDocsTop
.slice(0, 5)
.map((item, index) => `${index + 1}. ${item.year}: ${item.count}`)
);
}
if (includeBottomYear && bottomYearByDocs) {
lines.push(`Самый пассивный год по документам: ${bottomYearByDocs.year} (${bottomYearByDocs.count}).`);
lines.push(
...yearDocsBottom
.slice(0, 5)
.map((item, index) => `${index + 1}. ${item.year}: ${item.count}`)
);
}
if (includeTopMonth && topMonthByOps) {
lines.push(`Самый активный месяц по операциям: ${topMonthByOps.month} (${topMonthByOps.count}).`);
lines.push(
...monthOpsTop
.slice(0, 5)
.map((item, index) => `${index + 1}. ${item.month}: ${item.count}`)
);
}
if (includeBottomMonth && bottomMonthByOps) {
lines.push(`Самый пассивный месяц по операциям: ${bottomMonthByOps.month} (${bottomMonthByOps.count}).`);
lines.push(
...monthOpsBottom
.slice(0, 5)
.map((item, index) => `${index + 1}. ${item.month}: ${item.count}`)
);
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "counterparty_population_and_roles") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
if (!rowsByMarker.has(marker)) {
rowsByMarker.set(marker, []);
}
rowsByMarker.get(marker)!.push(row);
}
const sumMarker = (marker: string): number =>
(rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
const totalCounterparties = sumMarker("CP_TOTAL");
const customerActive = sumMarker("CP_CUSTOMER_ACTIVE");
const supplierActive = sumMarker("CP_SUPPLIER_ACTIVE");
const mixedActive = sumMarker("CP_MIXED_ACTIVE");
const activeUnion = sumMarker("CP_ACTIVE_UNION");
const customerOnly = Math.max(0, customerActive - mixedActive);
const supplierOnly = Math.max(0, supplierActive - mixedActive);
const resolvedActive = customerOnly + supplierOnly + mixedActive;
const activeCounterparties = Math.max(activeUnion, resolvedActive);
const otherCounterparties = totalCounterparties > 0 ? Math.max(0, totalCounterparties - resolvedActive) : null;
const focus = detectCounterpartyProfileFocus(options.userMessage);
const includeTotal = focus === "full_profile" || focus === "total_only";
const includeRoles = focus === "full_profile" || focus === "roles_only";
const lines: string[] = [
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (includeTotal) {
if (totalCounterparties > 0) {
lines.push(`Всего уникальных контрагентов в базе: ${totalCounterparties}.`);
} else if (activeCounterparties > 0) {
lines.push(
`Total из справочника не получен, оценка по активности в документах: ${activeCounterparties} контрагентов.`
);
} else {
lines.push("По количеству контрагентов агрегатных строк не найдено.");
}
}
if (includeRoles) {
if (resolvedActive > 0 || activeCounterparties > 0) {
lines.push("Роли контрагентов по активности:");
lines.push(`1. Заказчики (только customer-роль): ${customerOnly}.`);
lines.push(`2. Поставщики (только supplier-роль): ${supplierOnly}.`);
lines.push(`3. Смешанные (и покупатель, и поставщик): ${mixedActive}.`);
lines.push(`4. Активные контрагенты (union ролей): ${activeCounterparties}.`);
if (otherCounterparties !== null) {
lines.push(`5. Прочие/неактивные в выбранном окне: ${otherCounterparties}.`);
}
} else {
lines.push("По role-split контрагентов агрегатных строк не найдено.");
}
}
if (focus === "suppliers_only") {
lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`);
}
if (focus === "customers_only") {
lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`);
}
if (focus === "mixed_only") {
lines.push(`Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`);
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "counterparty_activity_lifecycle") {
const activityRows = rows.filter(
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY"
);
const byCounterparty = new Map<string, { name: string; opsCount: number; lastPeriod: string | null }>();
for (const row of activityRows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, { name, opsCount, lastPeriod: row.period });
continue;
}
if (opsCount > current.opsCount) {
current.opsCount = opsCount;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
}
const counterparties = Array.from(byCounterparty.values()).sort((left, right) => {
if (right.opsCount !== left.opsCount) {
return right.opsCount - left.opsCount;
}
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
});
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
const scopeLabel =
focus === "active_customers_all_time"
? "за все время"
: requestedYear
? `в ${requestedYear} году`
: "в выбранном периоде";
const lines: string[] = [
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (counterparties.length === 0) {
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}` : "";
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}`;
})
);
if (counterparties.length > visible.length) {
lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "contract_usage_overview") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
if (!rowsByMarker.has(marker)) {
rowsByMarker.set(marker, []);
}
rowsByMarker.get(marker)!.push(row);
}
const sumMarker = (marker: string): number =>
(rowsByMarker.get(marker) ?? []).reduce((sum, row) => sum + (row.amount ?? 0), 0);
const totalContracts = sumMarker("CT_TOTAL");
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 lines: string[] = [
"Профиль договорной базы собран (catalog + usage aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (totalContracts > 0) {
lines.push(`Всего договоров в базе: ${totalContracts}.`);
} else {
lines.push("Общее количество договоров не получено (пустой/недоступный срез справочника).");
}
lines.push(`Использованных договоров (есть factual связь с операциями): ${usedContracts}.`);
if (unusedContracts !== null) {
lines.push(`Неиспользуемых договоров: ${unusedContracts}.`);
}
if (usedShare) {
lines.push(`Доля используемых договоров: ${usedShare}.`);
}
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 = [
@@ -109,6 +898,47 @@ export function composeFactualReply(
};
}
if (intent === "list_contracts_by_counterparty") {
const contracts = uniqueStrings(
rows
.map((row) => String(row.registrator ?? "").trim())
.filter((item) => item.length > 0)
);
const counterparties = uniqueStrings(
rows
.flatMap((row) => row.analytics)
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0)
);
const lines: string[] = [
"Собран список договоров по контрагенту (catalog address lane).",
`Строк отобрано: ${rows.length}.`,
`Уникальных договоров: ${contracts.length}.`
];
if (counterparties.length === 1) {
lines.push(`Контрагент: ${counterparties[0]}.`);
} else if (counterparties.length > 1) {
lines.push(`Контрагенты в выборке: ${counterparties.length}.`);
}
if (contracts.length > 0) {
const visible = contracts.slice(0, 120);
lines.push(...visible.map((item, index) => `${index + 1}. ${item}`));
if (contracts.length > visible.length) {
lines.push(`Показаны первые ${visible.length} из ${contracts.length} договоров.`);
}
} else {
lines.push("Договоры по указанному якорю в текущем live-срезе не найдены.");
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
@@ -194,7 +194,11 @@ function mergeFollowupFilters(
const allTimeRequested = hasAllTimeHint(userMessage);
const sameDateRequested = hasSameDateHint(userMessage);
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty") {
if (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty"
) {
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty =
previousCounterparty ??
@@ -282,6 +286,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
documents_forming_balance: ["account", "as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"],
list_documents_by_contract: ["contract"],
bank_operations_by_contract: ["contract"]
};
@@ -0,0 +1,165 @@
import type { AddressFilterSet, AddressIntent, AddressQuestionMode, AddressQueryShape } from "../../types/addressQuery";
import { detectAddressQuestionMode } from "../addressQueryClassifier";
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
import { resolveAddressIntent } from "../addressIntentResolver";
import { extractAddressFilters } from "../addressFilterExtractor";
export type AddressPredecomposePeriodScope = "all_time" | "year" | "range" | "as_of" | "unspecified";
export type AddressPredecomposeAggregationProfile =
| "management_profile"
| "list_lookup"
| "balance_snapshot"
| "open_items"
| "unknown";
export interface AddressLlmPredecomposeContractV1 {
schema_version: "address_llm_predecompose_contract_v1";
source_message: string;
canonical_message: string;
mode: AddressQuestionMode;
mode_confidence: "high" | "medium" | "low";
query_shape: AddressQueryShape;
query_shape_confidence: "high" | "medium" | "low";
intent: AddressIntent;
intent_confidence: "high" | "medium" | "low";
entities: {
account: string | null;
counterparty: string | null;
contract: string | null;
document_type: string | null;
document_ref: string | null;
organization: string | null;
};
period: {
scope: AddressPredecomposePeriodScope;
period_from: string | null;
period_to: string | null;
as_of_date: string | null;
has_explicit_period: boolean;
};
aggregation_profile: AddressPredecomposeAggregationProfile;
}
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
}
function hasAllTimeHint(text: string): boolean {
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|for\s+all\s+time|all\s+time|entire\s+period|full\s+history)/iu.test(
String(text ?? "")
);
}
function inferPeriodScope(filters: AddressFilterSet, canonicalMessage: string): AddressPredecomposePeriodScope {
const asOfDate = toNonEmptyString(filters.as_of_date);
const periodFrom = toNonEmptyString(filters.period_from);
const periodTo = toNonEmptyString(filters.period_to);
if (asOfDate) {
return "as_of";
}
if (periodFrom && periodTo) {
const yearFrom = periodFrom.match(/^(\d{4})-01-01$/);
const yearTo = periodTo.match(/^(\d{4})-12-31$/);
if (yearFrom && yearTo && yearFrom[1] === yearTo[1]) {
return "year";
}
return "range";
}
if (hasAllTimeHint(canonicalMessage)) {
return "all_time";
}
return "unspecified";
}
function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape): AddressPredecomposeAggregationProfile {
if (
intent === "period_coverage_profile" ||
intent === "document_type_and_account_section_profile" ||
intent === "counterparty_population_and_roles" ||
intent === "counterparty_activity_lifecycle" ||
intent === "contract_usage_overview"
) {
return "management_profile";
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return "balance_snapshot";
}
if (
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "list_payables_counterparties" ||
intent === "list_receivables_counterparties"
) {
return "open_items";
}
if (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract"
) {
return "list_lookup";
}
if (shape === "AGGREGATE_LOOKUP") {
return "management_profile";
}
if (shape === "DOCUMENT_LIST" || shape === "OBJECT_LOOKUP" || shape === "DRILLDOWN_REQUEST") {
return "list_lookup";
}
return "unknown";
}
export function buildAddressLlmPredecomposeContractV1(input: {
sourceMessage: string;
canonicalMessage: string;
}): AddressLlmPredecomposeContractV1 {
const sourceMessage = String(input.sourceMessage ?? "").trim();
const canonicalMessage = String(input.canonicalMessage ?? "").trim() || sourceMessage;
const mode = detectAddressQuestionMode(canonicalMessage);
const shape = classifyAddressQueryShape(canonicalMessage);
const intent = resolveAddressIntent(canonicalMessage);
const extraction = extractAddressFilters(canonicalMessage, intent.intent);
const filters = extraction.extracted_filters;
const periodScope = inferPeriodScope(filters, canonicalMessage);
return {
schema_version: "address_llm_predecompose_contract_v1",
source_message: sourceMessage,
canonical_message: canonicalMessage,
mode: mode.mode,
mode_confidence: mode.confidence,
query_shape: shape.shape,
query_shape_confidence: shape.confidence,
intent: intent.intent,
intent_confidence: intent.confidence,
entities: {
account: toNonEmptyString(filters.account),
counterparty: toNonEmptyString(filters.counterparty),
contract: toNonEmptyString(filters.contract),
document_type: toNonEmptyString(filters.document_type),
document_ref: toNonEmptyString(filters.document_ref),
organization: toNonEmptyString(filters.organization)
},
period: {
scope: periodScope,
period_from: toNonEmptyString(filters.period_from),
period_to: toNonEmptyString(filters.period_to),
as_of_date: toNonEmptyString(filters.as_of_date),
has_explicit_period: Boolean(
toNonEmptyString(filters.as_of_date) || toNonEmptyString(filters.period_from) || toNonEmptyString(filters.period_to)
)
},
aggregation_profile: inferAggregationProfile(intent.intent, shape.shape)
};
}
@@ -89,6 +89,28 @@ function tokenizeAnchor(value: string): string[] {
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function anchorTokenVariants(token: string): string[] {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
return [];
}
const variants = new Set<string>([source]);
if (/^[а-яё]+$/iu.test(source) && source.length >= 4) {
const withoutEnding = source.replace(
/(?:ами|ями|ого|ему|ому|ыми|ими|иях|ях|ах|ей|ой|ом|ем|ам|ям|ую|юю|ая|яя|ое|ее|ые|ие|ов|ев|ий|ый|ой|е|у|ы|а|я|и|ю)$/iu,
""
);
if (withoutEnding.length >= 3) {
variants.add(withoutEnding);
}
const withoutTrailingVowel = source.replace(/[аеёиоуыэюя]$/iu, "");
if (withoutTrailingVowel.length >= 3) {
variants.add(withoutTrailingVowel);
}
}
return Array.from(variants);
}
function matchesAnchorText(searchable: string, anchor: string): boolean {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
@@ -101,8 +123,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
const variants = anchorTokenVariants(token);
return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin);
});
});
}
@@ -146,6 +171,18 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt
}
}
if (intent === "list_contracts_by_counterparty") {
if (counterparty) {
return {
anchor_type: "counterparty",
anchor_value_raw: counterparty,
anchor_value_resolved: counterparty,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
}
if (counterparty) {
return {
anchor_type: "counterparty",
@@ -15,6 +15,7 @@ import * as assistantClaimBoundEvidence_1 from "./assistantClaimBoundEvidence";
import * as addressQueryService_1 from "./addressQueryService";
import * as addressQueryClassifier_1 from "./addressQueryClassifier";
import * as addressIntentResolver_1 from "./addressIntentResolver";
import * as predecomposeContract_1 from "./address_runtime/predecomposeContract";
import iconv from "iconv-lite";
function retrievalSummaryForRoute(route) {
if (route === "store_canonical")
@@ -1763,6 +1764,8 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
llm_decomposition_trace_id: llmMeta?.traceId ?? null,
llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null,
llm_decomposition_reason: llmMeta?.reason ?? null,
llm_canonical_candidate_detected: Boolean(llmMeta?.llmCanonicalCandidateDetected),
llm_predecompose_contract: llmMeta?.predecomposeContract ?? null,
fallback_rule_hit: llmMeta?.fallbackRuleHit ?? null,
sanitized_user_message: llmMeta?.sanitizedUserMessage ?? null,
tool_gate_decision: llmMeta?.toolGateDecision ?? null,
@@ -1835,6 +1838,16 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"которые",
"какие",
"какой",
"активный",
"активная",
"активное",
"активности",
"месяц",
"месяца",
"месяцев",
"количество",
"количеству",
"количества",
"были",
"был",
"была",
@@ -1931,6 +1944,8 @@ const ADDRESS_BANK_SIGNAL_PATTERN = /(?:bank|банк|банков|выписк|
const ADDRESS_CONTRACT_SIGNAL_PATTERN = /(?:договор(?:а|у|ом|е)?|(?:^|[^\p{L}\p{N}_])(?:дог\.?|[dд][oо][gг]\.?|dog\.?)(?=$|[^\p{L}\p{N}_])|contract|dogovor)/iu;
const ADDRESS_BALANCE_SIGNAL_PATTERN = /(?:остат|сальдо|баланс|взаиморасч|долг|saldo|balance)/i;
const ADDRESS_ALL_TIME_PATTERN = /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|for\s+all\s+time|all\s+time|entire\s+period|full\s+history)/iu;
const ADDRESS_MANAGEMENT_PROFILE_PATTERN =
/(?:за\s+какие\s+год[а-яё]*|сам(?:ый|ая|ое)\s+(?:актив|пассив)|наименее\s+актив|минимальн|покрыт(?:ие|ия)\s+период|диапазон\s+лет|тип[аы]\s+док(?:умент|ов|и)?|раздел[ыа]\s+уч[её]та|по\s+количеств[аоуе]|редк|реже|(?:сколько|скока|скок)\s+(?:всего\s+)?(?:уникальн(?:ых|ые|ого)?\s+)?контрагент(?:ов|а)?|(?:сколько|скока|скок)\s+(?:у\s+нас\s+)?(?:заказчик(?:ов|а)?|поставщик(?:ов|а)?|клиент(?:ов|а)?|покупател(?:ей|я)|смешан(?:ных|ые)\s+контрагент(?:ов|а)?)|(?:покажи|выведи|список|какие|кто).*(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?).*(?:за\s+вс[её]\s+время|all\s+time|(?:^|[^\d])(19|20)\d{2}(?:[^\d]|$)|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|за\s+год|в\s+году)|(?:какие|кто|покажи|выведи|список).*(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?).*(?:работал(?:и)?|активн(?:ые|ых|а|о)?).*(?:за\s+вс[её]\s+время|(?:19|20)\d{2}|за\s+год|в\s+году)|договорн(?:ая|ой)\s+баз[аы]|total\s+vs\s+used)/iu;
function normalizeAddressMonthAliasToken(token) {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
@@ -2136,6 +2151,9 @@ function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage)
if (!source) {
return null;
}
if (ADDRESS_MANAGEMENT_PROFILE_PATTERN.test(source)) {
return null;
}
const monthYear = extractAddressFallbackMonthYear(source);
const year = extractAddressFallbackYear(source);
const allTime = ADDRESS_ALL_TIME_PATTERN.test(source);
@@ -2383,6 +2401,14 @@ function hasAddressFollowupContextSignal(userMessage) {
if (shortFollowup && hasFollowupMarker(text)) {
return true;
}
if (shortFollowup && /(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu.test(text)) {
return true;
}
if (shortFollowup &&
/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu.test(text) &&
!/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu.test(text)) {
return true;
}
if (shortFollowup && hasPeriodLiteral(text)) {
return true;
}
@@ -2448,7 +2474,6 @@ function extractAddressQuestionFromNormalized(normalized) {
if (domainRelevance === "out_of_scope") {
continue;
}
const readiness = String(fragment.execution_readiness ?? "").trim().toLowerCase();
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
const rawText = toNonEmptyString(fragment.raw_fragment_text);
const candidate = selectPreferredAddressFragmentCandidate(rawText ?? "", normalizedText ?? "");
@@ -2456,9 +2481,6 @@ function extractAddressQuestionFromNormalized(normalized) {
continue;
}
if (candidate.length >= 3 && candidate.length <= 500) {
if (readiness === "no_route" && !isAddressLlmPreDecomposeCandidate(candidate)) {
continue;
}
return candidate;
}
}
@@ -2564,7 +2586,6 @@ function extractAddressQuestionFromRawNormalizerOutput(rawModelOutput) {
if (domainRelevance === false) {
continue;
}
const readiness = String(fragment.execution_readiness ?? "").trim().toLowerCase();
const normalizedText = toNonEmptyString(fragment.normalized_fragment_text);
const rawText = toNonEmptyString(fragment.raw_fragment_text);
const candidate = selectPreferredAddressFragmentCandidate(rawText ?? "", normalizedText ?? "");
@@ -2572,19 +2593,25 @@ function extractAddressQuestionFromRawNormalizerOutput(rawModelOutput) {
continue;
}
if (candidate.length >= 3 && candidate.length <= 500) {
if (readiness === "no_route" && !isAddressLlmPreDecomposeCandidate(candidate)) {
continue;
}
return candidate;
}
}
return null;
}
function attachAddressPredecomposeContract(meta, sourceMessage) {
const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? "");
return {
...meta,
predecomposeContract: (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({
sourceMessage: String(sourceMessage ?? ""),
canonicalMessage
})
};
}
async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) {
const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null;
const sanitizedUserMessage = sanitizeAddressMessageForFallback(userMessage);
const fallbackCandidate = resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage);
const hasAddressSignal = isAddressLlmPreDecomposeCandidate(userMessage) || isAddressLlmPreDecomposeCandidate(sanitizedUserMessage);
const baseMeta = {
attempted: false,
applied: false,
@@ -2592,6 +2619,7 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
traceId: null,
effectiveMessage: userMessage,
reason: "not_attempted",
llmCanonicalCandidateDetected: false,
fallbackRuleHit: null,
sanitizedUserMessage,
toolGateDecision: null,
@@ -2603,39 +2631,19 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const fallbackApplied = fallbackCompact.length > 0 && fallbackCompact !== sourceCompact;
if (fallbackApplied) {
return {
return attachAddressPredecomposeContract({
...baseMeta,
applied: true,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_without_llm",
fallbackRuleHit: fallbackCandidate.rule
};
}, userMessage);
}
}
return {
return attachAddressPredecomposeContract({
...baseMeta,
reason: "skipped_in_mock"
};
}
if (!hasAddressSignal) {
if (fallbackCandidate) {
const fallbackCompact = compactWhitespace(String(fallbackCandidate.candidate ?? "").toLowerCase());
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const fallbackApplied = fallbackCompact.length > 0 && fallbackCompact !== sourceCompact;
if (fallbackApplied) {
return {
...baseMeta,
applied: true,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_without_llm",
fallbackRuleHit: fallbackCandidate.rule
};
}
}
return {
...baseMeta,
reason: "not_address_like"
};
}, userMessage);
}
const normalizePayload = {
llmProvider: payload?.llmProvider,
@@ -2661,7 +2669,7 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const fallbackApplied = fallbackCompact.length > 0 && fallbackCompact !== sourceCompact;
if (fallbackApplied) {
return {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: true,
@@ -2669,15 +2677,15 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_after_llm",
fallbackRuleHit: fallbackCandidate.rule
};
}, userMessage);
}
}
return {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
traceId: normalized?.trace_id ?? null,
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
};
}, userMessage);
}
const repairedSourceMessage = repairAddressMojibake(userMessage);
const sourceIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(repairedSourceMessage || userMessage);
@@ -2692,18 +2700,19 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
(intentConflict &&
(sourceIntentResolution.confidence === "high" || candidateIntentResolution.confidence !== "high"));
if (rejectCandidateForIntentSafety) {
return {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: intentDroppedByCandidate
? "normalized_fragment_rejected_intent_drop"
: "normalized_fragment_rejected_intent_conflict",
fallbackRuleHit: null,
sanitizedUserMessage
};
}, userMessage);
}
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const candidateCompact = compactWhitespace(candidate.toLowerCase());
@@ -2720,16 +2729,17 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
: applied
? "raw_fragment_applied_after_normalize_failed"
: "raw_fragment_same_after_normalize_failed";
return {
return attachAddressPredecomposeContract({
attempted: true,
applied,
provider,
traceId: normalized?.trace_id ?? null,
effectiveMessage: applied ? candidate : userMessage,
reason,
llmCanonicalCandidateDetected: true,
fallbackRuleHit: null,
sanitizedUserMessage
};
}, userMessage);
}
catch (error) {
if (fallbackCandidate) {
@@ -2737,28 +2747,30 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const fallbackApplied = fallbackCompact.length > 0 && fallbackCompact !== sourceCompact;
if (fallbackApplied) {
return {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: true,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_after_llm_error",
fallbackRuleHit: fallbackCandidate.rule
};
}, userMessage);
}
}
return {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
reason: `error:${error instanceof Error ? error.message : String(error)}`
};
}, userMessage);
}
}
function resolveAddressToolGateDecision(addressInputMessage, followupContext) {
function resolveAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null) {
const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? ""));
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage);
const hasClassifierSignal = modeDetection.mode === "address_query";
const hasLlmCanonicalSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected);
const hasMessageSignal = hasClassifierSignal ||
hasLlmCanonicalSignal ||
isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
hasAccountingSignal(addressInputMessage) ||
@@ -2767,7 +2779,11 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext) {
return {
runAddressLane: true,
decision: "run_address_lane",
reason: hasClassifierSignal ? "address_mode_classifier_detected" : "address_signal_detected"
reason: hasClassifierSignal
? "address_mode_classifier_detected"
: hasLlmCanonicalSignal
? "llm_canonical_candidate_detected"
: "address_signal_detected"
};
}
if (followupContext) {
@@ -2857,6 +2873,9 @@ export class AssistantService {
address_sanitized_user_message: llmPreDecomposeMeta?.sanitizedUserMessage ?? null,
address_tool_gate_decision: llmPreDecomposeMeta?.toolGateDecision ?? null,
address_tool_gate_reason: llmPreDecomposeMeta?.toolGateReason ?? null,
address_llm_predecompose_contract_intent: llmPreDecomposeMeta?.predecomposeContract?.intent ?? null,
address_llm_predecompose_contract_aggregation_profile: llmPreDecomposeMeta?.predecomposeContract?.aggregation_profile ?? null,
address_llm_predecompose_contract_period_scope: llmPreDecomposeMeta?.predecomposeContract?.period?.scope ?? null,
detected_mode: addressLane.debug.detected_mode,
query_shape: addressLane.debug.query_shape,
detected_intent: addressLane.debug.detected_intent,
@@ -2901,6 +2920,7 @@ export class AssistantService {
conversation
};
};
let addressRuntimeMetaForDeep = null;
if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) {
const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1
? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage)
@@ -2911,6 +2931,11 @@ export class AssistantService {
traceId: null,
effectiveMessage: userMessage,
reason: "disabled_by_feature_flag",
llmCanonicalCandidateDetected: false,
predecomposeContract: (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({
sourceMessage: userMessage,
canonicalMessage: userMessage
}),
fallbackRuleHit: null,
sanitizedUserMessage: sanitizeAddressMessageForFallback(userMessage),
toolGateDecision: null,
@@ -2918,12 +2943,13 @@ export class AssistantService {
};
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items, addressInputMessage);
const toolGate = resolveAddressToolGateDecision(addressInputMessage, carryover?.followupContext ?? null);
const toolGate = resolveAddressToolGateDecision(addressInputMessage, carryover?.followupContext ?? null, addressPreDecompose);
const addressRuntimeMeta = {
...addressPreDecompose,
toolGateDecision: toolGate.decision,
toolGateReason: toolGate.reason
};
addressRuntimeMetaForDeep = addressRuntimeMeta;
if (!toolGate.runAddressLane) {
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
@@ -2941,7 +2967,10 @@ export class AssistantService {
address_fallback_rule_hit: addressRuntimeMeta?.fallbackRuleHit ?? null,
address_sanitized_user_message: addressRuntimeMeta?.sanitizedUserMessage ?? null,
address_tool_gate_decision: addressRuntimeMeta?.toolGateDecision ?? null,
address_tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null
address_tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null,
address_llm_predecompose_contract_intent: addressRuntimeMeta?.predecomposeContract?.intent ?? null,
address_llm_predecompose_contract_aggregation_profile: addressRuntimeMeta?.predecomposeContract?.aggregation_profile ?? null,
address_llm_predecompose_contract_period_scope: addressRuntimeMeta?.predecomposeContract?.period?.scope ?? null
}
});
}
@@ -3273,6 +3302,14 @@ export class AssistantService {
problem_unit_ids_used: composition.problem_unit_ids_used
}
: {}),
address_llm_predecompose_attempted: Boolean(addressRuntimeMetaForDeep?.attempted),
address_llm_predecompose_applied: Boolean(addressRuntimeMetaForDeep?.applied),
address_llm_predecompose_reason: addressRuntimeMetaForDeep?.reason ?? null,
address_llm_predecompose_provider: addressRuntimeMetaForDeep?.provider ?? null,
address_fallback_rule_hit: addressRuntimeMetaForDeep?.fallbackRuleHit ?? null,
address_tool_gate_decision: addressRuntimeMetaForDeep?.toolGateDecision ?? null,
address_tool_gate_reason: addressRuntimeMetaForDeep?.toolGateReason ?? null,
address_llm_predecompose_contract: addressRuntimeMetaForDeep?.predecomposeContract ?? null,
answer_structure_v11: answerStructureV11,
investigation_state_snapshot: investigationStateSnapshot,
normalized: normalized.normalized
@@ -1,6 +1,12 @@
export type AddressQuestionMode = "address_query" | "deep_analysis" | "unsupported";
export type AddressIntent =
| "period_coverage_profile"
| "document_type_and_account_section_profile"
| "counterparty_population_and_roles"
| "counterparty_activity_lifecycle"
| "contract_usage_overview"
| "list_contracts_by_counterparty"
| "list_open_contracts"
| "list_payables_counterparties"
| "list_receivables_counterparties"
@@ -99,7 +105,15 @@ export interface AddressRecipeDefinition {
recipe_id: string;
intent: Exclude<AddressIntent, "unknown">;
purpose: string;
query_template?: "movements" | "bank_docs";
query_template?:
| "movements"
| "bank_docs"
| "period_profile"
| "document_section_profile"
| "counterparty_roles_profile"
| "counterparty_lifecycle_profile"
| "contract_usage_profile"
| "contracts_by_counterparty_profile";
required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>;
default_limit: number;