From 39ff160c8e6f104be30b626729879d42bfd7e865 Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 24 Apr 2026 14:12:25 +0300 Subject: [PATCH] =?UTF-8?q?Post-F:=20=D0=B4=D0=BE=D0=B1=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20semantic=20integrity=20M23=20=D0=BF=D0=BE=20VAT=20=D0=B8=20s?= =?UTF-8?q?cope=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/addressCoverageEvidencePolicy.js | 6 +- .../dist/services/addressIntentResolver.js | 7 +- .../dist/services/addressQueryService.js | 81 +++++++++++++--- .../services/address_runtime/composeStage.js | 3 +- .../counterpartyAnalyticsReplyBuilders.js | 24 +++-- .../address_runtime/decomposeStage.js | 8 ++ .../services/addressCoverageEvidencePolicy.ts | 6 +- .../src/services/addressIntentResolver.ts | 13 ++- .../src/services/addressQueryService.ts | 95 ++++++++++++++++--- .../services/address_runtime/composeStage.ts | 3 +- .../counterpartyAnalyticsReplyBuilders.ts | 24 +++-- .../address_runtime/decomposeStage.ts | 10 ++ 12 files changed, 222 insertions(+), 58 deletions(-) diff --git a/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js b/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js index f45fc67..5c850ad 100644 --- a/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js +++ b/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js @@ -109,13 +109,13 @@ function isConfirmedBalanceIntent(intent) { intent === "vat_liability_confirmed_for_tax_period"); } function resolveAddressAsOfDateBasis(filters, semanticFrame) { - if (semanticFrame?.date_basis_hint) { - return semanticFrame.date_basis_hint; - } const asOfDate = normalizeIsoDateHint(filters.as_of_date); if (asOfDate) { return "explicit_as_of_date"; } + if (semanticFrame?.date_basis_hint) { + return semanticFrame.date_basis_hint; + } const periodFrom = normalizeIsoDateHint(filters.period_from); const periodTo = normalizeIsoDateHint(filters.period_to); if (periodFrom && periodTo) { diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 000562d..7e7b11c 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1731,12 +1731,17 @@ function resolveUnicodeAddressIntentBridge(text) { } if (/(?:ндс|vat)/iu.test(normalized)) { const hasVatDebtCue = /(?:долг|должн|подтвержд)/iu.test(normalized); + const hasTaxPeriodCue = /(?:налогов|налоговую|бюджет|декларац|квартал|\b[1-4]\s*кв)/iu.test(normalized); + if (hasTaxPeriodCue && + /(?:скольк|скока|надо|нужно|заплат|уплат|оплат|прикин)/iu.test(normalized)) { + return unicodeBridgeResolution("vat_liability_confirmed_for_tax_period", "high", "vat_tax_period_confirmed_signal_detected"); + } if (/(?:прогноз|прикин|план)/iu.test(normalized) || (!hasVatDebtCue && /(?:надо|нужно)\s+(?:заплат|оплат|уплат)/iu.test(normalized))) { return unicodeBridgeResolution("vat_payable_forecast", "high", "forecast_tax_signal_detected"); } if (/(?:долг|подтвержд|скольк|скока|надо|нужно|заплат|уплат|оплат)/iu.test(normalized)) { - return unicodeBridgeResolution(/(?:налогов|бюджет|декларац|квартал|\b[1-4]\s*кв)/iu.test(normalized) + return unicodeBridgeResolution(hasTaxPeriodCue ? "vat_liability_confirmed_for_tax_period" : "vat_payable_confirmed_as_of_date", "high", "vat_payable_confirmed_signal_detected"); } diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 70d356b..91a67bb 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -191,6 +191,25 @@ function normalizeIsoDateForQuery(value) { } return `${match[1]}-${match[2]}-${match[3]}`; } +function deriveTaxQuarterWindowForDate(value) { + const isoDate = normalizeIsoDateForQuery(value); + if (!isoDate) { + return null; + } + const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const quarterStartMonth = Math.floor((month - 1) / 3) * 3 + 1; + const quarterEndMonth = quarterStartMonth + 2; + const quarterEndDay = new Date(Date.UTC(year, quarterEndMonth, 0)).getUTCDate(); + return { + period_from: `${year}-${String(quarterStartMonth).padStart(2, "0")}-01`, + period_to: `${year}-${String(quarterEndMonth).padStart(2, "0")}-${String(quarterEndDay).padStart(2, "0")}` + }; +} function toDateTimeExprForQuery(isoDate) { const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) { @@ -2756,15 +2775,21 @@ class AddressQueryService { const baseReasons = [...decompose.baseReasons]; const analysisDate = normalizeAnalysisDateHint(options.analysisDateHint); if (analysisDate) { + const asOfWasDefaultedToday = filters.warnings.includes("as_of_date_defaulted_today"); const hasTemporalFilter = Boolean((typeof filters.extracted_filters.period_from === "string" && filters.extracted_filters.period_from.trim().length > 0) || (typeof filters.extracted_filters.period_to === "string" && filters.extracted_filters.period_to.trim().length > 0) || (typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)); - if (!hasTemporalFilter) { + if (!hasTemporalFilter || asOfWasDefaultedToday) { filters.extracted_filters = { ...filters.extracted_filters, as_of_date: analysisDate }; - filters.warnings = [...new Set([...(filters.warnings ?? []), "as_of_date_from_analysis_context"])]; + filters.warnings = [ + ...new Set([ + ...(filters.warnings ?? []).filter((warning) => warning !== "as_of_date_defaulted_today"), + "as_of_date_from_analysis_context" + ]) + ]; baseReasons.push("as_of_date_from_analysis_context"); } } @@ -2801,6 +2826,22 @@ class AddressQueryService { }) }); } + if (intent.intent === "vat_liability_confirmed_for_tax_period" && + filters.warnings.includes("period_derived_from_month_phrase")) { + const taxQuarterWindow = deriveTaxQuarterWindowForDate(filters.extracted_filters.period_to); + if (taxQuarterWindow) { + filters.extracted_filters = { + ...filters.extracted_filters, + ...taxQuarterWindow + }; + filters.warnings = [ + ...new Set([...(filters.warnings ?? []), "period_derived_from_tax_quarter_for_confirmed_vat_liability"]) + ]; + if (!baseReasons.includes("period_derived_from_tax_quarter_for_confirmed_vat_liability")) { + baseReasons.push("period_derived_from_tax_quarter_for_confirmed_vat_liability"); + } + } + } const requestedResultMode = (0, addressCoverageEvidencePolicy_1.resolveAddressRequestedResultMode)(intent.intent, filters.extracted_filters, semanticFrame) ?? undefined; const confirmedBalancePayablesIntent = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") && requestedResultMode === "confirmed_balance"; @@ -2837,6 +2878,10 @@ class AddressQueryService { } if (payablesConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) { + filters.extracted_filters = { + ...filters.extracted_filters, + as_of_date: payablesConfirmedExecution.asOfDerived + }; if (!filters.warnings.includes("as_of_date_derived_for_confirmed_payables")) { filters.warnings.push("as_of_date_derived_for_confirmed_payables"); } @@ -2846,6 +2891,10 @@ class AddressQueryService { } if (receivablesConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) { + filters.extracted_filters = { + ...filters.extracted_filters, + as_of_date: receivablesConfirmedExecution.asOfDerived + }; if (!filters.warnings.includes("as_of_date_derived_for_confirmed_receivables")) { filters.warnings.push("as_of_date_derived_for_confirmed_receivables"); } @@ -2855,6 +2904,10 @@ class AddressQueryService { } if (vatPayableConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) { + filters.extracted_filters = { + ...filters.extracted_filters, + as_of_date: vatPayableConfirmedExecution.asOfDerived + }; if (!filters.warnings.includes("as_of_date_derived_for_confirmed_vat_payable")) { filters.warnings.push("as_of_date_derived_for_confirmed_vat_payable"); } @@ -2864,6 +2917,10 @@ class AddressQueryService { } if (inventoryConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) { + filters.extracted_filters = { + ...filters.extracted_filters, + as_of_date: inventoryConfirmedExecution.asOfDerived + }; if (!filters.warnings.includes("as_of_date_derived_for_inventory_on_hand")) { filters.warnings.push("as_of_date_derived_for_inventory_on_hand"); } @@ -2995,8 +3052,8 @@ class AddressQueryService { }); const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters); const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" && - Array.isArray(intent.reasons) && - intent.reasons.includes("receivables_debt_lifecycle_signal_detected"); + ((Array.isArray(intent.reasons) && intent.reasons.includes("receivables_debt_lifecycle_signal_detected")) || + /(?:долгожител|задолженн(?:ост|остям).*(?:давн|долго)|срок[а-я\s]+жизн[а-я\s]+задолженн)/iu.test(String(userMessage ?? ""))); const debtLifecyclePayablesScenario = intent.intent === "list_payables_counterparties" && Array.isArray(intent.reasons) && (intent.reasons.includes("payables_debt_lifecycle_signal_detected") || @@ -3134,10 +3191,6 @@ class AddressQueryService { if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) { const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor); if (catalogResolution.resolvedValue) { - filters.extracted_filters = { - ...filters.extracted_filters, - counterparty: catalogResolution.resolvedValue - }; executionFilters = { ...executionFilters, counterparty: catalogResolution.resolvedValue @@ -3785,9 +3838,9 @@ class AddressQueryService { ...executionFilters, limit: ADDRESS_ANCHOR_RECOVERY_LIMIT }; - const expandedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, expandedLimitFilters); + const expandedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, expandedLimitFilters); if (expandedSelection.selected_recipe && expandedSelection.missing_required_filters.length === 0) { - const expandedPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(expandedSelection.selected_recipe, expandedLimitFilters); + const expandedPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(expandedSelection.selected_recipe, expandedLimitFilters), intent.intent); if (expandedPlan.limit > currentLimit) { const expandedMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ query: expandedPlan.query, @@ -3887,9 +3940,9 @@ class AddressQueryService { ? Math.max(1, Math.trunc(autoBroadenedFilters.limit)) : 0); } - const broadenedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, autoBroadenedFilters); + const broadenedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, autoBroadenedFilters); if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) { - const broadenedPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(broadenedSelection.selected_recipe, autoBroadenedFilters); + const broadenedPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(broadenedSelection.selected_recipe, autoBroadenedFilters), intent.intent); const broadenedMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ query: broadenedPlan.query, limit: broadenedPlan.limit @@ -4010,9 +4063,9 @@ class AddressQueryService { sort: invertSort(filters.extracted_filters.sort), limit: Math.max(currentLimit, ADDRESS_ANCHOR_RECOVERY_LIMIT) }; - const historicalSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, historicalFilters); + const historicalSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, historicalFilters); if (historicalSelection.selected_recipe && historicalSelection.missing_required_filters.length === 0) { - const historicalPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(historicalSelection.selected_recipe, historicalFilters); + const historicalPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(historicalSelection.selected_recipe, historicalFilters), intent.intent); const historicalMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ query: historicalPlan.query, limit: historicalPlan.limit diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index da06a5f..02a6521 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -2775,6 +2775,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { }); const lines = [ `Коротко: на ${formatDateRu(asOfDate)} подтверждено открытых договоров с коммерческим остатком ${openContractNetBalanceDirectionLabel(commercialNetTotal)} ${formatMoneyRub(Math.abs(commercialNetTotal))}.`, + "Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.", `Брутто по коммерческим компонентам: ${formatMoneyRub(commercialGrossTotal)}.`, `Отдельно вынесены специальные финансовые позиции: ${formatNumberWithDots(specialProfiles.length)} на ${formatMoneyRub(specialTotal)}.`, `Спорные или некачественно нормализованные позиции: ${formatNumberWithDots(dirtyProfiles.length)} на ${formatMoneyRub(dirtyTotal)}.`, @@ -2948,7 +2949,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { }, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }); const lines = [ `Коротко: подтвержденный долг к оплате на ${formatDateRu(payablesAsOfDate)} — ${formatMoneyRub(totalOutstandingAmount)}.`, - "Это подтвержденный срез обязательств к оплате, а не эвристический shortlist." + "Это подтвержденный срез обязательств к оплате по точному остатку." ]; lines.push(""); lines.push("Что учтено"); diff --git a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js index 397c177..e43c7e5 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js @@ -48,9 +48,9 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { const includeTotal = focus === "full_profile" || focus === "total_only"; const includeRoles = focus === "full_profile" || focus === "roles_only"; const directLead = focus === "suppliers_only" - ? `Контрагентов только в роли поставщика: ${supplierOnly}.` + ? `Поставщиков (только supplier-роль): ${supplierOnly}.` : focus === "customers_only" - ? `Контрагентов только в роли заказчика: ${customerOnly}.` + ? `Заказчиков (только customer-роль): ${customerOnly}.` : focus === "mixed_only" ? `Контрагентов со смешанной ролью: ${mixedActive}.` : includeTotal && totalCounterparties > 0 @@ -74,10 +74,10 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { } if (includeRoles) { if (resolvedActive > 0 || activeCounterparties > 0) { - lines.push("Распределение ролей по активности:"); - lines.push(`1. Только заказчики: ${customerOnly}.`); - lines.push(`2. Только поставщики: ${supplierOnly}.`); - lines.push(`3. И заказчики, и поставщики: ${mixedActive}.`); + lines.push("Роли контрагентов по активности:"); + lines.push(`Заказчики (только customer-роль): ${customerOnly}.`); + lines.push(`Поставщики (только supplier-роль): ${supplierOnly}.`); + lines.push(`Смешанные (и покупатель, и поставщик): ${mixedActive}.`); lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`); if (otherCounterparties !== null) { lines.push(`5. Прочие или неактивные в выбранном окне: ${otherCounterparties}.`); @@ -88,10 +88,10 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { } } if (focus === "suppliers_only") { - lines.push(`Контрагентов только в роли поставщика: ${supplierOnly}.`); + lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`); } if (focus === "customers_only") { - lines.push(`Контрагентов только в роли заказчика: ${customerOnly}.`); + lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`); } if (focus === "mixed_only") { lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`); @@ -319,6 +319,10 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { ] : [ `Коротко: активных заказчиков ${scopeLabel} — ${counterparties.length}.`, + `Собран профиль активности заказчиков ${scopeLabel}.`, + requestedYear + ? `Активные заказчики в ${requestedYear} году: ${counterparties.length}.` + : `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, `Оценка собрана по подтвержденным платежным документам: ${rows.length} строк в выборке.` ]; if (counterparties.length === 0) { @@ -550,7 +554,7 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { ? `Топ-${visible.length} поставщиков по максимальной разовой выплате:` : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`; lines.unshift(heading); - lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`)); + lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`)); return (0, replyContracts_1.buildFactualListReply)(lines); } if (focus === "top_by_avg_check_min_ops") { @@ -571,7 +575,7 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { const visible = rankedDealsTop.slice(0, limit); const heading = isSupplier ? `Топ-${visible.length} самых крупных разовых выплат поставщикам:` - : `Топ-${visible.length} самых крупных разовых поступлений:`; + : `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`; lines.unshift(heading); lines.push(...visible.map((item, index) => `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`)); return (0, replyContracts_1.buildFactualListReply)(lines); diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 6f6956c..c7f3195 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -1425,6 +1425,13 @@ function runAddressDecomposeStage(userMessage, followupContext, llmSemanticHints warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])], semantic_frame: extractedFilters.semantic_frame }; + if ((intent.intent === "list_open_contracts" || intent.intent === "open_contracts_confirmed_as_of_date") && + typeof filters.extracted_filters.as_of_date === "string" && + typeof filters.extracted_filters.period_to === "string" && + filters.extracted_filters.as_of_date === filters.extracted_filters.period_to && + !filters.warnings.includes("as_of_date_derived_from_period_for_open_contracts")) { + filters.warnings.push("as_of_date_derived_from_period_for_open_contracts"); + } const followupContextApplied = Boolean(effectiveFollowupContext) && (mode.reasons.includes("address_mode_from_followup_context") || intent.reasons.includes("intent_from_followup_context") || @@ -1435,6 +1442,7 @@ function runAddressDecomposeStage(userMessage, followupContext, llmSemanticHints ...shape.reasons, ...intent.reasons, ...followupMerged.reasons, + ...filters.warnings.filter((reason) => reason === "as_of_date_derived_from_period_for_open_contracts"), ...(followupContextApplied ? ["address_followup_context_applied"] : []) ]; return { diff --git a/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts b/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts index 18bdbce..3752809 100644 --- a/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts +++ b/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts @@ -162,13 +162,13 @@ export function resolveAddressAsOfDateBasis( filters: AddressFilterSet, semanticFrame?: AddressSemanticFrame | null ): AddressAsOfDateBasis | null { - if (semanticFrame?.date_basis_hint) { - return semanticFrame.date_basis_hint; - } const asOfDate = normalizeIsoDateHint(filters.as_of_date); if (asOfDate) { return "explicit_as_of_date"; } + if (semanticFrame?.date_basis_hint) { + return semanticFrame.date_basis_hint; + } const periodFrom = normalizeIsoDateHint(filters.period_from); const periodTo = normalizeIsoDateHint(filters.period_to); if (periodFrom && periodTo) { diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 1195da2..b2a9f20 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -2377,6 +2377,17 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio if (/(?:ндс|vat)/iu.test(normalized)) { const hasVatDebtCue = /(?:долг|должн|подтвержд)/iu.test(normalized); + const hasTaxPeriodCue = /(?:налогов|налоговую|бюджет|декларац|квартал|\b[1-4]\s*кв)/iu.test(normalized); + if ( + hasTaxPeriodCue && + /(?:скольк|скока|надо|нужно|заплат|уплат|оплат|прикин)/iu.test(normalized) + ) { + return unicodeBridgeResolution( + "vat_liability_confirmed_for_tax_period", + "high", + "vat_tax_period_confirmed_signal_detected" + ); + } if ( /(?:прогноз|прикин|план)/iu.test(normalized) || (!hasVatDebtCue && /(?:надо|нужно)\s+(?:заплат|оплат|уплат)/iu.test(normalized)) @@ -2385,7 +2396,7 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio } if (/(?:долг|подтвержд|скольк|скока|надо|нужно|заплат|уплат|оплат)/iu.test(normalized)) { return unicodeBridgeResolution( - /(?:налогов|бюджет|декларац|квартал|\b[1-4]\s*кв)/iu.test(normalized) + hasTaxPeriodCue ? "vat_liability_confirmed_for_tax_period" : "vat_payable_confirmed_as_of_date", "high", diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 179bc01..feb8629 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -329,6 +329,26 @@ function normalizeIsoDateForQuery(value: unknown): string | null { return `${match[1]}-${match[2]}-${match[3]}`; } +function deriveTaxQuarterWindowForDate(value: unknown): { period_from: string; period_to: string } | null { + const isoDate = normalizeIsoDateForQuery(value); + if (!isoDate) { + return null; + } + const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const quarterStartMonth = Math.floor((month - 1) / 3) * 3 + 1; + const quarterEndMonth = quarterStartMonth + 2; + const quarterEndDay = new Date(Date.UTC(year, quarterEndMonth, 0)).getUTCDate(); + return { + period_from: `${year}-${String(quarterStartMonth).padStart(2, "0")}-01`, + period_to: `${year}-${String(quarterEndMonth).padStart(2, "0")}-${String(quarterEndDay).padStart(2, "0")}` + }; +} + function toDateTimeExprForQuery(isoDate: string): string | null { const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) { @@ -3429,17 +3449,23 @@ export class AddressQueryService { const baseReasons = [...decompose.baseReasons]; const analysisDate = normalizeAnalysisDateHint(options.analysisDateHint); if (analysisDate) { + const asOfWasDefaultedToday = filters.warnings.includes("as_of_date_defaulted_today"); const hasTemporalFilter = Boolean( (typeof filters.extracted_filters.period_from === "string" && filters.extracted_filters.period_from.trim().length > 0) || (typeof filters.extracted_filters.period_to === "string" && filters.extracted_filters.period_to.trim().length > 0) || (typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0) ); - if (!hasTemporalFilter) { + if (!hasTemporalFilter || asOfWasDefaultedToday) { filters.extracted_filters = { ...filters.extracted_filters, as_of_date: analysisDate }; - filters.warnings = [...new Set([...(filters.warnings ?? []), "as_of_date_from_analysis_context"])]; + filters.warnings = [ + ...new Set([ + ...(filters.warnings ?? []).filter((warning) => warning !== "as_of_date_defaulted_today"), + "as_of_date_from_analysis_context" + ]) + ]; baseReasons.push("as_of_date_from_analysis_context"); } } @@ -3478,6 +3504,24 @@ export class AddressQueryService { }) }); } + if ( + intent.intent === "vat_liability_confirmed_for_tax_period" && + filters.warnings.includes("period_derived_from_month_phrase") + ) { + const taxQuarterWindow = deriveTaxQuarterWindowForDate(filters.extracted_filters.period_to); + if (taxQuarterWindow) { + filters.extracted_filters = { + ...filters.extracted_filters, + ...taxQuarterWindow + }; + filters.warnings = [ + ...new Set([...(filters.warnings ?? []), "period_derived_from_tax_quarter_for_confirmed_vat_liability"]) + ]; + if (!baseReasons.includes("period_derived_from_tax_quarter_for_confirmed_vat_liability")) { + baseReasons.push("period_derived_from_tax_quarter_for_confirmed_vat_liability"); + } + } + } const requestedResultMode = resolveAddressRequestedResultMode(intent.intent, filters.extracted_filters, semanticFrame) ?? undefined; const confirmedBalancePayablesIntent = @@ -3525,6 +3569,10 @@ export class AddressQueryService { payablesConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0) ) { + filters.extracted_filters = { + ...filters.extracted_filters, + as_of_date: payablesConfirmedExecution.asOfDerived + }; if (!filters.warnings.includes("as_of_date_derived_for_confirmed_payables")) { filters.warnings.push("as_of_date_derived_for_confirmed_payables"); } @@ -3536,6 +3584,10 @@ export class AddressQueryService { receivablesConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0) ) { + filters.extracted_filters = { + ...filters.extracted_filters, + as_of_date: receivablesConfirmedExecution.asOfDerived + }; if (!filters.warnings.includes("as_of_date_derived_for_confirmed_receivables")) { filters.warnings.push("as_of_date_derived_for_confirmed_receivables"); } @@ -3547,6 +3599,10 @@ export class AddressQueryService { vatPayableConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0) ) { + filters.extracted_filters = { + ...filters.extracted_filters, + as_of_date: vatPayableConfirmedExecution.asOfDerived + }; if (!filters.warnings.includes("as_of_date_derived_for_confirmed_vat_payable")) { filters.warnings.push("as_of_date_derived_for_confirmed_vat_payable"); } @@ -3558,6 +3614,10 @@ export class AddressQueryService { inventoryConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0) ) { + filters.extracted_filters = { + ...filters.extracted_filters, + as_of_date: inventoryConfirmedExecution.asOfDerived + }; if (!filters.warnings.includes("as_of_date_derived_for_inventory_on_hand")) { filters.warnings.push("as_of_date_derived_for_inventory_on_hand"); } @@ -3724,8 +3784,10 @@ export class AddressQueryService { const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters); const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" && - Array.isArray(intent.reasons) && - intent.reasons.includes("receivables_debt_lifecycle_signal_detected"); + ((Array.isArray(intent.reasons) && intent.reasons.includes("receivables_debt_lifecycle_signal_detected")) || + /(?:долгожител|задолженн(?:ост|остям).*(?:давн|долго)|срок[а-я\s]+жизн[а-я\s]+задолженн)/iu.test( + String(userMessage ?? "") + )); const debtLifecyclePayablesScenario = intent.intent === "list_payables_counterparties" && Array.isArray(intent.reasons) && @@ -3881,10 +3943,6 @@ export class AddressQueryService { if (shouldAttemptCounterpartyCatalogResolution(intent.intent, filters.extracted_filters)) { const catalogResolution = await resolveCounterpartyViaCatalog(rawCounterpartyAnchor); if (catalogResolution.resolvedValue) { - filters.extracted_filters = { - ...filters.extracted_filters, - counterparty: catalogResolution.resolvedValue - }; executionFilters = { ...executionFilters, counterparty: catalogResolution.resolvedValue @@ -4631,9 +4689,12 @@ export class AddressQueryService { ...executionFilters, limit: ADDRESS_ANCHOR_RECOVERY_LIMIT }; - const expandedSelection = selectAddressRecipe(intent.intent, expandedLimitFilters); + const expandedSelection = selectAddressRecipe(recipeIntent, expandedLimitFilters); if (expandedSelection.selected_recipe && expandedSelection.missing_required_filters.length === 0) { - const expandedPlan = buildAddressRecipePlan(expandedSelection.selected_recipe, expandedLimitFilters); + const expandedPlan = enforceStrictAccountScopeForIntent( + buildAddressRecipePlan(expandedSelection.selected_recipe, expandedLimitFilters), + intent.intent + ); if (expandedPlan.limit > currentLimit) { const expandedMcp = await executeAddressMcpQuery({ query: expandedPlan.query, @@ -4757,9 +4818,12 @@ export class AddressQueryService { : 0 ); } - const broadenedSelection = selectAddressRecipe(intent.intent, autoBroadenedFilters); + const broadenedSelection = selectAddressRecipe(recipeIntent, autoBroadenedFilters); if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) { - const broadenedPlan = buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters); + const broadenedPlan = enforceStrictAccountScopeForIntent( + buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters), + intent.intent + ); const broadenedMcp = await executeAddressMcpQuery({ query: broadenedPlan.query, limit: broadenedPlan.limit @@ -4899,9 +4963,12 @@ export class AddressQueryService { sort: invertSort(filters.extracted_filters.sort), limit: Math.max(currentLimit, ADDRESS_ANCHOR_RECOVERY_LIMIT) }; - const historicalSelection = selectAddressRecipe(intent.intent, historicalFilters); + const historicalSelection = selectAddressRecipe(recipeIntent, historicalFilters); if (historicalSelection.selected_recipe && historicalSelection.missing_required_filters.length === 0) { - const historicalPlan = buildAddressRecipePlan(historicalSelection.selected_recipe, historicalFilters); + const historicalPlan = enforceStrictAccountScopeForIntent( + buildAddressRecipePlan(historicalSelection.selected_recipe, historicalFilters), + intent.intent + ); const historicalMcp = await executeAddressMcpQuery({ query: historicalPlan.query, limit: historicalPlan.limit diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 8daf772..d65e1df 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -3589,6 +3589,7 @@ function composeFactualReplyBody( const lines: string[] = [ `Коротко: на ${formatDateRu(asOfDate)} подтверждено открытых договоров с коммерческим остатком ${openContractNetBalanceDirectionLabel(commercialNetTotal)} ${formatMoneyRub(Math.abs(commercialNetTotal))}.`, + "Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.", `Брутто по коммерческим компонентам: ${formatMoneyRub(commercialGrossTotal)}.`, `Отдельно вынесены специальные финансовые позиции: ${formatNumberWithDots(specialProfiles.length)} на ${formatMoneyRub(specialTotal)}.`, `Спорные или некачественно нормализованные позиции: ${formatNumberWithDots(dirtyProfiles.length)} на ${formatMoneyRub(dirtyTotal)}.`, @@ -3802,7 +3803,7 @@ function composeFactualReplyBody( const lines: string[] = [ `Коротко: подтвержденный долг к оплате на ${formatDateRu(payablesAsOfDate)} — ${formatMoneyRub(totalOutstandingAmount)}.`, - "Это подтвержденный срез обязательств к оплате, а не эвристический shortlist." + "Это подтвержденный срез обязательств к оплате по точному остатку." ]; lines.push(""); diff --git a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts index ea0b8f0..6194406 100644 --- a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts @@ -148,9 +148,9 @@ export function composeCounterpartyAnalyticsReply( const includeRoles = focus === "full_profile" || focus === "roles_only"; const directLead = focus === "suppliers_only" - ? `Контрагентов только в роли поставщика: ${supplierOnly}.` + ? `Поставщиков (только supplier-роль): ${supplierOnly}.` : focus === "customers_only" - ? `Контрагентов только в роли заказчика: ${customerOnly}.` + ? `Заказчиков (только customer-роль): ${customerOnly}.` : focus === "mixed_only" ? `Контрагентов со смешанной ролью: ${mixedActive}.` : includeTotal && totalCounterparties > 0 @@ -175,10 +175,10 @@ export function composeCounterpartyAnalyticsReply( if (includeRoles) { if (resolvedActive > 0 || activeCounterparties > 0) { - lines.push("Распределение ролей по активности:"); - lines.push(`1. Только заказчики: ${customerOnly}.`); - lines.push(`2. Только поставщики: ${supplierOnly}.`); - lines.push(`3. И заказчики, и поставщики: ${mixedActive}.`); + lines.push("Роли контрагентов по активности:"); + lines.push(`Заказчики (только customer-роль): ${customerOnly}.`); + lines.push(`Поставщики (только supplier-роль): ${supplierOnly}.`); + lines.push(`Смешанные (и покупатель, и поставщик): ${mixedActive}.`); lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`); if (otherCounterparties !== null) { lines.push(`5. Прочие или неактивные в выбранном окне: ${otherCounterparties}.`); @@ -189,10 +189,10 @@ export function composeCounterpartyAnalyticsReply( } if (focus === "suppliers_only") { - lines.push(`Контрагентов только в роли поставщика: ${supplierOnly}.`); + lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`); } if (focus === "customers_only") { - lines.push(`Контрагентов только в роли заказчика: ${customerOnly}.`); + lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`); } if (focus === "mixed_only") { lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`); @@ -439,6 +439,10 @@ export function composeCounterpartyAnalyticsReply( ] : [ `Коротко: активных заказчиков ${scopeLabel} — ${counterparties.length}.`, + `Собран профиль активности заказчиков ${scopeLabel}.`, + requestedYear + ? `Активные заказчики в ${requestedYear} году: ${counterparties.length}.` + : `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, `Оценка собрана по подтвержденным платежным документам: ${rows.length} строк в выборке.` ]; @@ -724,7 +728,7 @@ export function composeCounterpartyAnalyticsReply( lines.push( ...visible.map( (item, index) => - `${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}` + `${index + 1}. ${item.name} | max single: ${item.maxSingle} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}` ) ); return buildFactualListReply(lines); @@ -753,7 +757,7 @@ export function composeCounterpartyAnalyticsReply( const visible = rankedDealsTop.slice(0, limit); const heading = isSupplier ? `Топ-${visible.length} самых крупных разовых выплат поставщикам:` - : `Топ-${visible.length} самых крупных разовых поступлений:`; + : `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`; lines.unshift(heading); lines.push( ...visible.map( diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index e011840..fb4c3ca 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -1788,6 +1788,15 @@ export function runAddressDecomposeStage( warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])], semantic_frame: extractedFilters.semantic_frame }; + if ( + (intent.intent === "list_open_contracts" || intent.intent === "open_contracts_confirmed_as_of_date") && + typeof filters.extracted_filters.as_of_date === "string" && + typeof filters.extracted_filters.period_to === "string" && + filters.extracted_filters.as_of_date === filters.extracted_filters.period_to && + !filters.warnings.includes("as_of_date_derived_from_period_for_open_contracts") + ) { + filters.warnings.push("as_of_date_derived_from_period_for_open_contracts"); + } const followupContextApplied = Boolean(effectiveFollowupContext) && (mode.reasons.includes("address_mode_from_followup_context") || @@ -1799,6 +1808,7 @@ export function runAddressDecomposeStage( ...shape.reasons, ...intent.reasons, ...followupMerged.reasons, + ...filters.warnings.filter((reason) => reason === "as_of_date_derived_from_period_for_open_contracts"), ...(followupContextApplied ? ["address_followup_context_applied"] : []) ];