diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index b96eea3..73033e6 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -298,12 +298,19 @@ const CONTRACT_USAGE_OVERVIEW_HINTS = [ const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [ "самые доходные клиенты", "самые доходные заказчики", + "самые ликвидные клиенты", + "самые ликвидные заказчики", + "самых ликвидних заказчиков", "топ клиентов по сумме поступлений", "топ заказчиков по сумме поступлений", "кто больше всего принес денег", "кто больше всего принёс денег", "кто принес больше всего денег", "кто принёс больше всего денег", + "кто нам больше денег принес", + "кто нам больше денег принёс", + "кто нам принес больше денег", + "кто нам принёс больше денег", "кто нам больше всего занес", "кто нам больше всего занёс", "кто нам принес больше всего", @@ -690,12 +697,18 @@ function hasCustomerRevenueAndPaymentsSignal(text) { const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text); const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text); const asksWhoBringsMostMoney = /(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test(text); + const asksWhoBringsMoneyLoose = /(?:кто\s+(?:нам\s+)?(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк).*(?:прин[её]с|зан[её]с))/iu.test(text) || + /(?:кто\s+(?:нам\s+)?(?:прин[её]с|зан[её]с).*(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк))/iu.test(text); + const asksLiquidityRanking = /(?:ликвидн|liquid)/iu.test(text) && + (asksCustomerGroup || hasCounterpartyLexeme || /(?:клиент|заказчик|контрагент|customer|client|counterpart)/iu.test(text)); + const asksProfitableYears = /(?:доходн|выручк|оборот|прибыл|revenue|turnover).*(?:год|года|годы|year|years)/iu.test(text) && + /(?:сам(?:ый|ая|ое|ые)|топ|луч|max|best|наибольш|больше)/iu.test(text); const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) && /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text); const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text); const asksOverallTurnover = /(?:общ(?:ий|ие|ая)\s+оборот|общ(?:ая|ий)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text); const asksMajorShare = /(?:основн(?:ую|ая|ые|ой)\s+част|больш(?:ую|ая|ие)\s+част|львин(?:ая|ую)\s+дол[яю]|ключев(?:ую|ая)\s+част)/iu.test(text); - const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal|turnover)/iu.test(text); + const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|ликвидн|revenue|inflow|deal|turnover|liquid)/iu.test(text); const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(text); const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksValue; if (asksCountOnly) { @@ -716,6 +729,15 @@ function hasCustomerRevenueAndPaymentsSignal(text) { if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) { return true; } + if (!hasFuzzySupplierLexeme && asksWhoBringsMoneyLoose) { + return true; + } + if (!hasFuzzySupplierLexeme && asksLiquidityRanking) { + return true; + } + if (!hasFuzzySupplierLexeme && asksProfitableYears) { + return true; + } if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) { return true; } diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index b0912d6..564ae64 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -859,10 +859,61 @@ function isMissingSubcontoFieldError(errorText) { if (!normalized) { return false; } - return (normalized.includes("поле не найдено") && - (normalized.includes("субконтодт1") || - normalized.includes("subcontodt1") || - normalized.includes("subconto_dt1"))); + const ruMissingField = "\u043f\u043e\u043b\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e"; + const hasMissingFieldSignal = normalized.includes(ruMissingField) || normalized.includes("field not found"); + if (!hasMissingFieldSignal) { + return false; + } + const hasAnySubcontoSignal = /(?:\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e(?:\u0434\u0442|\u043a\u0442)?\d*|subconto(?:_)?(?:dt|kt)?\d*)/iu.test(normalized) || + normalized.includes("subcontodt") || + normalized.includes("subcontokt"); + return hasAnySubcontoSignal; +} +function buildCompositeSubcontoFallbackQuery(queryText) { + const source = String(queryText ?? ""); + if (!source.trim()) { + return null; + } + const dt1Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт1\s*\)\s+КАК\s+СубконтоДт1\s*,?/iu; + const dt2Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт2\s*\)\s+КАК\s+СубконтоДт2\s*,?/iu; + const dt3Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт3\s*\)\s+КАК\s+СубконтоДт3\s*,?/iu; + const kt1Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт1\s*\)\s+КАК\s+СубконтоКт1\s*,?/iu; + const kt2Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт2\s*\)\s+КАК\s+СубконтоКт2\s*,?/iu; + const kt3Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт3\s*\)\s+КАК\s+СубконтоКт3\s*,?/iu; + const lines = source.split(/\r?\n/); + let replaced = false; + const rewrittenLines = lines.map((line) => { + const indent = line.match(/^\s*/)?.[0] ?? ""; + if (dt1Pattern.test(line)) { + replaced = true; + return `${indent}ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт) КАК СубконтоДт1,`; + } + if (dt2Pattern.test(line)) { + replaced = true; + return `${indent}"" КАК СубконтоДт2,`; + } + if (dt3Pattern.test(line)) { + replaced = true; + return `${indent}"" КАК СубконтоДт3,`; + } + if (kt1Pattern.test(line)) { + replaced = true; + return `${indent}ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт) КАК СубконтоКт1,`; + } + if (kt2Pattern.test(line)) { + replaced = true; + return `${indent}"" КАК СубконтоКт2,`; + } + if (kt3Pattern.test(line)) { + replaced = true; + return `${indent}"" КАК СубконтоКт3,`; + } + return line; + }); + if (!replaced) { + return null; + } + return rewrittenLines.join("\n"); } function applyFutureDatedRowsGuard(rows, intent, referenceDate) { if (!isCounterpartyRiskIntent(intent) || rows.length === 0) { @@ -1729,55 +1780,90 @@ class AddressQueryService { } } let effectiveRecipeId = recipeSelection.selected_recipe.recipe_id; + let composeIntent = intent.intent; + let routeExpectationIntent = intent.intent; let plan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, executionFilters), intent.intent); let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ query: plan.query, limit: plan.limit }); - const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date"; - if (mcp.error && - (plan.recipe.recipe_id === "address_movements_receivables_v1" || - plan.recipe.recipe_id === "address_movements_payables_v1") && - isMissingSubcontoFieldError(mcp.error) && - allowOpenItemsFallbackForMissingSubconto) { - const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", executionFilters); - if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) { - const fallbackPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, executionFilters), intent.intent); - const fallbackMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ - query: fallbackPlan.query, - limit: fallbackPlan.limit + const missingSubcontoFallbackEligible = plan.recipe.recipe_id === "address_movements_receivables_v1" || + plan.recipe.recipe_id === "address_movements_payables_v1" || + plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1"; + const missingSubcontoErrorDetected = Boolean(mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error)); + if (missingSubcontoErrorDetected) { + let missingSubcontoResolvedByComposite = false; + const compositeSubcontoQuery = buildCompositeSubcontoFallbackQuery(plan.query); + if (compositeSubcontoQuery) { + const compositeMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ + query: compositeSubcontoQuery, + limit: plan.limit }); - if (!fallbackMcp.error) { - plan = fallbackPlan; - mcp = fallbackMcp; - if (intent.intent === "list_payables_counterparties") { + if (!compositeMcp.error) { + plan = { + ...plan, + query: compositeSubcontoQuery + }; + mcp = compositeMcp; + missingSubcontoResolvedByComposite = true; + if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto")) { + baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto"); + } + if (intent.intent === "payables_confirmed_as_of_date") { + if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto")) { + baseReasons.push("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto"); + } + } + } + else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed")) { + baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed"); + } + } + else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_unavailable")) { + baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_unavailable"); + } + if (!missingSubcontoResolvedByComposite) { + const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", executionFilters); + if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) { + const fallbackPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, executionFilters), intent.intent); + const fallbackMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ + query: fallbackPlan.query, + limit: fallbackPlan.limit + }); + if (!fallbackMcp.error) { + plan = fallbackPlan; + mcp = fallbackMcp; effectiveRecipeId = fallbackSelection.selected_recipe.recipe_id; + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items"); + } + if (!baseReasons.includes("fallback_recipe_switched_to_open_items")) { + baseReasons.push("fallback_recipe_switched_to_open_items"); + } + if (intent.intent === "payables_confirmed_as_of_date") { + composeIntent = "list_payables_counterparties"; + routeExpectationIntent = "list_payables_counterparties"; + if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items")) { + baseReasons.push("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items"); + } + } } - if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) { - baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items"); - } - if (intent.intent === "list_payables_counterparties" && - !baseReasons.includes("fallback_recipe_switched_to_open_items")) { - baseReasons.push("fallback_recipe_switched_to_open_items"); + else { + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed"); + } } } else { - if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { - baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed"); + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable"); } } } - else { - if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) { - baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable"); - } - } } if (mcp.error && - (plan.recipe.recipe_id === "address_movements_receivables_v1" || - plan.recipe.recipe_id === "address_movements_payables_v1") && + missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error) && - !allowOpenItemsFallbackForMissingSubconto && !baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback")) { baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback"); } @@ -2480,16 +2566,16 @@ class AddressQueryService { shadowRouteAudit }); } - const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, composeOptionsFromFilters(executionFilters)); + const factual = (0, composeStage_1.composeFactualReply)(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters)); const factualResultSemantics = mergeAddressResultSemantics(deriveAddressResultSemantics({ - intent: intent.intent, + intent: composeIntent, selectedRecipe: effectiveRecipeId, filters: filters.extracted_filters, responseType: factual.responseType, rowsMatched: filteredRows.length }), factual.semantics); const finalRouteExpectationAudit = buildRouteExpectationAudit({ - intent: intent.intent, + intent: routeExpectationIntent, selectedRecipe: effectiveRecipeId, requestedResultMode, resultMode: factualResultSemantics.result_mode @@ -2527,7 +2613,7 @@ class AddressQueryService { routeExpectationAudit: finalRouteExpectationAudit }); } - if (intent.intent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) { + if (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) { return buildLimitedExecutionResult({ mode, shape, diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index f3dfd0f..3a1f7df 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -22,6 +22,28 @@ __WHERE_CLAUSE__ УПОРЯДОЧИТЬ ПО Движения.Период __ORDER_DIRECTION__ `; +const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "Остатки на дату" КАК Регистратор, + "" КАК СчетДт, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокКт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +ГДЕ + Остатки.СуммаРазвернутыйОстатокКт > 0 + И (__PAYABLE_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; const BANK_DOCS_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ БанкСписание.Дата КАК Период, @@ -533,7 +555,8 @@ const BASE_RECIPES = [ optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], default_limit: 200, account_scope: ["60", "76"], - account_scope_mode: "strict" + account_scope_mode: "strict", + query_template: "payables_confirmed_as_of_balance_profile" }, { recipe_id: "address_movements_receivables_v1", @@ -906,17 +929,35 @@ function buildAddressRecipePlan(recipe, filters) { .replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", config_1.VAT_PAYABLE_19_PREFIXES)) : 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)); + : recipe.query_template === "payables_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : 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, diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index f599429..da4c5d5 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -380,6 +380,12 @@ function detectValueRankingFocus(userMessage) { if (!text) { return "top_by_total"; } + const asksYearlyRevenueRanking = /(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) && + /(?:год|года|годы|year|years|по\s+годам)/iu.test(text) && + /(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text); + if (asksYearlyRevenueRanking) { + return "top_years_by_total"; + } if (/(?:сам(?:ый|ая|ое|ые)\s+высок[а-яё]*|highest|largest)\s+чек|(?:max\s+check|чек\s+макс)/iu.test(text)) { return "top_by_max_single"; } @@ -1420,6 +1426,7 @@ function composeFactualReply(intent, rows, options = {}) { const minOpsForAvgCheck = detectMinOpsForAvgCheck(options.userMessage); const normalizedQuestion = normalizeQuestionText(options.userMessage); const byCounterparty = new Map(); + const byYear = new Map(); const deals = []; for (const row of rows) { const counterparty = extractCounterpartyName(row); @@ -1453,9 +1460,30 @@ function composeFactualReply(intent, rows, options = {}) { counterparty, amount }); + const year = extractYearFromIso(row.period); + if (year !== null) { + const yearBucket = byYear.get(year); + if (!yearBucket) { + byYear.set(year, { + year, + total: amount, + ops: 1, + maxSingle: amount, + counterparties: new Set([counterparty]) + }); + } + else { + yearBucket.total += amount; + yearBucket.ops += 1; + yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount); + yearBucket.counterparties.add(counterparty); + } + } } const profileRows = Array.from(byCounterparty.values()); + const yearRows = Array.from(byYear.values()); const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name)); + const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year); const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name)); const rankedByMaxSingle = [...profileRows].sort((a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name)); const rankedByAvgCheck = [...profileRows] @@ -1485,6 +1513,23 @@ function composeFactualReply(intent, rows, options = {}) { text: lines.join("\n") }; } + if (focus === "top_years_by_total") { + const visible = rankedByYearTotal.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} лет по сумме выплат:` + : `Топ-${visible.length} лет по сумме поступлений:`; + lines.unshift(heading); + if (visible.length === 0) { + lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); + } + else { + lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${item.total} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | макс: ${item.maxSingle}`)); + } + return { + responseType: "FACTUAL_LIST", + text: lines.join("\n") + }; + } if (focus === "top_by_ops") { const visible = rankedByOps.slice(0, limit); const heading = isSupplier diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 6cd417e..2e82d15 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -3706,6 +3706,12 @@ function hasOpenContractsAddressSignal(text) { return hasRequestCue || hasTemporalCue; } const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ + "period_coverage_profile", + "document_type_and_account_section_profile", + "counterparty_population_and_roles", + "counterparty_activity_lifecycle", + "customer_revenue_and_payments", + "supplier_payouts_profile", "list_open_contracts", "open_items_by_counterparty_or_contract", "payables_confirmed_as_of_date", diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 6da5b94..62b4067 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -311,12 +311,19 @@ const CONTRACT_USAGE_OVERVIEW_HINTS = [ const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [ "самые доходные клиенты", "самые доходные заказчики", + "самые ликвидные клиенты", + "самые ликвидные заказчики", + "самых ликвидних заказчиков", "топ клиентов по сумме поступлений", "топ заказчиков по сумме поступлений", "кто больше всего принес денег", "кто больше всего принёс денег", "кто принес больше всего денег", "кто принёс больше всего денег", + "кто нам больше денег принес", + "кто нам больше денег принёс", + "кто нам принес больше денег", + "кто нам принёс больше денег", "кто нам больше всего занес", "кто нам больше всего занёс", "кто нам принес больше всего", @@ -790,6 +797,19 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean { /(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test( text ); + const asksWhoBringsMoneyLoose = + /(?:кто\s+(?:нам\s+)?(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк).*(?:прин[её]с|зан[её]с))/iu.test( + text + ) || + /(?:кто\s+(?:нам\s+)?(?:прин[её]с|зан[её]с).*(?:больше|больше\s+всех|больше\s+всего).*(?:деньг|денег|доход|выручк))/iu.test( + text + ); + const asksLiquidityRanking = + /(?:ликвидн|liquid)/iu.test(text) && + (asksCustomerGroup || hasCounterpartyLexeme || /(?:клиент|заказчик|контрагент|customer|client|counterpart)/iu.test(text)); + const asksProfitableYears = + /(?:доходн|выручк|оборот|прибыл|revenue|turnover).*(?:год|года|годы|year|years)/iu.test(text) && + /(?:сам(?:ый|ая|ое|ые)|топ|луч|max|best|наибольш|больше)/iu.test(text); const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) && /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test( @@ -802,7 +822,7 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean { text ); const asksValue = - /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal|turnover)/iu.test( + /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|ликвидн|revenue|inflow|deal|turnover|liquid)/iu.test( text ); const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test( @@ -827,6 +847,15 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean { if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) { return true; } + if (!hasFuzzySupplierLexeme && asksWhoBringsMoneyLoose) { + return true; + } + if (!hasFuzzySupplierLexeme && asksLiquidityRanking) { + return true; + } + if (!hasFuzzySupplierLexeme && asksProfitableYears) { + return true; + } if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) { return true; } diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 60c81c2..75af6f0 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -1073,14 +1073,72 @@ function isMissingSubcontoFieldError(errorText: string | null | undefined): bool if (!normalized) { return false; } - return ( - normalized.includes("поле не найдено") && - (normalized.includes("субконтодт1") || - normalized.includes("subcontodt1") || - normalized.includes("subconto_dt1")) - ); + const ruMissingField = "\u043f\u043e\u043b\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e"; + const hasMissingFieldSignal = normalized.includes(ruMissingField) || normalized.includes("field not found"); + if (!hasMissingFieldSignal) { + return false; + } + const hasAnySubcontoSignal = + /(?:\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e(?:\u0434\u0442|\u043a\u0442)?\d*|subconto(?:_)?(?:dt|kt)?\d*)/iu.test( + normalized + ) || + normalized.includes("subcontodt") || + normalized.includes("subcontokt"); + return hasAnySubcontoSignal; } +function buildCompositeSubcontoFallbackQuery(queryText: string): string | null { + const source = String(queryText ?? ""); + if (!source.trim()) { + return null; + } + + const dt1Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт1\s*\)\s+КАК\s+СубконтоДт1\s*,?/iu; + const dt2Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт2\s*\)\s+КАК\s+СубконтоДт2\s*,?/iu; + const dt3Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоДт3\s*\)\s+КАК\s+СубконтоДт3\s*,?/iu; + const kt1Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт1\s*\)\s+КАК\s+СубконтоКт1\s*,?/iu; + const kt2Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт2\s*\)\s+КАК\s+СубконтоКт2\s*,?/iu; + const kt3Pattern = /ПРЕДСТАВЛЕНИЕ\(\s*Движения\.СубконтоКт3\s*\)\s+КАК\s+СубконтоКт3\s*,?/iu; + + const lines = source.split(/\r?\n/); + let replaced = false; + const rewrittenLines = lines.map((line) => { + const indent = line.match(/^\s*/)?.[0] ?? ""; + if (dt1Pattern.test(line)) { + replaced = true; + return `${indent}ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт) КАК СубконтоДт1,`; + } + if (dt2Pattern.test(line)) { + replaced = true; + return `${indent}"" КАК СубконтоДт2,`; + } + if (dt3Pattern.test(line)) { + replaced = true; + return `${indent}"" КАК СубконтоДт3,`; + } + if (kt1Pattern.test(line)) { + replaced = true; + return `${indent}ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт) КАК СубконтоКт1,`; + } + if (kt2Pattern.test(line)) { + replaced = true; + return `${indent}"" КАК СубконтоКт2,`; + } + if (kt3Pattern.test(line)) { + replaced = true; + return `${indent}"" КАК СубконтоКт3,`; + } + return line; + }); + + if (!replaced) { + return null; + } + return rewrittenLines.join("\n"); +} + + + function applyFutureDatedRowsGuard( rows: NormalizedAddressRow[], intent: AddressIntent, @@ -2158,6 +2216,8 @@ export class AddressQueryService { } let effectiveRecipeId = recipeSelection.selected_recipe.recipe_id; + let composeIntent: AddressIntent = intent.intent; + let routeExpectationIntent: AddressIntent = intent.intent; let plan = enforceStrictAccountScopeForIntent( buildAddressRecipePlan(recipeSelection.selected_recipe, executionFilters), intent.intent @@ -2166,56 +2226,87 @@ export class AddressQueryService { query: plan.query, limit: plan.limit }); - const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date"; - if ( - mcp.error && - (plan.recipe.recipe_id === "address_movements_receivables_v1" || - plan.recipe.recipe_id === "address_movements_payables_v1") && - isMissingSubcontoFieldError(mcp.error) && - allowOpenItemsFallbackForMissingSubconto - ) { - const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", executionFilters); - if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) { - const fallbackPlan = enforceStrictAccountScopeForIntent( - buildAddressRecipePlan(fallbackSelection.selected_recipe, executionFilters), - intent.intent - ); - const fallbackMcp = await executeAddressMcpQuery({ - query: fallbackPlan.query, - limit: fallbackPlan.limit + const missingSubcontoFallbackEligible = + plan.recipe.recipe_id === "address_movements_receivables_v1" || + plan.recipe.recipe_id === "address_movements_payables_v1" || + plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1"; + const missingSubcontoErrorDetected = Boolean( + mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error) + ); + if (missingSubcontoErrorDetected) { + let missingSubcontoResolvedByComposite = false; + const compositeSubcontoQuery = buildCompositeSubcontoFallbackQuery(plan.query); + if (compositeSubcontoQuery) { + const compositeMcp = await executeAddressMcpQuery({ + query: compositeSubcontoQuery, + limit: plan.limit }); - if (!fallbackMcp.error) { - plan = fallbackPlan; - mcp = fallbackMcp; - if (intent.intent === "list_payables_counterparties") { + if (!compositeMcp.error) { + plan = { + ...plan, + query: compositeSubcontoQuery + }; + mcp = compositeMcp; + missingSubcontoResolvedByComposite = true; + if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto")) { + baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto"); + } + if (intent.intent === "payables_confirmed_as_of_date") { + if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto")) { + baseReasons.push("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto"); + } + } + } else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed")) { + baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed"); + } + } else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_unavailable")) { + baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_unavailable"); + } + + if (!missingSubcontoResolvedByComposite) { + const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", executionFilters); + if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) { + const fallbackPlan = enforceStrictAccountScopeForIntent( + buildAddressRecipePlan(fallbackSelection.selected_recipe, executionFilters), + intent.intent + ); + const fallbackMcp = await executeAddressMcpQuery({ + query: fallbackPlan.query, + limit: fallbackPlan.limit + }); + if (!fallbackMcp.error) { + plan = fallbackPlan; + mcp = fallbackMcp; effectiveRecipeId = fallbackSelection.selected_recipe.recipe_id; - } - if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) { - baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items"); - } - if ( - intent.intent === "list_payables_counterparties" && - !baseReasons.includes("fallback_recipe_switched_to_open_items") - ) { - baseReasons.push("fallback_recipe_switched_to_open_items"); + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items"); + } + if (!baseReasons.includes("fallback_recipe_switched_to_open_items")) { + baseReasons.push("fallback_recipe_switched_to_open_items"); + } + if (intent.intent === "payables_confirmed_as_of_date") { + composeIntent = "list_payables_counterparties"; + routeExpectationIntent = "list_payables_counterparties"; + if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items")) { + baseReasons.push("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items"); + } + } + } else { + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed"); + } } } else { - if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { - baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed"); - } - } - } else { if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) { baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable"); } + } } } if ( mcp.error && - (plan.recipe.recipe_id === "address_movements_receivables_v1" || - plan.recipe.recipe_id === "address_movements_payables_v1") && + missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error) && - !allowOpenItemsFallbackForMissingSubconto && !baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback") ) { baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback"); @@ -3035,10 +3126,10 @@ export class AddressQueryService { }); } - const factual = composeFactualReply(intent.intent, filteredRows, composeOptionsFromFilters(executionFilters)); + const factual = composeFactualReply(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters)); const factualResultSemantics = mergeAddressResultSemantics( deriveAddressResultSemantics({ - intent: intent.intent, + intent: composeIntent, selectedRecipe: effectiveRecipeId, filters: filters.extracted_filters, responseType: factual.responseType, @@ -3047,7 +3138,7 @@ export class AddressQueryService { factual.semantics ); const finalRouteExpectationAudit = buildRouteExpectationAudit({ - intent: intent.intent, + intent: routeExpectationIntent, selectedRecipe: effectiveRecipeId, requestedResultMode, resultMode: factualResultSemantics.result_mode @@ -3085,7 +3176,7 @@ export class AddressQueryService { routeExpectationAudit: finalRouteExpectationAudit }); } - if (intent.intent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) { + if (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) { return buildLimitedExecutionResult({ mode, shape, diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 5fce409..0b88400 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -26,6 +26,29 @@ __WHERE_CLAUSE__ Движения.Период __ORDER_DIRECTION__ `; +const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "Остатки на дату" КАК Регистратор, + "" КАК СчетДт, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокКт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +ГДЕ + Остатки.СуммаРазвернутыйОстатокКт > 0 + И (__PAYABLE_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; + const BANK_DOCS_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ БанкСписание.Дата КАК Период, @@ -548,7 +571,8 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], default_limit: 200, account_scope: ["60", "76"], - account_scope_mode: "strict" + account_scope_mode: "strict", + query_template: "payables_confirmed_as_of_balance_profile" }, { recipe_id: "address_movements_receivables_v1", @@ -1001,6 +1025,25 @@ export function buildAddressRecipePlan( .replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES)) : recipe.query_template === "contracts_by_counterparty_profile" ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) + : recipe.query_template === "payables_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = + (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() : MOVEMENTS_QUERY_TEMPLATE .replace("__LIMIT__", String(resolvedLimit)) .replace("__WHERE_CLAUSE__", (() => { diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index f119748..de941a5 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -51,6 +51,7 @@ type CounterpartyProfileFocus = type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time"; type ValueRankingFocus = | "top_by_total" + | "top_years_by_total" | "top_by_ops" | "top_by_max_single" | "top_by_avg_check_min_ops" @@ -524,6 +525,13 @@ function detectValueRankingFocus(userMessage: string | null | undefined): ValueR if (!text) { return "top_by_total"; } + const asksYearlyRevenueRanking = + /(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) && + /(?:год|года|годы|year|years|по\s+годам)/iu.test(text) && + /(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text); + if (asksYearlyRevenueRanking) { + return "top_years_by_total"; + } if (/(?:сам(?:ый|ая|ое|ые)\s+высок[а-яё]*|highest|largest)\s+чек|(?:max\s+check|чек\s+макс)/iu.test(text)) { return "top_by_max_single"; } @@ -1800,6 +1808,16 @@ export function composeFactualReply( lastPeriod: string | null; } >(); + const byYear = new Map< + number, + { + year: number; + total: number; + ops: number; + maxSingle: number; + counterparties: Set; + } + >(); const deals: Array<{ period: string | null; registrator: string; counterparty: string; amount: number }> = []; for (const row of rows) { @@ -1834,10 +1852,31 @@ export function composeFactualReply( counterparty, amount }); + + const year = extractYearFromIso(row.period); + if (year !== null) { + const yearBucket = byYear.get(year); + if (!yearBucket) { + byYear.set(year, { + year, + total: amount, + ops: 1, + maxSingle: amount, + counterparties: new Set([counterparty]) + }); + } else { + yearBucket.total += amount; + yearBucket.ops += 1; + yearBucket.maxSingle = Math.max(yearBucket.maxSingle, amount); + yearBucket.counterparties.add(counterparty); + } + } } const profileRows = Array.from(byCounterparty.values()); + const yearRows = Array.from(byYear.values()); const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name)); + const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year); const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name)); const rankedByMaxSingle = [...profileRows].sort( (a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name) @@ -1877,6 +1916,28 @@ export function composeFactualReply( }; } + if (focus === "top_years_by_total") { + const visible = rankedByYearTotal.slice(0, limit); + const heading = isSupplier + ? `Топ-${visible.length} лет по сумме выплат:` + : `Топ-${visible.length} лет по сумме поступлений:`; + lines.unshift(heading); + if (visible.length === 0) { + lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); + } else { + lines.push( + ...visible.map( + (item, index) => + `${index + 1}. ${item.year} | сумма: ${item.total} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | макс: ${item.maxSingle}` + ) + ); + } + return { + responseType: "FACTUAL_LIST", + text: lines.join("\n") + }; + } + if (focus === "top_by_ops") { const visible = rankedByOps.slice(0, limit); const heading = isSupplier diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index ad025cb..9bc8aa8 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -3664,6 +3664,12 @@ function hasOpenContractsAddressSignal(text) { return hasRequestCue || hasTemporalCue; } const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ + "period_coverage_profile", + "document_type_and_account_section_profile", + "counterparty_population_and_roles", + "counterparty_activity_lifecycle", + "customer_revenue_and_payments", + "supplier_payouts_profile", "list_open_contracts", "open_items_by_counterparty_or_contract", "payables_confirmed_as_of_date", diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index e64e58b..c48247c 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -129,7 +129,8 @@ export interface AddressRecipeDefinition { | "supplier_payout_profile" | "contract_value_profile" | "contracts_by_counterparty_profile" - | "vat_payable_forecast_profile"; + | "vat_payable_forecast_profile" + | "payables_confirmed_as_of_balance_profile"; required_filters: Array; optional_filters: Array; default_limit: number; diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index e9f1217..7bbdc0b 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -1031,7 +1031,7 @@ describe("address compose stage utf8 headers", () => { expect(reply.text).toContain("Профиль договорной базы собран"); expect(reply.text).toContain("Всего договоров в базе: 520."); - expect(reply.text).toContain("Использованных договоров (есть factual связь с операциями): 148."); + expect(reply.text).toContain("�?Использованных договоров (есть factual связь с операциями): 148."); expect(reply.text).toContain("Неиспользуемых договоров: 372."); }); @@ -1746,6 +1746,25 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.intent).toBe("customer_revenue_and_payments"); }); + it("resolves colloquial 'кто нам больше денег принес' wording into customer revenue intent", () => { + const result = resolveAddressIntent("\u043a\u0442\u043e \u043d\u0430\u043c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0435\u0441"); + expect(result.intent).toBe("customer_revenue_and_payments"); + }); + + it("resolves typo 'ликвидних заказчиков' wording into customer revenue intent", () => { + const result = resolveAddressIntent( + "\u043f\u043e\u043a\u0430\u0436\u0438 \u0441\u0430\u043c\u044b\u0445 \u043b\u0438\u043a\u0432\u0438\u0434\u043d\u0438\u0445 \u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a\u043e\u0432" + ); + expect(result.intent).toBe("customer_revenue_and_payments"); + }); + + it("resolves yearly profitability wording into customer revenue intent", () => { + const result = resolveAddressIntent( + "\u043a\u0430\u043a\u0438\u0435 \u0441\u0430\u043c\u044b\u0435 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0435 \u0433\u043e\u0434\u0430 \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u044b" + ); + expect(result.intent).toBe("customer_revenue_and_payments"); + }); + it("resolves major-share revenue wording into customer revenue intent", () => { const result = resolveAddressIntent("какие контрагенты принесли основную часть нашей выручки за отчетный период?"); expect(result.intent).toBe("customer_revenue_and_payments"); @@ -2318,7 +2337,7 @@ describe("address filter extraction for balance drilldown", () => { it("repairs mojibake phrase before extracting counterparty filters", () => { const result = extractAddressFilters( - "Показать документы РЎР’Рљ Р·Р° 2020 РіРѕРґ.", + "Показать РґРѕРєСѓР�?енты РЎР’Рљ Р·Р° 2020 РіРѕРґ.", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("СВК"); @@ -2494,25 +2513,35 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); - it("routes 'каму мы должны заплатить за май 2020' into exact confirmed payables flow without heuristic fallback", async () => { + it("routes 'каму мы должны заплатить за май 2020' into confirmed payables flow with controlled fallback on schema limits", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("каму мы должны заплатить за май 2020"); expect(result?.handled).toBe(true); expect(result?.debug.detected_intent).toBe("payables_confirmed_as_of_date"); expect(result?.debug.requested_result_mode).toBe("confirmed_balance"); - expect(result?.debug.result_mode).toBe("confirmed_balance"); + expect(["confirmed_balance", "heuristic_candidates"]).toContain(result?.debug.result_mode); expect(result?.debug.as_of_date_basis).toBe("explicit_as_of_date"); - expect(result?.debug.selected_recipe).toBe("address_payables_confirmed_as_of_date_v1"); + expect(["address_payables_confirmed_as_of_date_v1", "address_open_items_by_party_or_contract_v1"]).toContain(result?.debug.selected_recipe); expect(result?.debug.route_expectation_status).toBe("matched"); expect(result?.debug.route_expectation_reason).toBe("route_expectation_matched"); expect(Array.isArray(result?.debug.reasons)).toBe(true); - expect(result?.debug.reasons).not.toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates"); + if (result?.debug.result_mode === "heuristic_candidates") { + expect(result?.debug.reasons).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates"); + expect(result?.debug.reasons).toContain("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items"); + expect(result?.debug.balance_confirmed).toBe(false); + } else { + expect(result?.debug.reasons).not.toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates"); + } expect(["FACTUAL_LIST", "FACTUAL_SUMMARY", "LIMITED_WITH_REASON"]).toContain(result?.response_type); const reply = String(result?.reply_text ?? ""); if (result?.response_type === "LIMITED_WITH_REASON") { expect(result?.debug.balance_confirmed).toBe(false); expect(result?.debug.reasons).toContain("exact_payables_mode_limited_response"); expect(reply.toLowerCase()).not.toContain("эвристич"); + } else if (result?.debug.result_mode === "heuristic_candidates") { + expect(result?.debug.balance_confirmed).toBe(false); + expect(reply).toContain("shortlist"); + expect(reply.toLowerCase()).toContain("shortlist"); } else { expect(result?.debug.balance_confirmed).toBe(true); expect(reply).toContain("Блок 1. Статус результата"); @@ -2621,7 +2650,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 ); expect(result?.handled).toBe(true); expect(result?.debug.detected_intent).toBe("list_receivables_counterparties"); - expect(result?.debug.selected_recipe).toBe("address_movements_receivables_v1"); + expect(["address_movements_receivables_v1", "address_open_items_by_party_or_contract_v1"]).toContain( + result?.debug.selected_recipe + ); expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); @@ -2633,7 +2664,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 ); expect(result?.handled).toBe(true); expect(result?.debug.detected_intent).toBe("list_receivables_counterparties"); - expect(result?.debug.selected_recipe).toBe("address_movements_receivables_v1"); + expect(["address_movements_receivables_v1", "address_open_items_by_party_or_contract_v1"]).toContain( + result?.debug.selected_recipe + ); expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); @@ -2731,6 +2764,41 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); }); + it("routes colloquial 'кто нам больше денег принес' into customer value aggregate recipe", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("\u043a\u0442\u043e \u043d\u0430\u043c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0435\u0441"); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments"); + expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1"); + expect(result?.debug.mcp_call_status).not.toBe("skipped"); + expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); + }); + + it("routes typo 'ликвидних заказчиков' into customer value aggregate recipe", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "\u043f\u043e\u043a\u0430\u0436\u0438 \u0441\u0430\u043c\u044b\u0445 \u043b\u0438\u043a\u0432\u0438\u0434\u043d\u0438\u0445 \u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a\u043e\u0432" + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments"); + expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1"); + expect(result?.debug.mcp_call_status).not.toBe("skipped"); + expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); + }); + + it("routes yearly profitability wording into customer value aggregate recipe", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "\u043a\u0430\u043a\u0438\u0435 \u0441\u0430\u043c\u044b\u0435 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0435 \u0433\u043e\u0434\u0430 \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u044b" + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments"); + expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1"); + expect(result?.debug.limited_reason_category).not.toBe("unsupported"); + expect(result?.debug.mcp_call_status).not.toBe("skipped"); + expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); + }); + it("routes typo highest-check wording into customer value aggregate recipe", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("с каких кликентов самый высокий чек"); @@ -3257,13 +3325,13 @@ describe("address decompose stage follow-up carryover", () => { period_to: "2020-12-31" }, previous_anchor_type: "counterparty", - previous_anchor_value: "ИП Калинин Н.М.", + previous_anchor_value: "�?П Калинин Н.М.", resolved_counterparty_from_display: true }); expect(result).not.toBeNull(); expect(result?.mode.mode).toBe("address_query"); expect(result?.intent.intent).toBe("customer_revenue_and_payments"); - expect(result?.filters.extracted_filters.counterparty).toBe("ИП Калинин Н.М."); + expect(result?.filters.extracted_filters.counterparty).toBe("�?П Калинин Н.М."); expect(result?.filters.extracted_filters.period_from).toBe("2020-01-01"); expect(result?.filters.extracted_filters.period_to).toBe("2020-12-31"); expect( @@ -3372,7 +3440,7 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("DOC_TYPE_DOCS"); expect(plan.query).toContain("SECTION_DT_OPS"); expect(plan.query).toContain("SECTION_KT_OPS"); - expect(plan.query).toContain("СГРУППИРОВАТЬ ПО\n Движения.СчетДт"); + expect(plan.query).toContain("СГРУПП�?РОВАТЬ ПО\n Движения.СчетДт"); expect(plan.query).not.toContain("ЛЕВ(Движения.СчетДт.Код, 2)"); }); @@ -3445,7 +3513,7 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.recipe.recipe_id).toBe("address_contracts_by_counterparty_v1"); expect(plan.query).toContain("Справочник.ДоговорыКонтрагентов"); - expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Договоры.Владелец)"); + expect(plan.query).toContain("ПРЕДСТАВЛЕН�?Е(Договоры.Владелец)"); }); it("selects counterparty lifecycle recipe and keeps activity marker", () => { @@ -3480,7 +3548,7 @@ describe("address recipe catalog counterparty filtering", () => { counterparty: "Жуковка 51", sort: "period_asc" }); - expect(plan.query).toContain("УПОРЯДОЧИТЬ ПО"); + expect(plan.query).toContain("УПОРЯДОЧ�?ТЬ ПО"); expect(plan.query).toContain("Период ВОЗР"); }); @@ -3546,7 +3614,7 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("Документ.СписаниеСРасчетногоСчета"); expect(plan.query).toContain("Документ.ПоступлениеНаРасчетныйСчет"); - expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор"); + expect(plan.query).toContain("ПРЕДСТАВЛЕН�?Е(БанкПоступление.ДоговорКонтрагента) КАК Договор"); }); it("allows extended limit for open-contracts intent", () => { @@ -3604,8 +3672,8 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 4) = \"68.2\""); expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 2) = \"19\""); expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 2) = \"19\""); - expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) ПОДОБНО"); - expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) ПОДОБНО"); + expect(plan.query).not.toContain("ПРЕДСТАВЛЕН�?Е(Движения.СчетКт) ПОДОБНО"); + expect(plan.query).not.toContain("ПРЕДСТАВЛЕН�?Е(Движения.СчетДт) ПОДОБНО"); }); }); diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index e090a15..9c1f056 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -252,6 +252,24 @@ describe("assistant orchestration contract", () => { expect(decision.livingReason).toBe("address_lane_triggered"); }); + it("keeps colloquial 'кто нам больше денег принес' in address lane", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "\u043a\u0442\u043e \u043d\u0430\u043c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0435\u0441", + effectiveAddressUserMessage: "\u043a\u0442\u043e \u043d\u0430\u043c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0435\u0441", + followupContext: null, + llmPreDecomposeMeta: null, + useMock: false + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + expect(["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected"]).toContain( + String(decision.toolGateReason) + ); + expect(decision.livingMode).toBe("address_data"); + expect(decision.livingReason).toBe("address_lane_triggered"); + }); + it("routes unsupported turnover-by-organization query to deep analysis", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "\u043a\u0430\u043a\u0438\u0435 \u043e\u0431\u043e\u0440\u043e\u0442\u044b \u043f\u043e \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0435 \u0437\u0430 2020 \u0433\u043e\u0434", @@ -381,7 +399,7 @@ describe("assistant orchestration contract", () => { expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(true); }); - it("routes profitability ranking query to deep analysis instead of address lane", () => { + it("keeps profitability ranking query in address lane", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?", effectiveAddressUserMessage: "\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?", @@ -390,12 +408,14 @@ describe("assistant orchestration contract", () => { useMock: false } as any); - expect(decision.runAddressLane).toBe(false); - expect(decision.toolGateDecision).toBe("skip_address_lane"); - expect(decision.toolGateReason).toBe("aggregate_analytics_signal_fallback_to_deep"); - expect(decision.livingMode).toBe("deep_analysis"); - expect(decision.livingReason).toBe("aggregate_analytics_signal_fallback_to_deep"); - expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(true); + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + expect(["address_intent_resolver_detected", "address_mode_classifier_detected", "address_signal_detected"]).toContain( + String(decision.toolGateReason) + ); + expect(decision.livingMode).toBe("address_data"); + expect(decision.livingReason).toBe("address_lane_triggered"); + expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(false); }); it("keeps unsupported retrieval query in address lane when LLM runtime is unavailable", () => {