diff --git a/docs/TECH/README.md b/docs/TECH/README.md index a03ae0b..75b05f1 100644 --- a/docs/TECH/README.md +++ b/docs/TECH/README.md @@ -1,10 +1,14 @@ # TECH Docs Index -Актуальные документы по operational-контру ассистента: - -1. `assistant_canon.md` - канон поведения ассистента. -2. `capabilities_registry.json` - реестр поддерживаемых возможностей. -3. `manual_case_decision_schema.json` - схема решений ручной разметки. -4. `ui_markup_system.md` - рабочий процесс разметки через GUI. -5. `history_colibration.md` - сводка статуса и ближайших задач. +Актуальные документы по техническому контуру ассистента: +1. `ARCH_LAYER_FOUNDATION.md` — архитектурный фундамент: разделение слоев `compute` / `navigation` / `conversational`. +2. `STATUS_2026-04-12.md` — текущий статус маршрутов, фиксов и открытых рисков. +3. `address_route_baseline_v1.json` — baseline-срез для анти-регресса по ключевым интентам. +4. `address_route_expectations_v1.json` — ожидания по `intent -> recipe/result_mode`. +5. `capabilities_registry.json` — реестр поддерживаемых capability и границ. +6. `assistant_canon.md` — канон поведения ассистента. +7. `manual_case_decision_schema.json` — схема ручного решения кейсов. +8. `ui_markup_system.md` — правила разметки и UI-процесса. +9. `history_colibration.md` — исторический журнал калибровки. +10. `PLAN_FIX.md` — долгосрочный план безопасного развития маршрутов. diff --git a/docs/TECH/STATUS_2026-04-12.md b/docs/TECH/STATUS_2026-04-12.md new file mode 100644 index 0000000..057f125 --- /dev/null +++ b/docs/TECH/STATUS_2026-04-12.md @@ -0,0 +1,52 @@ +# Статус проекта на 2026-04-12 + +## 1) Что уже стабильно в compute-слое + +- Введены и работают exact-маршруты подтвержденного среза на дату: + - `payables_confirmed_as_of_date` (`address_payables_confirmed_as_of_date_v1`) + - `receivables_confirmed_as_of_date` (`address_receivables_confirmed_as_of_date_v1`) + - `vat_payable_confirmed_as_of_date` (`address_vat_payable_confirmed_as_of_date_v1`) +- Для этих интентов зафиксирован expected route/result mode в: + - `docs/TECH/address_route_expectations_v1.json` +- Режим результата для exact-сценариев закреплен как `confirmed_balance`. + +## 2) Что исправлено в цепных (follow-up) вопросах + +- Исправлен перенос даты среза в коротких продолжениях по долгам: + - после вопроса о долгах на дату follow-up по дебиторке наследует `as_of_date`, если новая дата не задана явно. +- Добавлен короткий follow-up для НДС: + - короткие реплики вида `а ндс?`/`по ндс` теперь корректно идут в VAT exact-route с переносом даты среза из контекста. +- Сохранена стратегия LLM-first нормализации с последующим детерминированным compute-роутингом. + +## 3) Что уже покрыто тестами + +- Добавлены/актуализированы тесты на carryover и follow-up: + - `llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts` + - `llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts` +- Проверен маршрутный baseline: + - `llm_normalizer/backend/tests/addressRouteBaseline.test.ts` + +## 4) Известные ограничения (не считать багом расчета) + +- В разговорных нерелевантных репликах (эмоции/брань/односложные сообщения) система может уйти в `clarification_required`; это относится к conversational-слою, не к compute-расчету. +- `query_shape` в части exact-кейсов может оставаться `UNKNOWN` при корректном `intent`; расчетный маршрут при этом работает корректно. +- Качество бизнес-категоризации контрагентов (особенно по счету 76) требует отдельной донастройки presentation-слоя. + +## 5) Что в приоритете дальше + +1. НДС-контур: усилить доказательную часть расчета "к уплате на дату" и добавить понятную детализацию оснований. +2. Цепные вопросы: закрепить перенос контекста между payables/receivables/VAT во всех коротких follow-up формулировках. +3. Ответы для UI: довести формат вывода до стабильной блочной структуры без markdown-зависимости. +4. Категоризация: отделить поставщиков/заказчиков от банков/госорганов/спецобязательств в итоговой выдаче. + +## 6) Быстрый smoke-check (ручной) + +1. `кому мы должны на сентябрь 2017` +2. `а нам кто должен?` +3. `кто нам должен на сентябрь 2017` +4. `а ндс?` + +Ожидаемое поведение: + +- для 1/3 — `confirmed_balance` в exact-route, +- для 2/4 — корректный follow-up с переносом даты среза, без ухода в эвристический shortlist для exact-интентов. diff --git a/docs/TECH/address_route_baseline_v1.json b/docs/TECH/address_route_baseline_v1.json index ad48014..0d5bfa9 100644 --- a/docs/TECH/address_route_baseline_v1.json +++ b/docs/TECH/address_route_baseline_v1.json @@ -1,6 +1,6 @@ { "schema_version": "address_route_baseline_v1", - "updated_at": "2026-04-12T12:00:00.000Z", + "updated_at": "2026-04-12T20:50:00.000Z", "entries": [ { "intent": "payables_confirmed_as_of_date", @@ -14,6 +14,12 @@ "capability_layer": "compute", "capability_route_mode": "exact" }, + { + "intent": "vat_payable_confirmed_as_of_date", + "capability_id": "confirmed_vat_payable_as_of_date", + "capability_layer": "compute", + "capability_route_mode": "exact" + }, { "intent": "list_payables_counterparties", "capability_id": "payables_candidates_list", diff --git a/docs/TECH/address_route_expectations_v1.json b/docs/TECH/address_route_expectations_v1.json index 6766405..fc8a570 100644 --- a/docs/TECH/address_route_expectations_v1.json +++ b/docs/TECH/address_route_expectations_v1.json @@ -1,6 +1,6 @@ { "schema_version": "address_route_expectations_v1", - "updated_at": "2026-04-12T13:00:00.000Z", + "updated_at": "2026-04-12T20:50:00.000Z", "entries": [ { "intent": "payables_confirmed_as_of_date", @@ -14,6 +14,12 @@ "expected_requested_result_modes": ["confirmed_balance"], "expected_result_modes": ["confirmed_balance"] }, + { + "intent": "vat_payable_confirmed_as_of_date", + "expected_selected_recipes": ["address_vat_payable_confirmed_as_of_date_v1"], + "expected_requested_result_modes": ["confirmed_balance"], + "expected_result_modes": ["confirmed_balance"] + }, { "intent": "list_payables_counterparties", "expected_selected_recipes": ["address_movements_payables_v1", "address_open_items_by_party_or_contract_v1"], diff --git a/docs/TECH/capabilities_registry.json b/docs/TECH/capabilities_registry.json index c651fdf..8784df7 100644 --- a/docs/TECH/capabilities_registry.json +++ b/docs/TECH/capabilities_registry.json @@ -1,6 +1,6 @@ { "schema_version": "capabilities_registry_v1", - "updated_at": "2026-04-09T00:00:00.000Z", + "updated_at": "2026-04-12T20:50:00.000Z", "assistant_mode": "read_only", "groups": [ { @@ -11,6 +11,7 @@ "maturity_status": "partial", "supported_operations": [ "vat_period_snapshot", + "vat_payable_confirmed_as_of_date", "vat_payable_forecast", "vat_turnover_breakdown" ], @@ -32,6 +33,7 @@ "Почему НДС к уплате ноль?" ], "related_routes": [ + "address_vat_payable_confirmed_as_of_date_v1", "address_vat_payable_forecast_v1" ], "safe_alternatives": [ diff --git a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js index b5ed105..56e8aa0 100644 --- a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js +++ b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js @@ -8,7 +8,8 @@ const COMPUTE_EXACT_INTENTS = new Set([ "account_balance_snapshot", "documents_forming_balance", "payables_confirmed_as_of_date", - "receivables_confirmed_as_of_date" + "receivables_confirmed_as_of_date", + "vat_payable_confirmed_as_of_date" ]); const NAVIGATION_INTENTS = new Set([ "list_documents_by_counterparty", @@ -39,6 +40,9 @@ function defaultCapabilityId(intent) { if (intent === "receivables_confirmed_as_of_date") { return "confirmed_receivables_as_of_date"; } + if (intent === "vat_payable_confirmed_as_of_date") { + return "confirmed_vat_payable_as_of_date"; + } if (intent === "list_payables_counterparties") { return "payables_candidates_list"; } @@ -74,6 +78,14 @@ function resolveCapabilityEnabled(intent) { : "receivables_confirmed_route_disabled_by_flag" }; } + if (intent === "vat_payable_confirmed_as_of_date") { + return { + enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, + reason: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 + ? "vat_payable_confirmed_route_enabled" + : "vat_payable_confirmed_route_disabled_by_flag" + }; + } if (intent === "list_payables_counterparties") { return { enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 13d0c96..985c8f4 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -574,6 +574,43 @@ function isLowQualityCounterpartyAnchorValue(rawValue) { if (questionCue && (rankingCue || paymentCue)) { return true; } + const hasTemporalCue = /(?:по\s+состоянию|на\s+дат|на\s+конец|за\s+(?:период|месяц|год|квартал)|\b(?:19|20)\d{2}\b|\bянвар|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(value); + const hasGenericEntityCue = /(?:компан|организац|контрагент|поставщик|клиент|покупател|дебитор|кредитор|counterparty|company|supplier|customer)/iu.test(value); + if (hasTemporalCue && hasGenericEntityCue) { + return true; + } + const lowQualityTimeTokens = new Set([ + "по", + "состоянию", + "состояние", + "на", + "дату", + "дата", + "конец", + "период", + "месяц", + "году", + "год", + "квартал", + "январь", + "февраль", + "март", + "апрель", + "май", + "июнь", + "июль", + "август", + "сентябрь", + "октябрь", + "ноябрь", + "декабрь" + ]); + const meaningfulNonTemporalTokens = tokens.filter((token) => isLikelyCounterpartyToken(token) && + !lowQualityTimeTokens.has(token) && + !/^(?:19|20)\d{2}$/.test(token)); + if (meaningfulNonTemporalTokens.length === 0 && hasTemporalCue) { + return true; + } const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token)); return meaningfulTokens.length === 0; } @@ -751,6 +788,9 @@ function requiredFiltersByIntent(intent) { if (intent === "receivables_confirmed_as_of_date") { return ["as_of_date"]; } + if (intent === "vat_payable_confirmed_as_of_date") { + return ["as_of_date"]; + } if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || intent === "list_contracts_by_counterparty") { @@ -765,7 +805,8 @@ function usesAsOfPrimaryWindow(intent) { return (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || intent === "payables_confirmed_as_of_date" || - intent === "receivables_confirmed_as_of_date"); + intent === "receivables_confirmed_as_of_date" || + intent === "vat_payable_confirmed_as_of_date"); } function extractAddressFilters(userMessage, intent) { const rawText = String(userMessage ?? "").trim(); @@ -928,7 +969,8 @@ function extractAddressFilters(userMessage, intent) { if ((intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "payables_confirmed_as_of_date" || - intent === "receivables_confirmed_as_of_date") && + intent === "receivables_confirmed_as_of_date" || + intent === "vat_payable_confirmed_as_of_date") && !filters.as_of_date) { if (filters.period_to) { filters.as_of_date = filters.period_to; diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 0a1e45a..31a5c30 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -535,10 +535,20 @@ function hasAccountBalanceSignal(text) { } function hasForecastTaxSignal(text) { const hasForecastLexeme = /(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text); - const hasVatLexeme = /(?:ндс|vat)/iu.test(text); const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text); - const hasVatPayableEstimatePattern = /(?:(?:сколько|скока|скок).{0,48}(?:ндс|vat).{0,48}(?:надо|нужно|к\s+уплате|заплатить|уплатить|платеж|платежа|платежей|платежку)|(?:ндс|vat).{0,48}(?:к\s+уплате|надо|нужно|заплатить|уплатить)|(?:сколько|скока|скок).{0,32}(?:надо|нужно).{0,32}(?:заплатить|уплатить).{0,32}(?:ндс|vat))/iu.test(text); - return (hasForecastLexeme && hasTaxLexeme) || (hasVatLexeme && hasVatPayableEstimatePattern); + return hasForecastLexeme && hasTaxLexeme; +} +function hasVatPayableConfirmedSignal(text) { + const hasVatLexeme = /(?:ндс|vat)/iu.test(text); + if (!hasVatLexeme) { + return false; + } + const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test(text); + if (!hasPaymentCue) { + return false; + } + const hasDateOrPeriodCue = /(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(text); + return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text); } function hasPeriodCoverageProfileSignal(text) { if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) { @@ -862,7 +872,7 @@ function hasSupplierTailRiskSignal(text) { return hasSupplier && hasTail && (hasRisk || hasPeriodCue); } function hasPayablesDebtLifecycleSignal(text) { - const hasOweSignal = /(?:кому\s+мы\s+должны|мы\s+должны|кому\s+должны|должн(?:ы|а|о)\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:ск)?)/iu.test(text); + const hasOweSignal = /(?:кому\s+мы\s+долж(?:ен|ны|эны|эна|эно)?|мы\s+долж(?:ен|ны|эны|эна|эно)?|кому\s+долж(?:ен|ны|эны|эна|эно)?|долж[нэ](?:ы|а|о)?\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:[а-яё]{0,6})?)/iu.test(text); if (!hasOweSignal) { return false; } @@ -874,7 +884,7 @@ function hasPayablesDebtLifecycleSignal(text) { return true; } function hasReceivablesDebtLifecycleSignal(text) { - const hasOweUsSignal = /(?:кто\s+нам\s+долж(?:ен|ны)?|кто\s+долж(?:ен|ны)?\s+нам|нам\s+долж(?:ен|ны)|должник(?:и|ов|а)?|дебитор(?:ы|ов|ск)?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test(text); + const hasOweUsSignal = /(?:кто\s+нам\s+долж(?:ен|ны|эны|эна|эно)?|кто\s+долж(?:ен|ны|эны|эна|эно)?\s+нам|нам\s+долж(?:ен|ны|эны|эна|эно)?|должник(?:[а-яё]{0,6})?|дебитор(?:[а-яё]{0,6})?|дебиторск(?:[а-яё]{0,6})?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test(text); if (!hasOweUsSignal) { return false; } @@ -1258,6 +1268,13 @@ function resolveAddressIntent(userMessage) { reasons: ["forecast_tax_signal_detected"] }; } + if (hasVatPayableConfirmedSignal(text)) { + return { + intent: "vat_payable_confirmed_as_of_date", + confidence: "high", + reasons: ["vat_payable_confirmed_signal_detected"] + }; + } if (hasAny(text, RECEIVABLES_STRONG)) { const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text); const reasons = ["receivables_signal_detected"]; diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index ed7f3b9..96c0367 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -29,6 +29,7 @@ const RESULT_SET_TYPE_BY_INTENT = { supplier_payouts_profile: "counterparty_list", list_payables_counterparties: "counterparty_list", payables_confirmed_as_of_date: "balance_snapshot", + vat_payable_confirmed_as_of_date: "balance_snapshot", receivables_confirmed_as_of_date: "balance_snapshot", list_receivables_counterparties: "counterparty_list", list_contracts_by_counterparty: "contract_list", diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index e97f1b3..edae49b 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -648,7 +648,8 @@ function isConfirmedBalanceIntent(intent) { return (intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "payables_confirmed_as_of_date" || - intent === "receivables_confirmed_as_of_date"); + intent === "receivables_confirmed_as_of_date" || + intent === "vat_payable_confirmed_as_of_date"); } function resolveAsOfDateBasis(filters) { const asOfDate = normalizeAnalysisDateHint(filters.as_of_date); @@ -818,7 +819,7 @@ function enforceStrictAccountScopeForIntent(plan, intent) { account_scope_mode: "strict" }; } -function resolveExecutionFiltersForPayablesConfirmedBalance(filters, analysisDate) { +function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate) { const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date); const periodTo = normalizeAnalysisDateHint(filters.period_to); const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null; @@ -1302,6 +1303,9 @@ function buildLimitedOffers(input) { else if (input.intent === "receivables_confirmed_as_of_date") { offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76"); } + else if (input.intent === "vat_payable_confirmed_as_of_date") { + offers.push("показать подтвержденную сумму НДС к уплате на дату среза по счетам 68*"); + } else if (input.intent === "payables_confirmed_as_of_date") { offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76"); } @@ -1348,7 +1352,8 @@ function buildLimitedIntentSignalLine(input) { list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", - payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату." + payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.", + vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату." }; const byShape = { AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.", @@ -1474,16 +1479,15 @@ function buildLimitedExecutionResult(input) { }); const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters); const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(input.reasons, requestedResultMode, undefined, resultSemantics.result_mode); - const reasons = (input.intent.intent === "payables_confirmed_as_of_date" || input.intent.intent === "receivables_confirmed_as_of_date") && - !reasonsWithConfirmedFallback.includes(input.intent.intent === "payables_confirmed_as_of_date" - ? "exact_payables_mode_limited_response" - : "exact_receivables_mode_limited_response") - ? [ - ...reasonsWithConfirmedFallback, - input.intent.intent === "payables_confirmed_as_of_date" - ? "exact_payables_mode_limited_response" - : "exact_receivables_mode_limited_response" - ] + const exactLimitedReason = input.intent.intent === "payables_confirmed_as_of_date" + ? "exact_payables_mode_limited_response" + : input.intent.intent === "receivables_confirmed_as_of_date" + ? "exact_receivables_mode_limited_response" + : input.intent.intent === "vat_payable_confirmed_as_of_date" + ? "exact_vat_payable_mode_limited_response" + : null; + const reasons = exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason) + ? [...reasonsWithConfirmedFallback, exactLimitedReason] : reasonsWithConfirmedFallback; const routeExpectationAudit = input.routeExpectationAudit ?? buildRouteExpectationAudit({ @@ -1591,13 +1595,20 @@ class AddressQueryService { const confirmedBalancePayablesIntent = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") && requestedResultMode === "confirmed_balance"; const confirmedBalanceReceivablesIntent = intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; + const confirmedBalanceVatPayableIntent = intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; const payablesConfirmedExecution = confirmedBalancePayablesIntent - ? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) : null; const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent - ? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) : null; - const executionFilters = payablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ?? filters.extracted_filters; + const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + : null; + const executionFilters = payablesConfirmedExecution?.executionFilters ?? + receivablesConfirmedExecution?.executionFilters ?? + vatPayableConfirmedExecution?.executionFilters ?? + filters.extracted_filters; if (payablesConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) { if (!filters.warnings.includes("as_of_date_derived_for_confirmed_payables")) { @@ -1616,6 +1627,15 @@ class AddressQueryService { baseReasons.push("as_of_date_derived_for_confirmed_receivables"); } } + if (vatPayableConfirmedExecution?.asOfDerived && + !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) { + if (!filters.warnings.includes("as_of_date_derived_for_confirmed_vat_payable")) { + filters.warnings.push("as_of_date_derived_for_confirmed_vat_payable"); + } + if (!baseReasons.includes("as_of_date_derived_for_confirmed_vat_payable")) { + baseReasons.push("as_of_date_derived_for_confirmed_vat_payable"); + } + } const capabilityDecision = (0, addressCapabilityPolicy_1.resolveAddressCapabilityRouteDecision)(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent); const shadowRouteAudit = buildShadowRouteAudit({ @@ -1686,6 +1706,10 @@ class AddressQueryService { !baseReasons.includes("confirmed_balance_exact_receivables_intent")) { baseReasons.push("confirmed_balance_exact_receivables_intent"); } + if (intent.intent === "vat_payable_confirmed_as_of_date" && + !baseReasons.includes("confirmed_balance_exact_vat_payable_intent")) { + baseReasons.push("confirmed_balance_exact_vat_payable_intent"); + } if (requestedResultMode === "confirmed_balance" && recipeIntent === "open_items_by_counterparty_or_contract" && !baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) { @@ -2658,10 +2682,15 @@ class AddressQueryService { routeExpectationAudit: finalRouteExpectationAudit }); } - if (((intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || - (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date")) && - factualResultSemantics.balance_confirmed !== true) { - const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : "receivables"; + const exactConfirmedIntent = (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || + (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date") || + (intent.intent === "vat_payable_confirmed_as_of_date" && composeIntent === "vat_payable_confirmed_as_of_date"); + if (exactConfirmedIntent && factualResultSemantics.balance_confirmed !== true) { + const exactModeName = intent.intent === "payables_confirmed_as_of_date" + ? "payables" + : intent.intent === "receivables_confirmed_as_of_date" + ? "receivables" + : "vat_payable"; return buildLimitedExecutionResult({ mode, shape, @@ -2686,7 +2715,9 @@ class AddressQueryService { materializationDropReason: rowDiagnostics.materializationDropReason, category: "recipe_visibility_gap", reasonText: `exact ${exactModeName} mode: confirmed balance was not proven for the requested as-of slice`, - nextStep: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", + nextStep: intent.intent === "vat_payable_confirmed_as_of_date" + ? "specify as_of_date/organization or provide VAT settlement registers to prove exact VAT payable balance" + : "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`], reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`], capabilityAudit, diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 847273a..ee23896 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -66,6 +66,28 @@ const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` УПОРЯДОЧИТЬ ПО Сумма __ORDER_DIRECTION__ `; +const VAT_PAYABLE_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 + И (__VAT_PAYABLE_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; const BANK_DOCS_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ БанкСписание.Дата КАК Период, @@ -549,6 +571,17 @@ const BASE_RECIPES = [ account_scope_mode: "preferred", query_template: "vat_payable_forecast_profile" }, + { + recipe_id: "address_vat_payable_confirmed_as_of_date_v1", + intent: "vat_payable_confirmed_as_of_date", + purpose: "Build confirmed VAT payable snapshot as-of date from balances on VAT payable accounts", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "limit", "sort"], + default_limit: 200, + account_scope: ["68"], + account_scope_mode: "strict", + query_template: "vat_payable_confirmed_as_of_balance_profile" + }, { recipe_id: "address_contracts_by_counterparty_v1", intent: "list_contracts_by_counterparty", @@ -960,27 +993,27 @@ function buildAddressRecipePlan(recipe, filters) { .replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_68_PREFIXES)) .replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_19_PREFIXES)) .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)) - : 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) + : recipe.query_template === "vat_payable_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_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)); - })() - : recipe.query_template === "receivables_confirmed_as_of_balance_profile" + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__VAT_PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", config_1.VAT_PAYABLE_68_PREFIXES)) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : 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) @@ -992,23 +1025,41 @@ function buildAddressRecipePlan(recipe, filters) { ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .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)); + : recipe.query_template === "receivables_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 RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "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 7ddb947..b9d49eb 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -1969,6 +1969,80 @@ function composeFactualReply(intent, rows, options = {}) { text: lines.join("\n") }; } + if (intent === "vat_payable_confirmed_as_of_date") { + const asOfDate = resolvePayablesAsOfDate(options); + const confirmedRows = rows.filter((row) => { + const amount = row.amount ?? 0; + if (!Number.isFinite(amount) || amount <= 0) { + return false; + } + const section = extractAccountSectionCode(row.account_kt); + return section === "68"; + }); + const byAccount = new Map(); + for (const row of confirmedRows) { + const account = String(row.account_kt ?? "").trim() || "68*"; + const registrator = String(row.registrator ?? "").trim(); + const amount = row.amount ?? 0; + const current = byAccount.get(account); + if (!current) { + byAccount.set(account, { + account, + total: amount, + operations: 1, + lastPeriod: row.period, + refs: registrator ? new Set([registrator]) : new Set() + }); + continue; + } + current.total += amount; + current.operations += 1; + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (registrator) { + current.refs.add(registrator); + } + } + const accountRows = Array.from(byAccount.values()) + .filter((item) => Number.isFinite(item.total) && item.total > 0) + .sort((a, b) => b.total - a.total || b.operations - a.operations || a.account.localeCompare(b.account, "ru")); + const totalVatPayable = accountRows.reduce((sum, item) => sum + item.total, 0); + const lines = [ + `Итого подтвержденный НДС к уплате на ${formatDateRu(asOfDate)}: ${formatMoneyRub(totalVatPayable)}.`, + "", + "Блок 1. Статус результата", + "- Результат: подтвержденный срез НДС к уплате по состоянию на дату.", + "", + "Блок 2. Что учтено", + `- Дата среза: ${formatDateRu(asOfDate)}.`, + "- Контур: остатки по счетам НДС к уплате (68*).", + "", + "Блок 3. Сводка", + `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, + `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, + "", + "Блок 4. Подтвержденные позиции" + ]; + if (accountRows.length > 0) { + lines.push(...accountRows.slice(0, 12).map((item, index) => { + const refs = Array.from(item.refs).slice(0, 2).join("; "); + return `${index + 1}. ${item.account} | остаток НДС к уплате: ${formatMoneyRub(item.total)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${refs ? ` | source refs: ${refs}` : ""}`; + })); + } + else { + lines.push("- Подтвержденный остаток НДС к уплате на дату среза не найден."); + } + return { + responseType: "FACTUAL_LIST", + text: lines.map(emphasizeNumericTokens).join("\n"), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: "strong", + balance_confirmed: true + } + }; + } if (intent === "account_balance_snapshot") { const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0); const lines = [ diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 201571e..8a98d0c 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -30,6 +30,12 @@ function hasExplicitPeriodLiteral(text) { function hasOpenItemsHint(text) { return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? "")); } +function hasVatCue(text) { + return /(?:^|[\s,.;:!?()\-])(?:ндс|vat)(?=$|[\s,.;:!?()\-])/iu.test(String(text ?? "")); +} +function hasVatForecastCue(text) { + return /(?:прогноз|forecast|прикин|оцен|план)/iu.test(String(text ?? "")); +} function hasDocumentSignal(text) { return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? "")); } @@ -349,11 +355,23 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("as_of_date_from_followup_context"); } } + if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage)) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } + } } if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || intent === "payables_confirmed_as_of_date" || - intent === "receivables_confirmed_as_of_date") { + intent === "receivables_confirmed_as_of_date" || + intent === "vat_payable_confirmed_as_of_date") { + const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage); const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const currentContract = toNonEmptyString(merged.contract); const shouldInheritContract = !currentContract || @@ -380,6 +398,16 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("as_of_date_from_followup_context"); } } + if (!sameDateRequested && hasFollowupSignalForConfirmed && !hasExplicitPeriodLiteral(userMessage)) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } + } } if (allTimeRequested) { if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) { @@ -436,6 +464,7 @@ function resolveMissingRequiredFilters(intent, filters) { documents_forming_balance: ["account", "as_of_date"], payables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"], + vat_payable_confirmed_as_of_date: ["as_of_date"], list_documents_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"], @@ -466,6 +495,17 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor); const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor); const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty; + const isVatFollowup = hasVatCue(normalizedMessage); + if (detectedIntent.intent === "unknown" && isVatFollowup) { + const vatIntent = hasVatForecastCue(normalizedMessage) + ? "vat_payable_forecast" + : "vat_payable_confirmed_as_of_date"; + return { + intent: vatIntent, + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_vat_followup_context"] + }; + } if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { return { intent: "open_items_by_counterparty_or_contract", diff --git a/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js b/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js index c80a37c..1492515 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js +++ b/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js @@ -94,7 +94,8 @@ function inferAggregationProfile(intent, shape) { if (intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "payables_confirmed_as_of_date" || - intent === "receivables_confirmed_as_of_date") { + intent === "receivables_confirmed_as_of_date" || + intent === "vat_payable_confirmed_as_of_date") { return "balance_snapshot"; } if (intent === "open_items_by_counterparty_or_contract" || diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index e9f93a5..b7f596e 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2033,7 +2033,7 @@ function textMojibakeScoreForAddress(value) { const source = String(value ?? ""); const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length; const latin = (source.match(/[A-Za-z]/g) ?? []).length; - const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length; + const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ�?’“”•–—™љ›њќћџ]/g) ?? []).length; const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length; const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length; return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2; @@ -2043,7 +2043,7 @@ function looksLikeMojibakeForAddress(value) { if (!source.trim()) { return false; } - if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) { + if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ�?’“”•–—™љ›њќћџ]/.test(source)) { return true; } if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) { @@ -2248,7 +2248,7 @@ function normalizeCounterpartyForFollowupMatch(value) { return compactWhitespace(repairAddressMojibake(String(value ?? "")) .toLowerCase() .replace(/ё/g, "е") - .replace(/[«»"'`“”„’‘]/g, " ") + .replace(/[«»"'`“”„’�?]/g, " ") .replace(/[^a-zа-я0-9\s._-]+/giu, " ")); } function normalizeCounterpartyTokenForFollowupMatch(value) { @@ -2294,7 +2294,7 @@ function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknow if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) { counterpartyCandidate = parts[1] ?? counterpartyCandidate; } - const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "")); + const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`’�?]+|["'«»“”„`’�?]+$/gu, "")); if (!cleanedCandidate || cleanedCandidate.length < 2) { continue; } @@ -2558,61 +2558,110 @@ function isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) { return tokenCount > 0 && tokenCount <= 4; } function hasAddressFollowupContextSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); const repaired = repairAddressMojibake(String(userMessage ?? "")); - const text = compactWhitespace(repaired.toLowerCase()); - if (!text) { + const repairedText = compactWhitespace(repaired.toLowerCase()); + const samples = [rawText, repairedText].filter((item) => item.length > 0); + if (samples.length === 0) { return false; } - if (hasStandaloneAddressTopicSignal(text)) { + const hasAny = (pattern) => samples.some((sample) => pattern.test(sample)); + const hasMarker = () => samples.some((sample) => hasFollowupMarker(sample)); + const hasPointer = () => samples.some((sample) => hasReferentialPointer(sample)); + const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY); + const shortFollowup = minTokens <= 8; + const ultraShortFollowup = minTokens <= 3; + const debtRoleSwapToReceivables = shortFollowup && + /^(?:\u0430|\u0438)\s+(?:\u043d\u0430\u043c\s+)?\u043a\u0442\u043e\b/iu.test(rawText); + if (debtRoleSwapToReceivables) { + return true; + } + const debtRoleSwapToPayables = shortFollowup && + /^(?:\u0430|\u0438)\s+(?:\u043c\u044b\s+)?\u043a\u043e\u043c\u0443\b/iu.test(rawText); + if (debtRoleSwapToPayables) { + return true; + } + const shortContinuationCue = ultraShortFollowup && + /^(?:\u0434\u0430\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u044b\u0430\u0439|\u0435\u0449[\u0435\u0451]|also|again|go|ok|okay)\b/iu.test(rawText); + if (shortContinuationCue) { + return true; + } + const shortVatCue = ultraShortFollowup && + /^(?:(?:\u0430|\u0438)\s+)?(?:(?:\u043f\u043e|po)\s+)?(?:\u043d\u0434\u0441|vat)(?=$|[\s,.;:!?])/iu.test(rawText); + if (shortVatCue) { + return true; + } + if (shortFollowup && hasAny(/^(?:а|и)\s+(?:нам\s+)?кто\b/iu)) { + return true; + } + if (shortFollowup && hasAny(/^(?:а|и)\s+(?:мы\s+)?кому\b/iu)) { + return true; + } + if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)\b/iu)) { + return true; + } + if (hasStandaloneAddressTopicSignal(rawText || repairedText)) { return false; } - if (shouldHandleAsAssistantCapabilityMetaQuery(text)) { + if (shouldHandleAsAssistantCapabilityMetaQuery(rawText || repairedText)) { return false; } - if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) { + if (hasAny(/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu)) { return true; } - if (hasReferentialPointer(text)) { + if (hasPointer()) { return true; } - if (/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu.test(text)) { + if (hasAny(/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu)) { return true; } - const shortFollowup = countTokens(text) <= 8; - if (/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu.test(text)) { + if (hasAny(/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu)) { return true; } - if (/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu.test(text) && countTokens(text) <= 12) { + if (hasAny(/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu) && minTokens <= 12) { return true; } - if (shortFollowup && hasFollowupMarker(text)) { + if (shortFollowup && hasMarker()) { return true; } - if (shortFollowup && /(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu.test(text)) { + if (shortFollowup && hasAny(/(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu)) { + return true; + } + if (shortFollowup && hasAny(/(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu)) { return true; } if (shortFollowup && - /(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu.test(text)) { - return true; - } - if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) { + hasAny(/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu) && + hasAny(/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu)) { return true; } if (shortFollowup && - /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text) && - /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text)) { + hasAny(/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu) && + !hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) { return true; } - if (shortFollowup && - /(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu.test(text) && - !/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu.test(text)) { - return true; - } - if (shortFollowup && hasPeriodLiteral(text)) { + if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { return true; } return false; } +function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { + const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); + if (!normalized || countTokens(normalized) > 10) { + return null; + } + const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized); + const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized); + if ((previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties") && + hasReceivablesCue) { + return "receivables_confirmed_as_of_date"; + } + if ((previousIntent === "receivables_confirmed_as_of_date" || previousIntent === "list_receivables_counterparties") && + hasPayablesCue) { + return "payables_confirmed_as_of_date"; + } + return null; +} function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) { const previousAddressItem = findLastAddressAssistantItem(items); const previousAddressDebug = previousAddressItem?.debug ?? null; @@ -2621,9 +2670,15 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes Boolean(followupOffer?.enabled) && (isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) || (toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); - const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage); + const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent); + const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null; + const debtRoleSwapAlternate = sourceIntentHint && toNonEmptyString(alternateMessage) + ? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) + : null; + const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; + const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary); const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage) - ? hasAddressFollowupContextSignal(alternateMessage) + ? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) : false; const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage) @@ -2632,7 +2687,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal; const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) || (toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false); - if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { + if (hasStandaloneAddressTopic && + !hasPrimaryFollowupSignal && + !hasAlternateFollowupSignal && + !hasImplicitContinuationSignal && + !hasIndexReferenceSignal) { return null; } if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { @@ -2644,6 +2703,9 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent); let previousIntent = sourceIntent; let followupSelectionMode = "carry_previous_intent"; + if (debtRoleSwapIntent) { + previousIntent = debtRoleSwapIntent; + } if (hasImplicitContinuationSignal) { const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) ? toNonEmptyString(followupOffer.suggested_intents[0]) @@ -3725,7 +3787,8 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "list_contracts_by_counterparty", "contract_usage_overview", "contract_usage_and_value", - "vat_payable_forecast" + "vat_payable_forecast", + "vat_payable_confirmed_as_of_date" ]); function resolveAssistantOrchestrationDecision(input) { const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? ""); @@ -4939,14 +5002,14 @@ async function resolveAssistantDataScopeProbe() { }; } const catalogQueryCandidates = [ - "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК Организация ИЗ Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации" + "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН�?Е(Организации.Ссылка) КАК Организация �?З Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация �?З Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация �?З Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН�?Е(Организации.Ссылка) КАК ОрганизацияПредставление �?З Справочник.Организации КАК Организации" ]; const movementProbeCandidates = [ - "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК ОрганизацияПредставление ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ", - "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения" + "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН�?Е(Движения.Организация) КАК ОрганизацияПредставление �?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ�?ТЬ ПО Движения.Период УБЫВ", + "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация �?З РегистрБухгалтерии.Хозрасчетный КАК Движения" ]; let lastError = null; const catalogFacts = { names: [], refs: [], pairs: [] }; @@ -5077,7 +5140,7 @@ function buildAssistantOperationalBoundaryReply() { return [ "Понимаю, что ситуация срочная.", "Я не могу сам настраивать 1С или менять базу/конфигурацию.", - "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа." + "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/�?Т-админа." ].join(" "); } function buildAssistantSafetyRefusalReply() { diff --git a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts index f98584b..207bf64 100644 --- a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts +++ b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts @@ -27,7 +27,8 @@ const COMPUTE_EXACT_INTENTS = new Set([ "account_balance_snapshot", "documents_forming_balance", "payables_confirmed_as_of_date", - "receivables_confirmed_as_of_date" + "receivables_confirmed_as_of_date", + "vat_payable_confirmed_as_of_date" ]); const NAVIGATION_INTENTS = new Set([ "list_documents_by_counterparty", @@ -62,6 +63,9 @@ function defaultCapabilityId(intent: AddressIntent): string { if (intent === "receivables_confirmed_as_of_date") { return "confirmed_receivables_as_of_date"; } + if (intent === "vat_payable_confirmed_as_of_date") { + return "confirmed_vat_payable_as_of_date"; + } if (intent === "list_payables_counterparties") { return "payables_candidates_list"; } @@ -98,6 +102,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re : "receivables_confirmed_route_disabled_by_flag" }; } + if (intent === "vat_payable_confirmed_as_of_date") { + return { + enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, + reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 + ? "vat_payable_confirmed_route_enabled" + : "vat_payable_confirmed_route_disabled_by_flag" + }; + } if (intent === "list_payables_counterparties") { return { enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 5370237..4ce2b33 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -643,6 +643,92 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean { if (questionCue && (rankingCue || paymentCue)) { return true; } + const moneyAsOfPhraseCue = + /(?:денег|деньг|money|cash)/iu.test(value) && + /(?:на\s+(?:данн(?:ую|ой|ая|ое)|эту|ту)\s+дат|on\s+(?:this|that)\s+date|as\s+of\s+(?:this|that)\s+date)/iu.test( + value + ); + if (moneyAsOfPhraseCue) { + return true; + } + const hasTemporalCue = + /(?:по\s+состоянию|на\s+дат|на\s+конец|за\s+(?:период|месяц|год|квартал)|\b(?:19|20)\d{2}\b|\bянвар|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test( + value + ); + const hasGenericEntityCue = + /(?:компан|организац|контрагент|поставщик|клиент|покупател|дебитор|кредитор|counterparty|company|supplier|customer)/iu.test( + value + ); + if (hasTemporalCue && hasGenericEntityCue) { + return true; + } + const lowQualityTimeTokens = new Set([ + "по", + "состоянию", + "состояние", + "на", + "дату", + "дата", + "конец", + "период", + "месяц", + "году", + "год", + "квартал", + "январь", + "февраль", + "март", + "апрель", + "май", + "июнь", + "июль", + "август", + "сентябрь", + "октябрь", + "ноябрь", + "декабрь" + ]); + const lowQualityGenericTokens = new Set([ + "деньги", + "денег", + "деньгам", + "деньгами", + "денежный", + "денежные", + "данную", + "данной", + "данный", + "данное", + "эту", + "этой", + "этот", + "этом", + "ту", + "той", + "тот", + "том", + "вцелом", + "целом" + ]); + const meaningfulNonTemporalTokens = tokens.filter( + (token) => + isLikelyCounterpartyToken(token) && + !lowQualityTimeTokens.has(token) && + !/^(?:19|20)\d{2}$/.test(token) + ); + if (meaningfulNonTemporalTokens.length === 0 && hasTemporalCue) { + return true; + } + const meaningfulNonGenericTokens = tokens.filter( + (token) => + isLikelyCounterpartyToken(token) && + !lowQualityTimeTokens.has(token) && + !lowQualityGenericTokens.has(token) && + !/^(?:19|20)\d{2}$/.test(token) + ); + if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) { + return true; + } const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token)); return meaningfulTokens.length === 0; } @@ -843,6 +929,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array text.includes(item)); } +function hasFlexibleReceivablesDebtSignal(text: string): boolean { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + return ( + /(?:кто(?:\s+\S+){0,4}\s+нам(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || + /(?:нам(?:\s+\S+){0,4}\s+кто(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) + ); +} + +function hasFlexiblePayablesDebtSignal(text: string): boolean { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + return ( + /(?:кому(?:\s+\S+){0,4}\s+мы(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || + /(?:мы(?:\s+\S+){0,4}\s+кому(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) + ); +} + function tokenizeText(text: string): string[] { return String(text ?? "") .toLowerCase() @@ -576,13 +598,27 @@ function hasAccountBalanceSignal(text: string): boolean { function hasForecastTaxSignal(text: string): boolean { const hasForecastLexeme = /(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text); - const hasVatLexeme = /(?:ндс|vat)/iu.test(text); const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text); - const hasVatPayableEstimatePattern = - /(?:(?:сколько|скока|скок).{0,48}(?:ндс|vat).{0,48}(?:надо|нужно|к\s+уплате|заплатить|уплатить|платеж|платежа|платежей|платежку)|(?:ндс|vat).{0,48}(?:к\s+уплате|надо|нужно|заплатить|уплатить)|(?:сколько|скока|скок).{0,32}(?:надо|нужно).{0,32}(?:заплатить|уплатить).{0,32}(?:ндс|vat))/iu.test( + return hasForecastLexeme && hasTaxLexeme; +} + +function hasVatPayableConfirmedSignal(text: string): boolean { + const hasVatLexeme = /(?:ндс|vat)/iu.test(text); + if (!hasVatLexeme) { + return false; + } + const hasPaymentCue = + /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test( text ); - return (hasForecastLexeme && hasTaxLexeme) || (hasVatLexeme && hasVatPayableEstimatePattern); + if (!hasPaymentCue) { + return false; + } + const hasDateOrPeriodCue = + /(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test( + text + ); + return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text); } function hasPeriodCoverageProfileSignal(text: string): boolean { @@ -1006,7 +1042,7 @@ function hasSupplierTailRiskSignal(text: string): boolean { function hasPayablesDebtLifecycleSignal(text: string): boolean { const hasOweSignal = - /(?:кому\s+мы\s+должны|мы\s+должны|кому\s+должны|должн(?:ы|а|о)\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:ск)?)/iu.test( + /(?:кому\s+мы\s+долж(?:ен|ны|эны|эна|эно)?|мы\s+долж(?:ен|ны|эны|эна|эно)?|кому\s+долж(?:ен|ны|эны|эна|эно)?|долж[нэ](?:ы|а|о)?\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:[а-яё]{0,6})?)/iu.test( text ); if (!hasOweSignal) { @@ -1022,7 +1058,7 @@ function hasPayablesDebtLifecycleSignal(text: string): boolean { function hasReceivablesDebtLifecycleSignal(text: string): boolean { const hasOweUsSignal = - /(?:кто\s+нам\s+долж(?:ен|ны)?|кто\s+долж(?:ен|ны)?\s+нам|нам\s+долж(?:ен|ны)|должник(?:и|ов|а)?|дебитор(?:ы|ов|ск)?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test( + /(?:кто\s+нам\s+долж(?:ен|ны|эны|эна|эно)?|кто\s+долж(?:ен|ны|эны|эна|эно)?\s+нам|нам\s+долж(?:ен|ны|эны|эна|эно)?|должник(?:[а-яё]{0,6})?|дебитор(?:[а-яё]{0,6})?|дебиторск(?:[а-яё]{0,6})?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test( text ); if (!hasOweUsSignal) { @@ -1475,11 +1511,23 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti }; } - if (hasAny(text, RECEIVABLES_STRONG)) { - const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text); + if (hasVatPayableConfirmedSignal(text)) { + return { + intent: "vat_payable_confirmed_as_of_date", + confidence: "high", + reasons: ["vat_payable_confirmed_signal_detected"] + }; + } + + if (hasAny(text, RECEIVABLES_STRONG) || hasFlexibleReceivablesDebtSignal(text)) { + const receivablesDebtLifecycleSignal = + hasReceivablesDebtLifecycleSignal(text) || hasFlexibleReceivablesDebtSignal(text); const reasons = ["receivables_signal_detected"]; if (receivablesDebtLifecycleSignal) { reasons.push("receivables_debt_lifecycle_signal_detected"); + if (hasFlexibleReceivablesDebtSignal(text)) { + reasons.push("receivables_signal_detected_flexible_phrase"); + } } return { intent: receivablesDebtLifecycleSignal ? "receivables_confirmed_as_of_date" : "list_receivables_counterparties", @@ -1488,11 +1536,15 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti }; } - if (hasAny(text, PAYABLES_STRONG)) { + if (hasAny(text, PAYABLES_STRONG) || hasFlexiblePayablesDebtSignal(text)) { const reasons = ["payables_signal_detected"]; - const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text); + const payablesDebtLifecycleSignal = + hasPayablesDebtLifecycleSignal(text) || hasFlexiblePayablesDebtSignal(text); if (payablesDebtLifecycleSignal) { reasons.push("payables_debt_lifecycle_signal_detected"); + if (hasFlexiblePayablesDebtSignal(text)) { + reasons.push("payables_signal_detected_flexible_phrase"); + } } return { intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties", diff --git a/llm_normalizer/backend/src/services/addressNavigationState.ts b/llm_normalizer/backend/src/services/addressNavigationState.ts index 7d3c60c..7551b1f 100644 --- a/llm_normalizer/backend/src/services/addressNavigationState.ts +++ b/llm_normalizer/backend/src/services/addressNavigationState.ts @@ -38,6 +38,7 @@ const RESULT_SET_TYPE_BY_INTENT: Partial> = { @@ -1887,19 +1891,17 @@ function buildLimitedExecutionResult(input: { undefined, resultSemantics.result_mode ); + const exactLimitedReason = + input.intent.intent === "payables_confirmed_as_of_date" + ? "exact_payables_mode_limited_response" + : input.intent.intent === "receivables_confirmed_as_of_date" + ? "exact_receivables_mode_limited_response" + : input.intent.intent === "vat_payable_confirmed_as_of_date" + ? "exact_vat_payable_mode_limited_response" + : null; const reasons = - (input.intent.intent === "payables_confirmed_as_of_date" || input.intent.intent === "receivables_confirmed_as_of_date") && - !reasonsWithConfirmedFallback.includes( - input.intent.intent === "payables_confirmed_as_of_date" - ? "exact_payables_mode_limited_response" - : "exact_receivables_mode_limited_response" - ) - ? [ - ...reasonsWithConfirmedFallback, - input.intent.intent === "payables_confirmed_as_of_date" - ? "exact_payables_mode_limited_response" - : "exact_receivables_mode_limited_response" - ] + exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason) + ? [...reasonsWithConfirmedFallback, exactLimitedReason] : reasonsWithConfirmedFallback; const routeExpectationAudit = input.routeExpectationAudit ?? @@ -2014,15 +2016,23 @@ export class AddressQueryService { requestedResultMode === "confirmed_balance"; const confirmedBalanceReceivablesIntent = intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; + const confirmedBalanceVatPayableIntent = + intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; const payablesConfirmedExecution = confirmedBalancePayablesIntent - ? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) : null; const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent - ? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + : null; + const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) : null; const executionFilters = - payablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ?? filters.extracted_filters; + payablesConfirmedExecution?.executionFilters ?? + receivablesConfirmedExecution?.executionFilters ?? + vatPayableConfirmedExecution?.executionFilters ?? + filters.extracted_filters; if ( payablesConfirmedExecution?.asOfDerived && !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0) @@ -2045,6 +2055,17 @@ export class AddressQueryService { baseReasons.push("as_of_date_derived_for_confirmed_receivables"); } } + if ( + vatPayableConfirmedExecution?.asOfDerived && + !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0) + ) { + if (!filters.warnings.includes("as_of_date_derived_for_confirmed_vat_payable")) { + filters.warnings.push("as_of_date_derived_for_confirmed_vat_payable"); + } + if (!baseReasons.includes("as_of_date_derived_for_confirmed_vat_payable")) { + baseReasons.push("as_of_date_derived_for_confirmed_vat_payable"); + } + } const capabilityDecision = resolveAddressCapabilityRouteDecision(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent); const shadowRouteAudit = buildShadowRouteAudit({ @@ -2120,6 +2141,12 @@ export class AddressQueryService { ) { baseReasons.push("confirmed_balance_exact_receivables_intent"); } + if ( + intent.intent === "vat_payable_confirmed_as_of_date" && + !baseReasons.includes("confirmed_balance_exact_vat_payable_intent") + ) { + baseReasons.push("confirmed_balance_exact_vat_payable_intent"); + } if ( requestedResultMode === "confirmed_balance" && recipeIntent === "open_items_by_counterparty_or_contract" && @@ -3227,12 +3254,17 @@ export class AddressQueryService { routeExpectationAudit: finalRouteExpectationAudit }); } - if ( - ((intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || - (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date")) && - factualResultSemantics.balance_confirmed !== true - ) { - const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : "receivables"; + const exactConfirmedIntent = + (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || + (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date") || + (intent.intent === "vat_payable_confirmed_as_of_date" && composeIntent === "vat_payable_confirmed_as_of_date"); + if (exactConfirmedIntent && factualResultSemantics.balance_confirmed !== true) { + const exactModeName = + intent.intent === "payables_confirmed_as_of_date" + ? "payables" + : intent.intent === "receivables_confirmed_as_of_date" + ? "receivables" + : "vat_payable"; return buildLimitedExecutionResult({ mode, shape, @@ -3257,7 +3289,10 @@ export class AddressQueryService { materializationDropReason: rowDiagnostics.materializationDropReason, category: "recipe_visibility_gap", reasonText: `exact ${exactModeName} mode: confirmed balance was not proven for the requested as-of slice`, - nextStep: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", + nextStep: + intent.intent === "vat_payable_confirmed_as_of_date" + ? "specify as_of_date/organization or provide VAT settlement registers to prove exact VAT payable balance" + : "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`], reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`], capabilityAudit, diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index be86810..6c0df1d 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -72,6 +72,29 @@ const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` Сумма __ORDER_DIRECTION__ `; +const VAT_PAYABLE_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 + И (__VAT_PAYABLE_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; + const BANK_DOCS_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ БанкСписание.Дата КАК Период, @@ -566,6 +589,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope_mode: "preferred", query_template: "vat_payable_forecast_profile" }, + { + recipe_id: "address_vat_payable_confirmed_as_of_date_v1", + intent: "vat_payable_confirmed_as_of_date", + purpose: "Build confirmed VAT payable snapshot as-of date from balances on VAT payable accounts", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "limit", "sort"], + default_limit: 200, + account_scope: ["68"], + account_scope_mode: "strict", + query_template: "vat_payable_confirmed_as_of_balance_profile" + }, { recipe_id: "address_contracts_by_counterparty_v1", intent: "list_contracts_by_counterparty", @@ -1057,6 +1091,28 @@ export function buildAddressRecipePlan( .replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_68_PREFIXES)) .replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_19_PREFIXES)) .replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES)) + : recipe.query_template === "vat_payable_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 VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll( + "__VAT_PAYABLE_ACCOUNTS_MATCH__", + buildAccountPrefixPredicate("Остатки.Счет", VAT_PAYABLE_68_PREFIXES) + ) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() : 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" diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 02f031a..f464df2 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -2510,6 +2510,97 @@ export function composeFactualReply( }; } + if (intent === "vat_payable_confirmed_as_of_date") { + const asOfDate = resolvePayablesAsOfDate(options); + const confirmedRows = rows.filter((row) => { + const amount = row.amount ?? 0; + if (!Number.isFinite(amount) || amount <= 0) { + return false; + } + const section = extractAccountSectionCode(row.account_kt); + return section === "68"; + }); + + const byAccount = new Map< + string, + { + account: string; + total: number; + operations: number; + lastPeriod: string | null; + refs: Set; + } + >(); + + for (const row of confirmedRows) { + const account = String(row.account_kt ?? "").trim() || "68*"; + const registrator = String(row.registrator ?? "").trim(); + const amount = row.amount ?? 0; + const current = byAccount.get(account); + if (!current) { + byAccount.set(account, { + account, + total: amount, + operations: 1, + lastPeriod: row.period, + refs: registrator ? new Set([registrator]) : new Set() + }); + continue; + } + current.total += amount; + current.operations += 1; + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (registrator) { + current.refs.add(registrator); + } + } + + const accountRows = Array.from(byAccount.values()) + .filter((item) => Number.isFinite(item.total) && item.total > 0) + .sort((a, b) => b.total - a.total || b.operations - a.operations || a.account.localeCompare(b.account, "ru")); + const totalVatPayable = accountRows.reduce((sum, item) => sum + item.total, 0); + + const lines: string[] = [ + `Итого подтвержденный НДС к уплате на ${formatDateRu(asOfDate)}: ${formatMoneyRub(totalVatPayable)}.`, + "", + "Блок 1. Статус результата", + "- Результат: подтвержденный срез НДС к уплате по состоянию на дату.", + "", + "Блок 2. Что учтено", + `- Дата среза: ${formatDateRu(asOfDate)}.`, + "- Контур: остатки по счетам НДС к уплате (68*).", + "", + "Блок 3. Сводка", + `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, + `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, + "", + "Блок 4. Подтвержденные позиции" + ]; + + if (accountRows.length > 0) { + lines.push( + ...accountRows.slice(0, 12).map((item, index) => { + const refs = Array.from(item.refs).slice(0, 2).join("; "); + return `${index + 1}. ${item.account} | остаток НДС к уплате: ${formatMoneyRub(item.total)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${refs ? ` | source refs: ${refs}` : ""}`; + }) + ); + } else { + lines.push("- Подтвержденный остаток НДС к уплате на дату среза не найден."); + } + + return { + responseType: "FACTUAL_LIST", + text: lines.map(emphasizeNumericTokens).join("\n"), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: "strong", + balance_confirmed: true + } + }; + } + if (intent === "account_balance_snapshot") { const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0); const lines = [ diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index cf56227..0e94d88 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -66,6 +66,14 @@ function hasOpenItemsHint(text: string): boolean { return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? "")); } +function hasVatCue(text: string): boolean { + return /(?:^|[\s,.;:!?()\-])(?:ндс|vat)(?=$|[\s,.;:!?()\-])/iu.test(String(text ?? "")); +} + +function hasVatForecastCue(text: string): boolean { + return /(?:прогноз|forecast|прикин|оцен|план)/iu.test(String(text ?? "")); +} + function hasDocumentSignal(text: string): boolean { return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? "")); } @@ -437,14 +445,26 @@ function mergeFollowupFilters( reasons.push("as_of_date_from_followup_context"); } } + if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage)) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } + } } if ( intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || intent === "payables_confirmed_as_of_date" || - intent === "receivables_confirmed_as_of_date" + intent === "receivables_confirmed_as_of_date" || + intent === "vat_payable_confirmed_as_of_date" ) { + const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage); const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const currentContract = toNonEmptyString(merged.contract); const shouldInheritContract = @@ -474,6 +494,16 @@ function mergeFollowupFilters( reasons.push("as_of_date_from_followup_context"); } } + if (!sameDateRequested && hasFollowupSignalForConfirmed && !hasExplicitPeriodLiteral(userMessage)) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + const currentAsOfDate = toNonEmptyString(merged.as_of_date); + const todayIso = new Date().toISOString().slice(0, 10); + const currentLooksDefaultedToToday = currentAsOfDate === todayIso; + if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } + } } if (allTimeRequested) { @@ -539,6 +569,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi documents_forming_balance: ["account", "as_of_date"], payables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"], + vat_payable_confirmed_as_of_date: ["as_of_date"], list_documents_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"], @@ -577,6 +608,18 @@ function deriveIntentWithFollowupContext( const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor); const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor); const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty; + const isVatFollowup = hasVatCue(normalizedMessage); + + if (detectedIntent.intent === "unknown" && isVatFollowup) { + const vatIntent: AddressIntent = hasVatForecastCue(normalizedMessage) + ? "vat_payable_forecast" + : "vat_payable_confirmed_as_of_date"; + return { + intent: vatIntent, + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_vat_followup_context"] + }; + } if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { return { diff --git a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts index 3533eda..2a1d334 100644 --- a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts +++ b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts @@ -193,7 +193,8 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "payables_confirmed_as_of_date" || - intent === "receivables_confirmed_as_of_date" + intent === "receivables_confirmed_as_of_date" || + intent === "vat_payable_confirmed_as_of_date" ) { return "balance_snapshot"; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index c920b86..906741f 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -1990,7 +1990,7 @@ function textMojibakeScoreForAddress(value) { const source = String(value ?? ""); const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length; const latin = (source.match(/[A-Za-z]/g) ?? []).length; - const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length; + const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ�?’“”•–—™љ›њќћџ]/g) ?? []).length; const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length; const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length; return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2; @@ -2000,7 +2000,7 @@ function looksLikeMojibakeForAddress(value) { if (!source.trim()) { return false; } - if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) { + if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ�?’“”•–—™љ›њќћџ]/.test(source)) { return true; } if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) { @@ -2205,7 +2205,7 @@ function normalizeCounterpartyForFollowupMatch(value) { return compactWhitespace(repairAddressMojibake(String(value ?? "")) .toLowerCase() .replace(/ё/g, "е") - .replace(/[«»"'`“”„’‘]/g, " ") + .replace(/[«»"'`“”„’�?]/g, " ") .replace(/[^a-zа-я0-9\s._-]+/giu, " ")); } function normalizeCounterpartyTokenForFollowupMatch(value) { @@ -2251,7 +2251,7 @@ function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknow if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) { counterpartyCandidate = parts[1] ?? counterpartyCandidate; } - const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "")); + const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`’�?]+|["'«»“”„`’�?]+$/gu, "")); if (!cleanedCandidate || cleanedCandidate.length < 2) { continue; } @@ -2515,61 +2515,133 @@ function isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) { return tokenCount > 0 && tokenCount <= 4; } function hasAddressFollowupContextSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); const repaired = repairAddressMojibake(String(userMessage ?? "")); - const text = compactWhitespace(repaired.toLowerCase()); - if (!text) { + const repairedText = compactWhitespace(repaired.toLowerCase()); + const samples = [rawText, repairedText].filter((item) => item.length > 0); + if (samples.length === 0) { return false; } - if (hasStandaloneAddressTopicSignal(text)) { + const hasAny = (pattern) => samples.some((sample) => pattern.test(sample)); + const hasMarker = () => samples.some((sample) => hasFollowupMarker(sample)); + const hasPointer = () => samples.some((sample) => hasReferentialPointer(sample)); + const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY); + const shortFollowup = minTokens <= 8; + const ultraShortFollowup = minTokens <= 3; + const debtRoleSwapToReceivables = shortFollowup && + (/^(?:\u0430|a|\u0438|i)\s+(?:\u043d\u0430\u043c\s+)?\u043a\u0442\u043e(?=$|[\s,.;:!?])/iu.test(rawText) || + /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(rawText)); + if (debtRoleSwapToReceivables) { + return true; + } + const debtRoleSwapToPayables = shortFollowup && + (/^(?:\u0430|a|\u0438|i)\s+(?:\u043c\u044b\s+)?\u043a\u043e\u043c\u0443(?=$|[\s,.;:!?])/iu.test(rawText) || + /^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(rawText)); + if (debtRoleSwapToPayables) { + return true; + } + const shortContinuationCue = ultraShortFollowup && + (/^(?:\u0434\u0430\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u044b\u0430\u0439|\u0435\u0449[\u0435\u0451]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu.test(rawText) || + /^(?:рґр°рір°р№|рїрѕрєр°р·с‹рір°р№|рїрѕрєр°р·с‹ріс‹р°р№|рµс‰[рµс‘]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu.test(rawText)); + if (shortContinuationCue) { + return true; + } + const shortVatCue = ultraShortFollowup && + /^(?:(?:\u0430|\u0438)\s+)?(?:(?:\u043f\u043e|po)\s+)?(?:\u043d\u0434\u0441|vat)(?=$|[\s,.;:!?])/iu.test(rawText); + if (shortVatCue) { + return true; + } + if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) { + return true; + } + if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu)) { + return true; + } + if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu)) { + return true; + } + if (hasStandaloneAddressTopicSignal(rawText || repairedText)) { return false; } - if (shouldHandleAsAssistantCapabilityMetaQuery(text)) { + if (shouldHandleAsAssistantCapabilityMetaQuery(rawText || repairedText)) { return false; } - if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) { + if (hasAny(/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu)) { return true; } - if (hasReferentialPointer(text)) { + if (hasPointer()) { return true; } - if (/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu.test(text)) { + if (hasAny(/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu)) { return true; } - const shortFollowup = countTokens(text) <= 8; - if (/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu.test(text)) { + if (hasAny(/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu)) { return true; } - if (/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu.test(text) && countTokens(text) <= 12) { + if (hasAny(/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu) && minTokens <= 12) { return true; } - if (shortFollowup && hasFollowupMarker(text)) { + if (shortFollowup && hasMarker()) { return true; } - if (shortFollowup && /(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu.test(text)) { + if (shortFollowup && hasAny(/(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu)) { + return true; + } + if (shortFollowup && hasAny(/(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu)) { return true; } if (shortFollowup && - /(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu.test(text)) { - return true; - } - if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) { + hasAny(/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu) && + hasAny(/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu)) { return true; } if (shortFollowup && - /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text) && - /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text)) { + hasAny(/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu) && + !hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) { return true; } - if (shortFollowup && - /(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu.test(text) && - !/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu.test(text)) { - return true; - } - if (shortFollowup && hasPeriodLiteral(text)) { + if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { return true; } return false; } +function hasShortDebtMirrorFollowupSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); + const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const samples = [rawText, repairedText].filter((item) => item.length > 0); + if (samples.length === 0) { + return false; + } + const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY); + if (minTokens > 8) { + return false; + } + return samples.some((sample) => /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(sample) || + /^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(sample) || + /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(sample) || + /^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample)); +} +function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { + const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); + if (!normalized || countTokens(normalized) > 10) { + return null; + } + const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized) || + /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) || + /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(normalized); + const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized) || + /^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) || + /^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(normalized); + if ((previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties") && + hasReceivablesCue) { + return "receivables_confirmed_as_of_date"; + } + if ((previousIntent === "receivables_confirmed_as_of_date" || previousIntent === "list_receivables_counterparties") && + hasPayablesCue) { + return "payables_confirmed_as_of_date"; + } + return null; +} function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) { const previousAddressItem = findLastAddressAssistantItem(items); const previousAddressDebug = previousAddressItem?.debug ?? null; @@ -2578,9 +2650,15 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes Boolean(followupOffer?.enabled) && (isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) || (toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); - const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage); + const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent); + const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null; + const debtRoleSwapAlternate = sourceIntentHint && toNonEmptyString(alternateMessage) + ? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) + : null; + const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; + const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary); const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage) - ? hasAddressFollowupContextSignal(alternateMessage) + ? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) : false; const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage) @@ -2589,7 +2667,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal; const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) || (toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false); - if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { + if (hasStandaloneAddressTopic && + !hasPrimaryFollowupSignal && + !hasAlternateFollowupSignal && + !hasImplicitContinuationSignal && + !hasIndexReferenceSignal) { return null; } if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { @@ -2601,6 +2683,9 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent); let previousIntent = sourceIntent; let followupSelectionMode = "carry_previous_intent"; + if (debtRoleSwapIntent) { + previousIntent = debtRoleSwapIntent; + } if (hasImplicitContinuationSignal) { const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) ? toNonEmptyString(followupOffer.suggested_intents[0]) @@ -3106,6 +3191,13 @@ function hasSameDateAccountFollowupSignalForPredecompose(text) { /(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(source) || /\b\d{2}(?:[.,]\d{1,2})\b/u.test(source)); } +function hasPredecomposeDiagnosticUncertaintyLead(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + return /^(?:неясно|не\s+ясно|непонятно|не\s+понятно|unclear|not\s+clear|ambiguous|unknown)(?=$|[\s,.;:!?])/iu.test(normalized); +} function attachAddressPredecomposeContract(meta, sourceMessage) { const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? ""); const predecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ @@ -3207,6 +3299,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage const candidateIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(candidate); const sourceIntentKnown = sourceIntentResolution.intent !== "unknown"; const candidateIntentKnown = candidateIntentResolution.intent !== "unknown"; + const candidateStartsWithDiagnosticUncertainty = hasPredecomposeDiagnosticUncertaintyLead(candidate); + if (candidateStartsWithDiagnosticUncertainty && sourceIntentKnown) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_diagnostic_rewrite", + fallbackRuleHit: null, + sanitizedUserMessage + }, userMessage); + } const intentConflict = sourceIntentKnown && candidateIntentKnown && sourceIntentResolution.intent !== candidateIntentResolution.intent; @@ -3473,6 +3579,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll isAddressLlmPreDecomposeCandidate(repairedInputMessage) || hasAccountingSignal(addressInputMessage) || hasAccountingSignal(repairedInputMessage) || + hasShortDebtMirrorFollowupSignal(rawMessageForGate) || + hasShortDebtMirrorFollowupSignal(repairedInputMessage) || sameDateAccountFollowupSignal; const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && @@ -3491,6 +3599,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll !followupContext && !hasClassifierSignal && !hasIntentSignal && + !hasLexicalAddressSignal && !strongDataSignalFromRawMessage && !strongDataSignalFromEffectiveMessage) { return { @@ -3683,7 +3792,8 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "list_contracts_by_counterparty", "contract_usage_overview", "contract_usage_and_value", - "vat_payable_forecast" + "vat_payable_forecast", + "vat_payable_confirmed_as_of_date" ]); export function resolveAssistantOrchestrationDecision(input) { const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? ""); @@ -3764,7 +3874,11 @@ export function resolveAssistantOrchestrationDecision(input) { const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) || hasAddressFollowupContextSignal(repairedRawUserMessage) || hasAddressFollowupContextSignal(effectiveAddressUserMessage) || - hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage); + hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) || + hasShortDebtMirrorFollowupSignal(rawUserMessage) || + hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) || + hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) || + hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && !capabilityMetaQuery && @@ -3879,7 +3993,11 @@ export function resolveAssistantOrchestrationDecision(input) { hasAddressFollowupContextSignal(rawUserMessage) || hasAddressFollowupContextSignal(effectiveAddressUserMessage) || hasAddressFollowupContextSignal(repairedRawUserMessage) || - hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage)); + hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) || + hasShortDebtMirrorFollowupSignal(rawUserMessage) || + hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) || + hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) || + hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage)); const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected && Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) || (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || @@ -4896,14 +5014,14 @@ async function resolveAssistantDataScopeProbe() { }; } const catalogQueryCandidates = [ - "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК Организация ИЗ Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации", - "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации" + "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН�?Е(Организации.Ссылка) КАК Организация �?З Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация �?З Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация �?З Справочник.Организации КАК Организации", + "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН�?Е(Организации.Ссылка) КАК ОрганизацияПредставление �?З Справочник.Организации КАК Организации" ]; const movementProbeCandidates = [ - "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК ОрганизацияПредставление ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ", - "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения" + "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН�?Е(Движения.Организация) КАК ОрганизацияПредставление �?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ�?ТЬ ПО Движения.Период УБЫВ", + "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация �?З РегистрБухгалтерии.Хозрасчетный КАК Движения" ]; let lastError = null; const catalogFacts = { names: [], refs: [], pairs: [] }; @@ -5034,7 +5152,7 @@ function buildAssistantOperationalBoundaryReply() { return [ "Понимаю, что ситуация срочная.", "Я не могу сам настраивать 1С или менять базу/конфигурацию.", - "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа." + "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/�?Т-админа." ].join(" "); } function buildAssistantSafetyRefusalReply() { diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index 58ebfc2..9109905 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -10,6 +10,7 @@ export type AddressIntent = | "supplier_payouts_profile" | "contract_usage_and_value" | "vat_payable_forecast" + | "vat_payable_confirmed_as_of_date" | "list_contracts_by_counterparty" | "list_open_contracts" | "list_payables_counterparties" @@ -131,6 +132,7 @@ export interface AddressRecipeDefinition { | "contract_value_profile" | "contracts_by_counterparty_profile" | "vat_payable_forecast_profile" + | "vat_payable_confirmed_as_of_balance_profile" | "payables_confirmed_as_of_balance_profile" | "receivables_confirmed_as_of_balance_profile"; required_filters: Array; diff --git a/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts index 94bca47..47ab661 100644 --- a/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts +++ b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts @@ -24,6 +24,15 @@ describe("address capability policy", () => { expect(isCapabilityRouteBlocked(decision)).toBe(false); }); + it("maps confirmed VAT payable intent to compute exact capability", () => { + const decision = resolveAddressCapabilityRouteDecision("vat_payable_confirmed_as_of_date"); + expect(decision.capability_id).toBe("confirmed_vat_payable_as_of_date"); + expect(decision.capability_layer).toBe("compute"); + expect(decision.capability_route_mode).toBe("exact"); + expect(decision.capability_route_enabled).toBe(true); + expect(isCapabilityRouteBlocked(decision)).toBe(false); + }); + it("maps document drilldown intent to navigation capability", () => { const decision = resolveAddressCapabilityRouteDecision("list_documents_by_contract"); expect(decision.capability_id).toBe("documents_drilldown"); diff --git a/llm_normalizer/backend/tests/addressDebtLifecycleIntentTypos.test.ts b/llm_normalizer/backend/tests/addressDebtLifecycleIntentTypos.test.ts new file mode 100644 index 0000000..577f36e --- /dev/null +++ b/llm_normalizer/backend/tests/addressDebtLifecycleIntentTypos.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { resolveAddressIntent } from "../src/services/addressIntentResolver"; + +describe("debt lifecycle typo tolerance", () => { + it("routes payables typo phrasing to exact confirmed payables intent", () => { + const result = resolveAddressIntent("кому мы должэны на май 2021"); + expect(result.intent).toBe("payables_confirmed_as_of_date"); + expect(result.reasons).toContain("payables_debt_lifecycle_signal_detected"); + }); + + it("routes receivables typo phrasing to exact confirmed receivables intent", () => { + const result = resolveAddressIntent("кто нам должэны на июль 2020"); + expect(result.intent).toBe("receivables_confirmed_as_of_date"); + expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected"); + }); +}); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 941c125..5d5805c 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -1855,6 +1855,12 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.reasons).toContain("payables_debt_lifecycle_signal_detected"); }); + it("resolves repair phrasing 'кто нам в целом должен' as receivables debt lifecycle intent", () => { + const result = resolveAddressIntent("нет вопрос кто нам в целом должен на денег на эту дату"); + expect(result.intent).toBe("receivables_confirmed_as_of_date"); + expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected"); + }); + it("keeps out-of-scope supplier control wording as unknown intent", () => { const result = resolveAddressIntent( "Какие поставщики у нас уже пару месяцев сдают акты без приходок. Может, их надо проконтролировать отдельно чтоб не засорять бухгалтерию дальше?" @@ -2022,6 +2028,15 @@ describe("address filter extraction for balance drilldown", () => { expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality"); }); + it("drops pseudo-counterparty 'деньги на данную дату' from diagnostic rewrite phrase", () => { + const extracted = extractAddressFilters( + "Неясно, кто должен компании деньги на данную дату.", + "unknown" + ); + expect(extracted.extracted_filters.counterparty).toBeUndefined(); + expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality"); + }); + it("does not capture narrative filler as counterparty in broad docs-vs-money question", () => { const extracted = extractAddressFilters( "В каких случаях мы видим ситуацию, когда документы есть, а денег нет и пока не предвидится?", @@ -3243,6 +3258,26 @@ describe("address decompose stage follow-up carryover", () => { expect(result?.baseReasons).toContain("address_followup_context_applied"); }); + it("inherits as_of_date for receivables follow-up without explicit period", () => { + const result = runAddressDecomposeStage("\u0430 \u043d\u0430\u043c \u043a\u0442\u043e \u0434\u043e\u043b\u0436\u0435\u043d?.", { + previous_intent: "receivables_confirmed_as_of_date", + previous_filters: { + period_from: "2017-09-01", + period_to: "2017-09-30", + as_of_date: "2017-09-30" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + }); + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date"); + expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01"); + expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2017-09-30"); + expect(result?.baseReasons).toContain("as_of_date_from_followup_context"); + expect(result?.baseReasons).toContain("address_followup_context_applied"); + }); + it("keeps contract scope when follow-up asks for bank operations without explicit anchor", () => { const result = runAddressDecomposeStage("а теперь банковские операции", { previous_intent: "list_documents_by_contract", @@ -3415,6 +3450,27 @@ describe("address decompose stage follow-up carryover", () => { result?.baseReasons?.includes("intent_from_followup_context") ).toBe(true); }); + + it("promotes short 'а ндс?' follow-up to confirmed VAT intent with inherited as-of date", () => { + const result = runAddressDecomposeStage("\u0430 \u043d\u0434\u0441?", { + previous_intent: "payables_confirmed_as_of_date", + previous_filters: { + period_from: "2017-09-01", + period_to: "2017-09-30", + as_of_date: "2017-09-30" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + }); + expect(result).not.toBeNull(); + expect(result?.mode.mode).toBe("address_query"); + expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2017-09-30"); + expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01"); + expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30"); + expect(result?.baseReasons).toContain("intent_adjusted_to_vat_followup_context"); + expect(result?.baseReasons).toContain("as_of_date_from_followup_context"); + }); }); describe("address recipe catalog counterparty filtering", () => { diff --git a/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts b/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts index 18fcd34..6a49704 100644 --- a/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts +++ b/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts @@ -7,12 +7,36 @@ import { evaluateAddressRouteExpectation } from "../src/services/addressRouteExp import { AddressQueryService } from "../src/services/addressQueryService"; describe("receivables confirmed as-of route", () => { + it("routes canonical debtor phrasing into exact receivables intent", () => { + const result = resolveAddressIntent("кто является дебитором компании по состоянию на июль 2020 года"); + expect(result.intent).toBe("receivables_confirmed_as_of_date"); + expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected"); + }); + + it("keeps exact receivables route for canonical debtor phrasing in runtime", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("кто является дебитором компании по состоянию на июль 2020 года"); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("receivables_confirmed_as_of_date"); + expect(result?.debug.selected_recipe).toBe("address_receivables_confirmed_as_of_date_v1"); + expect(result?.debug.requested_result_mode).toBe("confirmed_balance"); + expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); + }); + it("routes 'кто нам должен' wording into exact receivables intent", () => { const result = resolveAddressIntent("кто нам должен на июль 2020"); expect(result.intent).toBe("receivables_confirmed_as_of_date"); expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected"); }); + it("drops low-quality counterparty anchor from as-of debtor phrasing", () => { + const extracted = extractAddressFilters( + "кто является дебитором компании по состоянию на июль 2020 года", + "receivables_confirmed_as_of_date" + ); + expect(extracted.extracted_filters.counterparty).toBeUndefined(); + }); + it("selects confirmed receivables recipe and builds balance query", () => { const filters = extractAddressFilters("кто нам должен на июль 2020", "receivables_confirmed_as_of_date").extracted_filters; const selected = selectAddressRecipe("receivables_confirmed_as_of_date", filters); diff --git a/llm_normalizer/backend/tests/addressRouteExpectations.test.ts b/llm_normalizer/backend/tests/addressRouteExpectations.test.ts index 470c33a..53469a3 100644 --- a/llm_normalizer/backend/tests/addressRouteExpectations.test.ts +++ b/llm_normalizer/backend/tests/addressRouteExpectations.test.ts @@ -34,6 +34,17 @@ describe("address route expectations contract", () => { expect(audit.reason).toBe("route_expectation_matched"); }); + it("matches expected recipe and result mode for exact VAT payable route", () => { + const audit = evaluateAddressRouteExpectation({ + intent: "vat_payable_confirmed_as_of_date", + selectedRecipe: "address_vat_payable_confirmed_as_of_date_v1", + requestedResultMode: "confirmed_balance", + resultMode: "confirmed_balance" + }); + expect(audit.status).toBe("matched"); + expect(audit.reason).toBe("route_expectation_matched"); + }); + it("detects selected recipe mismatch", () => { const audit = evaluateAddressRouteExpectation({ intent: "payables_confirmed_as_of_date", diff --git a/llm_normalizer/backend/tests/addressVatConfirmedRoute.test.ts b/llm_normalizer/backend/tests/addressVatConfirmedRoute.test.ts new file mode 100644 index 0000000..c4ebc25 --- /dev/null +++ b/llm_normalizer/backend/tests/addressVatConfirmedRoute.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { resolveAddressIntent } from "../src/services/addressIntentResolver"; +import { extractAddressFilters } from "../src/services/addressFilterExtractor"; +import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog"; +import { resolveAddressCapabilityRouteDecision } from "../src/services/addressCapabilityPolicy"; +import { evaluateAddressRouteExpectation } from "../src/services/addressRouteExpectations"; +import { AddressQueryService } from "../src/services/addressQueryService"; + +describe("vat payable confirmed as-of route", () => { + it("routes VAT payable question into exact confirmed intent", () => { + const result = resolveAddressIntent("сколько НДС к уплате на март 2020"); + expect(result.intent).toBe("vat_payable_confirmed_as_of_date"); + expect(result.reasons).toContain("vat_payable_confirmed_signal_detected"); + }); + + it("keeps VAT forecast intent when explicit forecast wording is used", () => { + const result = resolveAddressIntent("какой прогноз оплаты ндс на март 2020"); + expect(result.intent).toBe("vat_payable_forecast"); + expect(result.reasons).toContain("forecast_tax_signal_detected"); + }); + + it("derives as_of_date for confirmed VAT route from period boundary", () => { + const extracted = extractAddressFilters("сколько НДС к уплате на март 2020", "vat_payable_confirmed_as_of_date"); + expect(extracted.extracted_filters.period_from).toBe("2020-03-01"); + expect(extracted.extracted_filters.period_to).toBe("2020-03-31"); + expect(extracted.extracted_filters.as_of_date).toBe("2020-03-31"); + }); + + it("selects confirmed VAT recipe and builds balance query", () => { + const filters = extractAddressFilters("сколько НДС к уплате на март 2020", "vat_payable_confirmed_as_of_date").extracted_filters; + const selected = selectAddressRecipe("vat_payable_confirmed_as_of_date", filters); + expect(selected.selected_recipe?.recipe_id).toBe("address_vat_payable_confirmed_as_of_date_v1"); + + const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); + expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки"); + expect(plan.query).toContain("СуммаРазвернутыйОстатокКт"); + expect(plan.query).toContain("Остатки.Счет"); + expect(plan.query).toContain("68.02"); + }); + + it("exposes compute exact capability and route expectation for confirmed VAT route", () => { + const capability = resolveAddressCapabilityRouteDecision("vat_payable_confirmed_as_of_date"); + expect(capability.capability_id).toBe("confirmed_vat_payable_as_of_date"); + expect(capability.capability_layer).toBe("compute"); + expect(capability.capability_route_mode).toBe("exact"); + + const expectation = evaluateAddressRouteExpectation({ + intent: "vat_payable_confirmed_as_of_date", + selectedRecipe: "address_vat_payable_confirmed_as_of_date_v1", + requestedResultMode: "confirmed_balance", + resultMode: "confirmed_balance" + }); + expect(expectation.status).toBe("matched"); + }); + + it("uses exact VAT route in runtime for monthly as-of query", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("сколько НДС к уплате на март 2020"); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("vat_payable_confirmed_as_of_date"); + expect(result?.debug.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1"); + expect(result?.debug.requested_result_mode).toBe("confirmed_balance"); + expect(result?.debug.route_expectation_status).toBe("matched"); + expect(result?.debug.limited_reason_category).not.toBe("unsupported"); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index f178576..da39743 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -1081,6 +1081,340 @@ describe("assistant address follow-up carryover", () => { expect(String(calls[0].message).toLowerCase()).toContain("свк"); expect(chatClient.chat).toHaveBeenCalledTimes(0); }); + it("keeps debt lifecycle follow-up context for 'а нам кто должен?.' after payables as-of answer", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = + "\u043a\u043e\u043c\u0443 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017"; + const followupMessage = + "\u0430 \u043d\u0430\u043c \u043a\u0442\u043e \u0434\u043e\u043b\u0436\u0435\u043d?."; + + const payablesResult = buildAddressLaneResult({ + reply_text: + "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u0441\u0442\u0432 \u043a \u043e\u043f\u043b\u0430\u0442\u0435 \u043d\u0430 30.09.2017", + debug: { + ...buildAddressLaneResult().debug, + query_shape: "UNKNOWN", + query_shape_confidence: "low", + detected_intent: "payables_confirmed_as_of_date", + detected_intent_confidence: "high", + extracted_filters: { + sort: "period_desc", + limit: 20, + period_from: "2017-09-01", + period_to: "2017-09-30", + as_of_date: "2017-09-30" + }, + selected_recipe: "address_payables_confirmed_as_of_date_v1", + response_type: "FACTUAL_LIST", + requested_result_mode: "confirmed_balance", + result_mode: "confirmed_balance", + balance_confirmed: true, + reasons: ["payables_debt_lifecycle_signal_detected", "confirmed_balance_exact_payables_intent"] + } + }); + + const receivablesResult = buildAddressLaneResult({ + reply_text: + "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a\u043e\u0439 \u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u0438 \u043d\u0430 30.09.2017", + debug: { + ...buildAddressLaneResult().debug, + query_shape: "UNKNOWN", + query_shape_confidence: "low", + detected_intent: "receivables_confirmed_as_of_date", + detected_intent_confidence: "high", + extracted_filters: { + sort: "period_desc", + limit: 20, + period_from: "2017-09-01", + period_to: "2017-09-30", + as_of_date: "2017-09-30" + }, + selected_recipe: "address_receivables_confirmed_as_of_date_v1", + response_type: "FACTUAL_LIST", + requested_result_mode: "confirmed_balance", + result_mode: "confirmed_balance", + balance_confirmed: true, + reasons: ["receivables_debt_lifecycle_signal_detected", "confirmed_balance_exact_receivables_intent"] + } + }); + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === firstMessage) { + return payablesResult; + } + if (message === followupMessage) { + if (!options?.followupContext) { + return null; + } + return receivablesResult; + } + return null; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-followup-debt-${Date.now()}`; + const first = await service.handleMessage({ + session_id: sessionId, + user_message: firstMessage, + useMock: true + } as any); + expect(first.ok).toBe(true); + expect(first.reply_type).toBe("factual"); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: followupMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(second.debug?.detected_intent).toBe("receivables_confirmed_as_of_date"); + expect(second.debug?.selected_recipe).toBe("address_receivables_confirmed_as_of_date_v1"); + + expect(calls).toHaveLength(2); + expect(calls[1].message).toBe(followupMessage); + expect(calls[1].options?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date"); + expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + + it("mirrors receivables->payables for short follow-up 'a мы кому' and keeps as-of date", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = "кто нам должен на сентябрь 2017"; + const followupMessage = "a мы кому"; + + const receivablesResult = buildAddressLaneResult({ + reply_text: "Подтвержденный срез дебиторской задолженности на 30.09.2017", + debug: { + ...buildAddressLaneResult().debug, + query_shape: "UNKNOWN", + query_shape_confidence: "low", + detected_intent: "receivables_confirmed_as_of_date", + detected_intent_confidence: "high", + extracted_filters: { + sort: "period_desc", + limit: 20, + period_from: "2017-09-01", + period_to: "2017-09-30", + as_of_date: "2017-09-30" + }, + selected_recipe: "address_receivables_confirmed_as_of_date_v1", + response_type: "FACTUAL_LIST", + requested_result_mode: "confirmed_balance", + result_mode: "confirmed_balance", + balance_confirmed: true, + reasons: ["receivables_debt_lifecycle_signal_detected", "confirmed_balance_exact_receivables_intent"] + } + }); + + const payablesResult = buildAddressLaneResult({ + reply_text: "Подтвержденный срез обязательств к оплате на 30.09.2017", + debug: { + ...buildAddressLaneResult().debug, + query_shape: "UNKNOWN", + query_shape_confidence: "low", + detected_intent: "payables_confirmed_as_of_date", + detected_intent_confidence: "high", + extracted_filters: { + sort: "period_desc", + limit: 20, + period_from: "2017-09-01", + period_to: "2017-09-30", + as_of_date: "2017-09-30" + }, + selected_recipe: "address_payables_confirmed_as_of_date_v1", + response_type: "FACTUAL_LIST", + requested_result_mode: "confirmed_balance", + result_mode: "confirmed_balance", + balance_confirmed: true, + reasons: ["payables_debt_lifecycle_signal_detected", "confirmed_balance_exact_payables_intent"] + } + }); + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === firstMessage) { + return receivablesResult; + } + if (message === followupMessage) { + if (!options?.followupContext) { + return null; + } + if (options?.followupContext?.previous_intent !== "payables_confirmed_as_of_date") { + return null; + } + return payablesResult; + } + return null; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-followup-debt-mirror-${Date.now()}`; + const first = await service.handleMessage({ + session_id: sessionId, + user_message: firstMessage, + useMock: true + } as any); + expect(first.ok).toBe(true); + expect(first.reply_type).toBe("factual"); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: followupMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(second.debug?.detected_intent).toBe("payables_confirmed_as_of_date"); + expect(second.debug?.selected_recipe).toBe("address_payables_confirmed_as_of_date_v1"); + + expect(calls).toHaveLength(2); + expect(calls[1].message).toBe(followupMessage); + expect(calls[1].options?.followupContext?.previous_intent).toBe("payables_confirmed_as_of_date"); + expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + + it("keeps short VAT follow-up in address lane after debt as-of answer", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = + "\u043a\u043e\u043c\u0443 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017"; + const followupMessage = "\u0430 \u043d\u0434\u0441?"; + + const payablesResult = buildAddressLaneResult({ + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "payables_confirmed_as_of_date", + extracted_filters: { + sort: "period_desc", + limit: 20, + period_from: "2017-09-01", + period_to: "2017-09-30", + as_of_date: "2017-09-30" + }, + selected_recipe: "address_payables_confirmed_as_of_date_v1", + response_type: "FACTUAL_LIST", + requested_result_mode: "confirmed_balance", + result_mode: "confirmed_balance", + balance_confirmed: true + } + }); + + const vatResult = buildAddressLaneResult({ + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "vat_payable_confirmed_as_of_date", + extracted_filters: { + sort: "period_desc", + limit: 20, + period_from: "2017-09-01", + period_to: "2017-09-30", + as_of_date: "2017-09-30" + }, + selected_recipe: "address_vat_payable_confirmed_as_of_date_v1", + response_type: "FACTUAL_LIST", + requested_result_mode: "confirmed_balance", + result_mode: "confirmed_balance", + balance_confirmed: true + } + }); + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === firstMessage) { + return payablesResult; + } + if (!options?.followupContext) { + return null; + } + return vatResult; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-followup-vat-${Date.now()}`; + const first = await service.handleMessage({ + session_id: sessionId, + user_message: firstMessage, + useMock: true + } as any); + expect(first.ok).toBe(true); + expect(first.reply_type).toBe("factual"); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: followupMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(second.debug?.detected_intent).toBe("vat_payable_confirmed_as_of_date"); + expect(second.debug?.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1"); + + expect(calls).toHaveLength(2); + expect(typeof calls[1].message).toBe("string"); + expect(String(calls[1].message).length).toBeGreaterThan(0); + expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + it("passes active organization scope into address lane follow-up context", async () => { const calls: Array<{ message: string; options?: any }> = []; const addressQueryService = { diff --git a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts index 2dfe592..058a57b 100644 --- a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts @@ -442,6 +442,105 @@ describe("assistant address llm pre-decompose candidate preference", () => { expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_anchor_substitution"); }); + it("rejects diagnostic canonical rewrite like 'Неясно...' for debt-intent repair message", async () => { + const calls: Array<{ message: string }> = []; + const addressQueryService = { + tryHandle: vi.fn(async (message: string) => { + calls.push({ message }); + return buildAddressLaneResult(message); + }) + } as any; + + const sourceMessage = "нет вопрос кто нам в целом должен на денег на эту дату"; + const candidateMessage = "Неясно, кто должен компании деньги на данную дату."; + + const normalizerService = { + normalize: vi.fn(async () => ({ + trace_id: "norm-predecompose-diagnostic-rewrite", + ok: true, + normalized: { + schema_version: "normalized_query_v2_0_2", + user_message_raw: sourceMessage, + message_in_scope: true, + scope_confidence: "medium", + contains_multiple_tasks: false, + fragments: [ + { + fragment_id: "F1", + raw_fragment_text: sourceMessage, + normalized_fragment_text: candidateMessage, + domain_relevance: "in_scope", + business_scope: "company_specific_accounting", + entity_hints: [], + account_hints: [], + document_hints: [], + register_hints: [], + time_scope: { + type: "implicit", + value: null, + confidence: "low" + }, + flags: { + has_multi_entity_scope: false, + asks_for_chain_explanation: false, + asks_for_ranking_or_top: false, + asks_for_period_summary: false, + asks_for_rule_check: false, + asks_for_anomaly_scan: false, + asks_for_exact_object_trace: false, + asks_for_evidence: false, + mentions_period_close_context: false + }, + candidate_labels: ["simple_factual"], + confidence: "medium", + execution_readiness: "executable", + clarification_reason: null, + soft_assumption_used: [], + route_status: "routed", + no_route_reason: null + } + ], + discarded_fragments: [], + global_notes: { + needs_clarification: false, + clarification_reason: null + } + }, + raw_model_output: null, + validation: { passed: true, errors: [] }, + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + latency_ms: 10, + prompt_version: "normalizer_v2_0_2", + schema_version: "v2_0_2", + request_count_for_case: 1 + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const response = await service.handleMessage({ + session_id: `asst-predecompose-diagnostic-rewrite-${Date.now()}`, + user_message: sourceMessage, + llmProvider: "local", + useMock: false + } as any); + + expect(response.ok).toBe(true); + expect(response.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].message).toBe(sourceMessage); + expect(calls[0].message).not.toBe(candidateMessage); + expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_diagnostic_rewrite"); + expect(String(response.debug?.llm_decomposition_effective_message ?? "")).toBe(sourceMessage); + }); + it("rejects follow-up intent injection when llm adds documents to same-date account prompt", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { @@ -1054,7 +1153,8 @@ describe("assistant address llm pre-decompose candidate preference", () => { [ "llm_predecompose_semantic_guard_rejected", "llm_predecompose_unsupported_mode", - "address_signal_unsupported_intent_fallback_to_deep" + "address_signal_unsupported_intent_fallback_to_deep", + "non_domain_query_indexed" ] ).toContain(response.debug?.address_tool_gate_reason); }); diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index b9e075c..509cccf 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -315,6 +315,36 @@ describe("assistant orchestration contract", () => { expect(decision.livingReason).toBe("address_lane_triggered"); }); + it("keeps short mirror follow-up 'a мы кому' in address lane instead of non-domain chat", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "a мы кому", + effectiveAddressUserMessage: "a мы кому", + followupContext: null, + llmPreDecomposeMeta: { + applied: false, + reason: "normalized_fragment_rejected_semantic_guard", + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + }, + semanticExtractionContract: { + valid: false, + apply_canonical_recommended: false, + reason_codes: ["unsupported_low_confidence_contract"] + } + } as any, + useMock: false + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + 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",