АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 1 - ЛЛМ ФЕРСТ + feat(address): стабилизация wave1 dynamic resolver контрагентов, follow-up carryover и актуализация docs/tests
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user