АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 1 - ЛЛМ ФЕРСТ + feat(address): стабилизация wave1 dynamic resolver контрагентов, follow-up carryover и актуализация docs/tests
This commit is contained in:
+70
-10
@@ -18,6 +18,7 @@ const YEAR_RANGE_LOOSE_PATTERN = /\b(20\d{2})\b\s*(?:[-‐‑‒–—―−]|д
|
||||
const YEAR_PERIOD_PATTERN = /(?:за|for)\s*(20\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*20\d{2})\s*(?:г(?:од|ода)?\.?|year|god)?/iu;
|
||||
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 = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NUMERIC_YEAR_MONTH_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})[.\/-](0?[1-9]|1[0-2])(?=$|[\s,.;:!?()\-])/iu;
|
||||
@@ -261,6 +262,17 @@ function extractYearPeriod(text) {
|
||||
};
|
||||
}
|
||||
}
|
||||
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 {};
|
||||
}
|
||||
function extractYearRangePeriod(text) {
|
||||
@@ -343,7 +355,7 @@ function hasAllTimeHint(text) {
|
||||
return /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+весь\s+срок|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|за\s+любой\s+срок|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)/iu.test(value);
|
||||
}
|
||||
function extractLooseByAnchorValue(text) {
|
||||
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;
|
||||
}
|
||||
@@ -376,6 +388,7 @@ function extractLooseByAnchorValue(text) {
|
||||
"периоду",
|
||||
"период",
|
||||
"документам",
|
||||
"документами",
|
||||
"докам",
|
||||
"взаиморасчетам",
|
||||
"взаиморасчётам",
|
||||
@@ -387,6 +400,13 @@ function extractLooseByAnchorValue(text) {
|
||||
"раскрой",
|
||||
"раскрыть",
|
||||
"раскройте",
|
||||
"связанный",
|
||||
"связанные",
|
||||
"связанных",
|
||||
"связанным",
|
||||
"связанному",
|
||||
"related",
|
||||
"linked",
|
||||
"нему",
|
||||
"ней",
|
||||
"ним",
|
||||
@@ -444,6 +464,13 @@ function isLikelyCounterpartyToken(rawToken) {
|
||||
"документ",
|
||||
"документы",
|
||||
"документов",
|
||||
"документами",
|
||||
"документу",
|
||||
"документе",
|
||||
"документа",
|
||||
"документах",
|
||||
"докам",
|
||||
"доками",
|
||||
"банк",
|
||||
"банковские",
|
||||
"операции",
|
||||
@@ -506,7 +533,14 @@ function isLikelyCounterpartyToken(rawToken) {
|
||||
"dokument",
|
||||
"dokumenty",
|
||||
"documents",
|
||||
"docs"
|
||||
"docs",
|
||||
"связанный",
|
||||
"связанные",
|
||||
"связанных",
|
||||
"связанным",
|
||||
"связанному",
|
||||
"related",
|
||||
"linked"
|
||||
]);
|
||||
return !stopWords.has(lowered);
|
||||
}
|
||||
@@ -670,7 +704,9 @@ function requiredFiltersByIntent(intent) {
|
||||
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") {
|
||||
@@ -684,10 +720,17 @@ function usesAsOfPrimaryWindow(intent) {
|
||||
function extractAddressFilters(userMessage, intent) {
|
||||
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 = {
|
||||
sort: "period_desc",
|
||||
limit: 20
|
||||
sort: "period_desc"
|
||||
};
|
||||
if (!isManagementProfileIntent) {
|
||||
filters.limit = 20;
|
||||
}
|
||||
const warnings = [];
|
||||
const accountMatch = text.match(ACCOUNT_PATTERN);
|
||||
if (accountMatch) {
|
||||
@@ -711,35 +754,48 @@ function extractAddressFilters(userMessage, intent) {
|
||||
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);
|
||||
warnings.push("counterparty_anchor_derived_from_leading_token");
|
||||
}
|
||||
}
|
||||
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]));
|
||||
}
|
||||
@@ -781,6 +837,10 @@ function extractAddressFilters(userMessage, intent) {
|
||||
warnings.push("period_derived_from_year_phrase");
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
+344
-4
@@ -154,12 +154,123 @@ const BANK_OPERATION_CORE_HINTS = [
|
||||
"statement",
|
||||
"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, patterns) {
|
||||
return patterns.some((item) => text.includes(item));
|
||||
}
|
||||
function hasCompactAccountCodeToken(text) {
|
||||
// Match compact account tokens like 60.01 / 62, while avoiding date fragments.
|
||||
return /(?<![\d-])\d{2}(?:[.,]\d{1,2})(?![\d-])/u.test(text);
|
||||
return /(?<![\d-])\d{2}(?:[.,]\d{1,2})?(?![\d-])/u.test(text);
|
||||
}
|
||||
function hasDocumentsFormingBalanceSignal(text) {
|
||||
if (hasAny(text, DOCUMENTS_FORMING_BALANCE_HINTS)) {
|
||||
@@ -204,7 +315,139 @@ function hasAccountBalanceSignal(text) {
|
||||
text.includes("скока") ||
|
||||
text.includes("сколько") ||
|
||||
/на\s+конец/u.test(text);
|
||||
return hasAccountLexeme && hasBalanceLexeme;
|
||||
if (hasAccountLexeme && hasBalanceLexeme) {
|
||||
return true;
|
||||
}
|
||||
const hasAsOfStyleDate = /\b(19|20)\d{2}[./-](0?[1-9]|1[0-2])(?:[./-](0?[1-9]|[12]\d|3[01]))\b/u.test(text) ||
|
||||
/(?:на\s+ту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text);
|
||||
const hasFollowupBalanceVerb = /(?:вернись|вернуться|вернуть|back|return)/iu.test(text);
|
||||
return hasAccountLexeme && hasAsOfStyleDate && hasFollowupBalanceVerb;
|
||||
}
|
||||
function hasPeriodCoverageProfileSignal(text) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
const hasAccountLexeme = hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text);
|
||||
const hasDocLexeme = /(?:документ|док(?:и|ам|ах|ов|а)?|docs?|documents?)/iu.test(text);
|
||||
const hasDrilldownVerb = /(?:раскрой|раскры|разлож|разверн|документами|по\s+документ)/iu.test(text);
|
||||
const hasSameDate = /(?:на\s+ту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text);
|
||||
return hasAccountLexeme && hasDocLexeme && (hasDrilldownVerb || hasSameDate);
|
||||
}
|
||||
function hasOpenContractsListSignal(text) {
|
||||
const hasContractLexeme = text.includes("договор") || text.includes("contract") || text.includes("dogovor");
|
||||
@@ -246,6 +489,28 @@ function isLikelyCounterpartyToken(rawToken) {
|
||||
"документ",
|
||||
"документы",
|
||||
"документов",
|
||||
"документами",
|
||||
"документу",
|
||||
"документе",
|
||||
"документа",
|
||||
"документах",
|
||||
"докам",
|
||||
"доками",
|
||||
"количество",
|
||||
"количеству",
|
||||
"количества",
|
||||
"количеством",
|
||||
"активный",
|
||||
"активного",
|
||||
"активности",
|
||||
"пассивный",
|
||||
"пассивного",
|
||||
"пассивности",
|
||||
"наименее",
|
||||
"минимальный",
|
||||
"минимум",
|
||||
"реже",
|
||||
"редкий",
|
||||
"банк",
|
||||
"банковские",
|
||||
"операции",
|
||||
@@ -273,7 +538,13 @@ function isLikelyCounterpartyToken(rawToken) {
|
||||
"ёпт",
|
||||
"епта",
|
||||
"нах",
|
||||
"нахуй"
|
||||
"нахуй",
|
||||
"связанным",
|
||||
"связанные",
|
||||
"связанных",
|
||||
"связанному",
|
||||
"related",
|
||||
"linked"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
@@ -388,7 +659,15 @@ function hasLooseByAnchorMention(text) {
|
||||
"периоду",
|
||||
"период",
|
||||
"документам",
|
||||
"докам"
|
||||
"докам",
|
||||
"количество",
|
||||
"количеству",
|
||||
"количества",
|
||||
"количеством",
|
||||
"активности",
|
||||
"пассивности",
|
||||
"наименее",
|
||||
"минимум"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
@@ -476,6 +755,13 @@ function resolveAddressIntent(userMessage) {
|
||||
reasons: ["documents_forming_balance_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (hasDocumentsByAccountDrilldownSignal(text)) {
|
||||
return {
|
||||
intent: "documents_forming_balance",
|
||||
confidence: "medium",
|
||||
reasons: ["documents_by_account_drilldown_signal_detected"]
|
||||
};
|
||||
}
|
||||
if (hasOpenContractsListSignal(text)) {
|
||||
return {
|
||||
intent: "list_open_contracts",
|
||||
@@ -491,6 +777,60 @@ function resolveAddressIntent(userMessage) {
|
||||
reasons: ["open_items_signal_detected"]
|
||||
};
|
||||
}
|
||||
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)) {
|
||||
return {
|
||||
|
||||
@@ -62,6 +62,7 @@ const ADDRESS_ENTITY_TOKENS = [
|
||||
"компан",
|
||||
"организац",
|
||||
"поставщик",
|
||||
"заказчик",
|
||||
"клиент",
|
||||
"покупател",
|
||||
"партнер",
|
||||
@@ -104,6 +105,57 @@ const DEEP_REASONING_TOKENS = [
|
||||
"разрыв",
|
||||
"ошибк"
|
||||
];
|
||||
function hasManagementProfileSignal(text) {
|
||||
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) {
|
||||
const match = text.match(/(?:^|\s)по\s+([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
|
||||
if (!match) {
|
||||
@@ -139,7 +191,14 @@ function hasLooseByAnchorMention(text) {
|
||||
"документам",
|
||||
"докам",
|
||||
"взаиморасчетам",
|
||||
"взаиморасчётам"
|
||||
"взаиморасчётам",
|
||||
"количество",
|
||||
"количеству",
|
||||
"количества",
|
||||
"активности",
|
||||
"пассивности",
|
||||
"наименее",
|
||||
"минимум"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
@@ -208,7 +267,15 @@ function hasLikelyCounterpartyToken(text) {
|
||||
"list",
|
||||
"please",
|
||||
"all",
|
||||
"vse"
|
||||
"vse",
|
||||
"количество",
|
||||
"количеству",
|
||||
"количества",
|
||||
"активный",
|
||||
"пассивный",
|
||||
"наименее",
|
||||
"минимум",
|
||||
"реже"
|
||||
]);
|
||||
const tokens = String(text ?? "")
|
||||
.split(/[^a-zа-яё0-9._-]+/iu)
|
||||
@@ -243,6 +310,7 @@ function detectAddressQuestionMode(userMessage) {
|
||||
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);
|
||||
@@ -253,6 +321,13 @@ function detectAddressQuestionMode(userMessage) {
|
||||
reasons: ["address_action_detected", "address_entity_detected"]
|
||||
};
|
||||
}
|
||||
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",
|
||||
|
||||
+337
-7
@@ -10,6 +10,8 @@ const composeStage_1 = require("./address_runtime/composeStage");
|
||||
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
|
||||
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
|
||||
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([
|
||||
"ооо",
|
||||
"ао",
|
||||
@@ -60,6 +62,18 @@ const ACCOUNT_ALIAS_MAP = {
|
||||
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
|
||||
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
|
||||
};
|
||||
const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
|
||||
ПРЕДСТАВЛЕНИЕ(Контрагенты.Ссылка) КАК Регистратор,
|
||||
"" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
0 КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(Контрагенты.Ссылка) КАК Контрагент
|
||||
ИЗ
|
||||
Справочник.Контрагенты КАК Контрагенты
|
||||
`;
|
||||
let counterpartyCatalogCache = null;
|
||||
function parseFiniteNumber(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
@@ -134,6 +148,24 @@ function tokenizeAnchor(value) {
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
|
||||
}
|
||||
function anchorTokenVariants(token) {
|
||||
const source = String(token ?? "").trim().toLowerCase();
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
const variants = new Set([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, anchor) {
|
||||
const searchableNormalized = normalizeSearchText(searchable);
|
||||
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||
@@ -146,8 +178,11 @@ function matchesAnchorText(searchable, anchor) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
function isLikelyLowQualityPartyAnchor(value) {
|
||||
@@ -218,6 +253,154 @@ function uniqueStrings(values) {
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)));
|
||||
}
|
||||
function normalizeCounterpartyName(value) {
|
||||
return normalizeSearchText(String(value ?? ""))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
function extractCounterpartyCatalogNames(rows) {
|
||||
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, anchor) {
|
||||
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, filters) {
|
||||
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) {
|
||||
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 = cacheFresh ? [...counterpartyCatalogCache.names] : [];
|
||||
if (!cacheFresh) {
|
||||
const mcp = await (0, addressMcpClient_1.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) => 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) {
|
||||
const fixedKeys = [
|
||||
"СубконтоДт1",
|
||||
@@ -390,9 +573,13 @@ function canAutoBroadenPeriodWindow(intent, filters) {
|
||||
intent === "list_documents_by_contract" ||
|
||||
intent === "bank_operations_by_contract");
|
||||
}
|
||||
function invertSort(sort) {
|
||||
return sort === "period_asc" ? "period_desc" : "period_asc";
|
||||
}
|
||||
function isAnchorRecoveryIntent(intent) {
|
||||
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" ||
|
||||
@@ -734,6 +921,42 @@ class AddressQueryService {
|
||||
reasons: baseReasons
|
||||
});
|
||||
}
|
||||
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 = (0, resolveStage_1.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 = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters);
|
||||
const mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
|
||||
query: plan.query,
|
||||
@@ -817,7 +1040,7 @@ class AddressQueryService {
|
||||
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
|
||||
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
|
||||
if (recoveredRows.length > 0) {
|
||||
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows);
|
||||
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows, { userMessage });
|
||||
const recoveryReason = recoveredBankRows.length > 0
|
||||
? "contract_docs_recovered_via_bank_fallback"
|
||||
: "contract_docs_recovered_via_anchor_rows";
|
||||
@@ -924,7 +1147,7 @@ class AddressQueryService {
|
||||
rowsAnchorMatched: expandedRowsByAnchor.length,
|
||||
rowsMatched: expandedFilteredRows.length
|
||||
});
|
||||
const expandedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, expandedFilteredRows);
|
||||
const expandedFactual = (0, composeStage_1.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"];
|
||||
@@ -1026,7 +1249,7 @@ class AddressQueryService {
|
||||
});
|
||||
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
|
||||
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
|
||||
const broadenedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, broadenedFilteredRows);
|
||||
const broadenedFactual = (0, composeStage_1.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 {
|
||||
@@ -1079,13 +1302,120 @@ 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 = {
|
||||
...filters.extracted_filters,
|
||||
sort: invertSort(filters.extracted_filters.sort),
|
||||
limit: Math.max(currentLimit, ADDRESS_ANCHOR_RECOVERY_LIMIT)
|
||||
};
|
||||
const historicalSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, historicalFilters);
|
||||
if (historicalSelection.selected_recipe && historicalSelection.missing_required_filters.length === 0) {
|
||||
const historicalPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(historicalSelection.selected_recipe, historicalFilters);
|
||||
const historicalMcp = await (0, addressMcpClient_1.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 = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, historicalFilters);
|
||||
historicalAnchor = (0, resolveStage_1.refineAnchorFromRows)(historicalAnchor, historicalNormalizedRows);
|
||||
const historicalFiltersForMatching = 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 = (0, composeStage_1.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: (0, composeStage_1.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 = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows);
|
||||
const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, { userMessage });
|
||||
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
|
||||
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
|
||||
return {
|
||||
@@ -1221,7 +1551,7 @@ class AddressQueryService {
|
||||
reasons: baseReasons
|
||||
});
|
||||
}
|
||||
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows);
|
||||
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, { userMessage });
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: factual.text,
|
||||
|
||||
+350
-13
@@ -13,7 +13,7 @@ const MOVEMENTS_QUERY_TEMPLATE = `
|
||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||||
__WHERE_CLAUSE__
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Движения.Период УБЫВ
|
||||
Движения.Период __ORDER_DIRECTION__
|
||||
`;
|
||||
const BANK_DOCS_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||
@@ -38,9 +38,303 @@ __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 = [
|
||||
{
|
||||
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",
|
||||
@@ -194,6 +488,14 @@ function buildWhereClause(filters, fieldPath, extraConditions = []) {
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function buildManagementWhereClause(filters, fieldPath) {
|
||||
return buildWhereClause(filters, fieldPath);
|
||||
}
|
||||
function buildUsedContractWhereClause(filters, fieldPath, contractFieldPath) {
|
||||
return buildWhereClause(filters, fieldPath, [
|
||||
`${contractFieldPath} <> ЗНАЧЕНИЕ(Справочник.ДоговорыКонтрагентов.ПустаяСсылка)`
|
||||
]);
|
||||
}
|
||||
function normalizeAccountTokenForQuery(value) {
|
||||
const source = String(value ?? "").trim().replace(",", ".");
|
||||
const match = source.match(/^(\d{2})(?:\.(\d{1,2}))?/);
|
||||
@@ -247,7 +549,13 @@ function shouldBoostLimitForAllTimeCounterparty(filters) {
|
||||
return !hasPeriod;
|
||||
}
|
||||
function maxLimitForIntent(intent) {
|
||||
if (intent === "list_documents_by_counterparty" ||
|
||||
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" ||
|
||||
intent === "bank_operations_by_contract" ||
|
||||
@@ -257,6 +565,9 @@ function maxLimitForIntent(intent) {
|
||||
}
|
||||
return ADDRESS_MAX_LIMIT_DEFAULT;
|
||||
}
|
||||
function resolveOrderDirection(sort) {
|
||||
return sort === "period_asc" ? "ВОЗР" : "УБЫВ";
|
||||
}
|
||||
function selectAddressRecipe(intent, filters) {
|
||||
const recipe = BASE_RECIPES.find((item) => item.intent === intent) ?? null;
|
||||
if (!recipe) {
|
||||
@@ -278,20 +589,26 @@ function selectAddressRecipe(intent, filters) {
|
||||
}
|
||||
function buildAddressRecipePlan(recipe, filters) {
|
||||
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 = (recipe.intent === "account_balance_snapshot" || recipe.intent === "documents_forming_balance") && filters.account
|
||||
? [String(filters.account)]
|
||||
@@ -304,14 +621,34 @@ function buildAddressRecipePlan(recipe, filters) {
|
||||
.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 = [];
|
||||
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 = [];
|
||||
const accountCondition = buildMovementAccountCondition(filters);
|
||||
if (accountCondition) {
|
||||
extraConditions.push(accountCondition);
|
||||
}
|
||||
return buildWhereClause(filters, "Движения.Период", extraConditions);
|
||||
})())
|
||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||
return {
|
||||
recipe,
|
||||
query,
|
||||
|
||||
@@ -17,6 +17,234 @@ function formatTopRows(rows, limit = 6) {
|
||||
return `${index + 1}. ${period} | ${row.registrator} | ${accounts} | ${amount}${analytics}`;
|
||||
});
|
||||
}
|
||||
function extractYearFromIso(value) {
|
||||
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) {
|
||||
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 = {
|
||||
"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, total) {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) {
|
||||
return null;
|
||||
}
|
||||
return `${((value / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
function extractAccountSectionCode(value) {
|
||||
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) {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
function detectPeriodProfileFocus(userMessage) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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, yearOps) {
|
||||
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
|
||||
};
|
||||
}
|
||||
function contractCandidatesFromRows(rows) {
|
||||
const candidates = [];
|
||||
for (const row of rows) {
|
||||
@@ -32,7 +260,406 @@ function contractCandidatesFromRows(rows) {
|
||||
}
|
||||
return uniqueStrings(candidates);
|
||||
}
|
||||
function composeFactualReply(intent, rows) {
|
||||
function composeFactualReply(intent, rows, options = {}) {
|
||||
if (intent === "document_type_and_account_section_profile") {
|
||||
const rowsByMarker = new Map();
|
||||
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();
|
||||
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 = [
|
||||
"Профиль типов документов и разделов учета собран (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();
|
||||
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 = (rowsByMarker.get("YEAR_OPS") ?? [])
|
||||
.map((row) => ({
|
||||
year: extractYearFromIso(row.period),
|
||||
count: row.amount ?? 0
|
||||
}))
|
||||
.filter((item) => item.year !== null)
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const yearDocs = (rowsByMarker.get("YEAR_DOCS") ?? [])
|
||||
.map((row) => ({
|
||||
year: extractYearFromIso(row.period),
|
||||
count: row.amount ?? 0
|
||||
}))
|
||||
.filter((item) => 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.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 = [
|
||||
"Профиль периодов базы собран (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();
|
||||
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) => (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 = [
|
||||
"Профиль контрагентов собран (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();
|
||||
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 = [
|
||||
"Собран профиль активности заказчиков (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();
|
||||
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) => (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 = [
|
||||
"Профиль договорной базы собран (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 = [
|
||||
@@ -90,6 +717,40 @@ function composeFactualReply(intent, rows) {
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
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 = [
|
||||
"Собран список договоров по контрагенту (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).",
|
||||
|
||||
@@ -133,7 +133,9 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||
const previousPeriodTo = toNonEmptyString(previous.period_to);
|
||||
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 ??
|
||||
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
|
||||
@@ -212,6 +214,7 @@ function resolveMissingRequiredFilters(intent, filters) {
|
||||
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"]
|
||||
};
|
||||
@@ -251,7 +254,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||
hasAccountSignal(normalizedMessage) &&
|
||||
(detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "bank_operations_by_counterparty")) {
|
||||
detectedIntent.intent === "bank_operations_by_counterparty" ||
|
||||
detectedIntent.intent === "account_balance_snapshot")) {
|
||||
const preferDocumentsForming = hasDocumentSignal(normalizedMessage) &&
|
||||
/(?:раскрой|раскры|формир|документами|по\s+документ)/iu.test(normalizedMessage);
|
||||
return {
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildAddressLlmPredecomposeContractV1 = buildAddressLlmPredecomposeContractV1;
|
||||
const addressQueryClassifier_1 = require("../addressQueryClassifier");
|
||||
const addressQueryShapeClassifier_1 = require("../addressQueryShapeClassifier");
|
||||
const addressIntentResolver_1 = require("../addressIntentResolver");
|
||||
const addressFilterExtractor_1 = require("../addressFilterExtractor");
|
||||
function toNonEmptyString(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const normalized = String(value).trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
function hasAllTimeHint(text) {
|
||||
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, canonicalMessage) {
|
||||
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, shape) {
|
||||
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";
|
||||
}
|
||||
function buildAddressLlmPredecomposeContractV1(input) {
|
||||
const sourceMessage = String(input.sourceMessage ?? "").trim();
|
||||
const canonicalMessage = String(input.canonicalMessage ?? "").trim() || sourceMessage;
|
||||
const mode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(canonicalMessage);
|
||||
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(canonicalMessage);
|
||||
const intent = (0, addressIntentResolver_1.resolveAddressIntent)(canonicalMessage);
|
||||
const extraction = (0, addressFilterExtractor_1.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)
|
||||
};
|
||||
}
|
||||
@@ -72,6 +72,24 @@ function tokenizeAnchor(value) {
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
|
||||
}
|
||||
function anchorTokenVariants(token) {
|
||||
const source = String(token ?? "").trim().toLowerCase();
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
const variants = new Set([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, anchor) {
|
||||
const searchableNormalized = normalizeSearchText(searchable);
|
||||
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
|
||||
@@ -84,8 +102,11 @@ function matchesAnchorText(searchable, anchor) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
function uniqueStrings(values) {
|
||||
@@ -120,6 +141,17 @@ function resolvePrimaryAnchor(intent, filters) {
|
||||
};
|
||||
}
|
||||
}
|
||||
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",
|
||||
|
||||
+92
-52
@@ -56,6 +56,7 @@ const assistantClaimBoundEvidence_1 = __importStar(require("./assistantClaimBoun
|
||||
const addressQueryService_1 = __importStar(require("./addressQueryService"));
|
||||
const addressQueryClassifier_1 = __importStar(require("./addressQueryClassifier"));
|
||||
const addressIntentResolver_1 = __importStar(require("./addressIntentResolver"));
|
||||
const predecomposeContract_1 = __importStar(require("./address_runtime/predecomposeContract"));
|
||||
const iconv_lite_1 = __importDefault(require("iconv-lite"));
|
||||
function retrievalSummaryForRoute(route) {
|
||||
if (route === "store_canonical")
|
||||
@@ -1804,6 +1805,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,
|
||||
@@ -1876,6 +1879,16 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
|
||||
"которые",
|
||||
"какие",
|
||||
"какой",
|
||||
"активный",
|
||||
"активная",
|
||||
"активное",
|
||||
"активности",
|
||||
"месяц",
|
||||
"месяца",
|
||||
"месяцев",
|
||||
"количество",
|
||||
"количеству",
|
||||
"количества",
|
||||
"были",
|
||||
"был",
|
||||
"была",
|
||||
@@ -1972,6 +1985,7 @@ 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) {
|
||||
@@ -2177,6 +2191,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);
|
||||
@@ -2424,13 +2441,25 @@ 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;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function resolveAddressFollowupCarryoverContext(userMessage, items) {
|
||||
if (!hasAddressFollowupContextSignal(userMessage)) {
|
||||
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null) {
|
||||
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage);
|
||||
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
|
||||
? hasAddressFollowupContextSignal(alternateMessage)
|
||||
: false;
|
||||
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal) {
|
||||
return null;
|
||||
}
|
||||
const previousAddressDebug = findLastAddressAssistantDebug(items);
|
||||
@@ -2485,7 +2514,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 ?? "");
|
||||
@@ -2493,9 +2521,6 @@ function extractAddressQuestionFromNormalized(normalized) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.length >= 3 && candidate.length <= 500) {
|
||||
if (readiness === "no_route" && !isAddressLlmPreDecomposeCandidate(candidate)) {
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
@@ -2601,7 +2626,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 ?? "");
|
||||
@@ -2609,19 +2633,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,
|
||||
@@ -2629,6 +2659,7 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
|
||||
traceId: null,
|
||||
effectiveMessage: userMessage,
|
||||
reason: "not_attempted",
|
||||
llmCanonicalCandidateDetected: false,
|
||||
fallbackRuleHit: null,
|
||||
sanitizedUserMessage,
|
||||
toolGateDecision: null,
|
||||
@@ -2640,39 +2671,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,
|
||||
@@ -2698,7 +2709,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,
|
||||
@@ -2706,15 +2717,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);
|
||||
@@ -2729,18 +2740,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());
|
||||
@@ -2757,16 +2769,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) {
|
||||
@@ -2774,28 +2787,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) ||
|
||||
@@ -2804,7 +2819,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) {
|
||||
@@ -2894,6 +2913,9 @@ 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,
|
||||
@@ -2938,6 +2960,7 @@ 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)
|
||||
@@ -2948,19 +2971,25 @@ 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,
|
||||
toolGateReason: null
|
||||
};
|
||||
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
|
||||
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items);
|
||||
const toolGate = resolveAddressToolGateDecision(addressInputMessage, carryover?.followupContext ?? null);
|
||||
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items, addressInputMessage);
|
||||
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(),
|
||||
@@ -2978,7 +3007,10 @@ 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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3310,6 +3342,14 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user