diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index b86c1cd..e5f228e 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -159,7 +159,7 @@ function toIsoDate(year, month, day) { return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } function extractAsOfDate(text) { - if (/\b(сегодня|на\s+сегодня|today|as\s+of\s+today)\b/i.test(text)) { + if (/\b(сегодня|на\s+сегодня|на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)\b/i.test(text)) { return new Date().toISOString().slice(0, 10); } const ymd = text.match(DATE_YMD_PATTERN); diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 7c021d2..24e3b5d 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -559,7 +559,7 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text) { if (!hasVatLexeme) { return false; } - const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test(text); + const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(text); if (!hasPaymentCue) { return false; } @@ -584,7 +584,7 @@ function hasVatPayableConfirmedSignal(text) { if (!hasVatLexeme) { return false; } - const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test(text); + const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(text); if (!hasPaymentCue) { return false; } diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index d57f8f4..92d7b94 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -2973,7 +2973,15 @@ class AddressQueryService { }; } } - if (filteredRows.length === 0) { + const allowConfirmedAsOfZeroSnapshot = filteredRows.length === 0 && + (composeIntent === "vat_payable_confirmed_as_of_date" || + composeIntent === "payables_confirmed_as_of_date" || + composeIntent === "receivables_confirmed_as_of_date") && + (stageStatus === "no_raw_rows" || stageStatus === "materialized_but_filtered_out_by_recipe") && + !toNonEmptyFilterValue(filters.extracted_filters.counterparty) && + !toNonEmptyFilterValue(filters.extracted_filters.contract) && + !toNonEmptyFilterValue(filters.extracted_filters.document_ref); + if (filteredRows.length === 0 && !allowConfirmedAsOfZeroSnapshot) { const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0; const hadAnchorMatchedRows = filterByAnchors.length > 0; const isVisibilityGapCandidate = hadBaseRows && diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 675fe01..3e5164d 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -27,6 +27,9 @@ function hasSameDateHint(text) { function hasExplicitPeriodLiteral(text) { return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? "")); } +function hasExplicitCurrentDateHint(text) { + return /(?:на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодня|сегодня|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)/iu.test(String(text ?? "")); +} function hasOpenItemsHint(text) { return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? "")); } @@ -358,7 +361,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("as_of_date_from_followup_context"); } } - if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage)) { + if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage) && !hasExplicitCurrentDateHint(userMessage)) { const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; const currentAsOfDate = toNonEmptyString(merged.as_of_date); const todayIso = new Date().toISOString().slice(0, 10); @@ -401,7 +404,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("as_of_date_from_followup_context"); } } - if (!sameDateRequested && hasFollowupSignalForConfirmed && !hasExplicitPeriodLiteral(userMessage)) { + if (!sameDateRequested && + hasFollowupSignalForConfirmed && + !hasExplicitPeriodLiteral(userMessage) && + !hasExplicitCurrentDateHint(userMessage)) { const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; const currentAsOfDate = toNonEmptyString(merged.as_of_date); const todayIso = new Date().toISOString().slice(0, 10); @@ -433,6 +439,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { } const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage); const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage); + const hasExplicitCurrentDateInMessage = hasExplicitCurrentDateHint(userMessage); + const asOfPrimaryIntent = intent === "account_balance_snapshot" || + intent === "documents_forming_balance" || + intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date" || + intent === "vat_payable_confirmed_as_of_date"; const currentHasPeriod = hasExplicitPeriodWindow(merged); const previousHasPeriod = hasExplicitPeriodWindow(previous); if ((intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period") && @@ -453,7 +465,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("period_from_followup_context"); } } - if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal) { + if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !(asOfPrimaryIntent && hasExplicitCurrentDateInMessage)) { if (previousPeriodFrom) { merged.period_from = previousPeriodFrom; } diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 36cfb91..8c830ba 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -1044,6 +1044,20 @@ function countTokens(text) { function hasPeriodLiteral(text) { return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text); } +function hasShortNamedPeriodFollowupLiteral(text) { + const normalized = compactWhitespace(String(text ?? "").toLowerCase()); + if (!normalized) { + return false; + } + return /^(?:(?:\u0430|a|\u0438|i)\s+)?(?:\u043d\u0430|\u0437\u0430|\u043f\u043e|na|za|for)\s+(?:(?:\u044f\u043d\u0432(?:\u0430\u0440)?|\u0444\u0435\u0432(?:\u0440\u0430\u043b)?|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440(?:\u0435\u043b)?|\u043c\u0430(?:\u0439|\u044f)|\u0438\u044e\u043d(?:\u044c)?|\u0438\u044e\u043b(?:\u044c)?|\u0430\u0432\u0433(?:\u0443\u0441\u0442)?|\u0441\u0435\u043d\u0442(?:\u044f\u0431\u0440)?|\u043e\u043a\u0442(?:\u044f\u0431\u0440)?|\u043d\u043e\u044f(?:\u0431\u0440)?|\u0434\u0435\u043a(?:\u0430\u0431\u0440)?)(?:[\u0430-\u044f\u0451]*)?|q[1-4]|(?:[1-4]|[ivx]{1,4})\s*(?:-?\u0439)?\s*\u043a\u0432(?:\.|\u0430\u0440\u0442(?:\u0430\u043b(?:\u0430|\u0435|\u0443|\u043e\u043c)?)?)?|(?:20\d{2})\s*(?:\u0433(?:\.|\u043e\u0434(?:\u0430|\u0443|\u043e\u043c)?)?))(?=$|[\s,.;:!?])/iu.test(normalized); +} +function hasShortCurrentDateFollowupLiteral(text) { + const normalized = compactWhitespace(String(text ?? "").toLowerCase()); + if (!normalized) { + return false; + } + return /^(?:(?:\u0430|a|\u0438|i)\s+)?(?:(?:\u043d\u0430|na)\s+)?(?:(?:\u0442\u0435\u043a\u0443\u0449(?:[\u0430-\u044f\u0451]+)?\s+\u0434\u0430\u0442(?:[\u0430-\u044f\u0451]+)?|(?:\u043d\u0430\s+)?\u0441\u0435\u0433\u043e\u0434\u043d(?:\u044f|\u0435\u0448\u043d(?:[\u0430-\u044f\u0451]+)?\s+\u0434\u0430\u0442(?:[\u0430-\u044f\u0451]+)?)|(?:\u043d\u0430\s+)?\u0442\u0435\u043a\u0443\u0449(?:[\u0430-\u044f\u0451]+)?\s+\u043c\u043e\u043c\u0435\u043d\u0442|today|current\s+date|current\s+moment|now)|(?:\u043f\u043e\s+\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044e\s+\u043d\u0430|as\s+of)\s+(?:\u0441\u0435\u0433\u043e\u0434\u043d\u044f|today|current\s+date))(?=$|[\s,.;:!?])/iu.test(normalized); +} function hasStandaloneAddressTopicSignal(text) { const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); if (!normalized) { @@ -2594,6 +2608,11 @@ function hasAddressFollowupContextSignal(userMessage) { if (shortVatCue) { return true; } + const shortCurrentDateCue = shortFollowup && + samples.some((sample) => hasShortCurrentDateFollowupLiteral(sample)); + if (shortCurrentDateCue) { + return true; + } if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) { return true; } @@ -2646,6 +2665,9 @@ function hasAddressFollowupContextSignal(userMessage) { if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { return true; } + if (shortFollowup && samples.some((sample) => hasShortNamedPeriodFollowupLiteral(sample))) { + return true; + } return false; } function hasShortDebtMirrorFollowupSignal(userMessage) { diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 489883e..7141340 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -176,7 +176,11 @@ function toIsoDate(year: number, month: number, day: number): string | null { } function extractAsOfDate(text: string): string | undefined { - if (/\b(сегодня|на\s+сегодня|today|as\s+of\s+today)\b/i.test(text)) { + if ( + /\b(сегодня|на\s+сегодня|на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)\b/i.test( + text + ) + ) { return new Date().toISOString().slice(0, 10); } diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index e9807fd..bbc6124 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -1,4 +1,4 @@ -import type { AddressIntentResolution } from "../types/addressQuery"; +import type { AddressIntentResolution } from "../types/addressQuery"; const RECEIVABLES_STRONG = [ "кто должен нам", @@ -608,7 +608,7 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text: string): boolean { return false; } const hasPaymentCue = - /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test( + /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test( text ); if (!hasPaymentCue) { @@ -646,7 +646,7 @@ function hasVatPayableConfirmedSignal(text: string): boolean { return false; } const hasPaymentCue = - /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test( + /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test( text ); if (!hasPaymentCue) { diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index cdac566..ea1baee 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -3620,7 +3620,16 @@ export class AddressQueryService { } } - if (filteredRows.length === 0) { + const allowConfirmedAsOfZeroSnapshot = + filteredRows.length === 0 && + (composeIntent === "vat_payable_confirmed_as_of_date" || + composeIntent === "payables_confirmed_as_of_date" || + composeIntent === "receivables_confirmed_as_of_date") && + (stageStatus === "no_raw_rows" || stageStatus === "materialized_but_filtered_out_by_recipe") && + !toNonEmptyFilterValue(filters.extracted_filters.counterparty) && + !toNonEmptyFilterValue(filters.extracted_filters.contract) && + !toNonEmptyFilterValue(filters.extracted_filters.document_ref); + if (filteredRows.length === 0 && !allowConfirmedAsOfZeroSnapshot) { const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0; const hadAnchorMatchedRows = filterByAnchors.length > 0; const isVisibilityGapCandidate = diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 578f074..85b2293 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -62,6 +62,12 @@ function hasExplicitPeriodLiteral(text: string): boolean { return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? "")); } +function hasExplicitCurrentDateHint(text: string): boolean { + return /(?:на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодня|сегодня|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)/iu.test( + String(text ?? "") + ); +} + function hasOpenItemsHint(text: string): boolean { return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? "")); } @@ -449,7 +455,7 @@ function mergeFollowupFilters( reasons.push("as_of_date_from_followup_context"); } } - if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage)) { + if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage) && !hasExplicitCurrentDateHint(userMessage)) { const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; const currentAsOfDate = toNonEmptyString(merged.as_of_date); const todayIso = new Date().toISOString().slice(0, 10); @@ -498,7 +504,12 @@ function mergeFollowupFilters( reasons.push("as_of_date_from_followup_context"); } } - if (!sameDateRequested && hasFollowupSignalForConfirmed && !hasExplicitPeriodLiteral(userMessage)) { + if ( + !sameDateRequested && + hasFollowupSignalForConfirmed && + !hasExplicitPeriodLiteral(userMessage) && + !hasExplicitCurrentDateHint(userMessage) + ) { const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; const currentAsOfDate = toNonEmptyString(merged.as_of_date); const todayIso = new Date().toISOString().slice(0, 10); @@ -535,6 +546,13 @@ function mergeFollowupFilters( const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage); const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage); + const hasExplicitCurrentDateInMessage = hasExplicitCurrentDateHint(userMessage); + const asOfPrimaryIntent = + intent === "account_balance_snapshot" || + intent === "documents_forming_balance" || + intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date" || + intent === "vat_payable_confirmed_as_of_date"; const currentHasPeriod = hasExplicitPeriodWindow(merged); const previousHasPeriod = hasExplicitPeriodWindow(previous); @@ -559,7 +577,7 @@ function mergeFollowupFilters( } } - if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal) { + if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !(asOfPrimaryIntent && hasExplicitCurrentDateInMessage)) { if (previousPeriodFrom) { merged.period_from = previousPeriodFrom; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 7e425c6..22f180d 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -998,6 +998,20 @@ function countTokens(text) { function hasPeriodLiteral(text) { return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text); } +function hasShortNamedPeriodFollowupLiteral(text) { + const normalized = compactWhitespace(String(text ?? "").toLowerCase()); + if (!normalized) { + return false; + } + return /^(?:(?:\u0430|a|\u0438|i)\s+)?(?:\u043d\u0430|\u0437\u0430|\u043f\u043e|na|za|for)\s+(?:(?:\u044f\u043d\u0432(?:\u0430\u0440)?|\u0444\u0435\u0432(?:\u0440\u0430\u043b)?|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440(?:\u0435\u043b)?|\u043c\u0430(?:\u0439|\u044f)|\u0438\u044e\u043d(?:\u044c)?|\u0438\u044e\u043b(?:\u044c)?|\u0430\u0432\u0433(?:\u0443\u0441\u0442)?|\u0441\u0435\u043d\u0442(?:\u044f\u0431\u0440)?|\u043e\u043a\u0442(?:\u044f\u0431\u0440)?|\u043d\u043e\u044f(?:\u0431\u0440)?|\u0434\u0435\u043a(?:\u0430\u0431\u0440)?)(?:[\u0430-\u044f\u0451]*)?|q[1-4]|(?:[1-4]|[ivx]{1,4})\s*(?:-?\u0439)?\s*\u043a\u0432(?:\.|\u0430\u0440\u0442(?:\u0430\u043b(?:\u0430|\u0435|\u0443|\u043e\u043c)?)?)?|(?:20\d{2})\s*(?:\u0433(?:\.|\u043e\u0434(?:\u0430|\u0443|\u043e\u043c)?)?))(?=$|[\s,.;:!?])/iu.test(normalized); +} +function hasShortCurrentDateFollowupLiteral(text) { + const normalized = compactWhitespace(String(text ?? "").toLowerCase()); + if (!normalized) { + return false; + } + return /^(?:(?:\u0430|a|\u0438|i)\s+)?(?:(?:\u043d\u0430|na)\s+)?(?:(?:\u0442\u0435\u043a\u0443\u0449(?:[\u0430-\u044f\u0451]+)?\s+\u0434\u0430\u0442(?:[\u0430-\u044f\u0451]+)?|(?:\u043d\u0430\s+)?\u0441\u0435\u0433\u043e\u0434\u043d(?:\u044f|\u0435\u0448\u043d(?:[\u0430-\u044f\u0451]+)?\s+\u0434\u0430\u0442(?:[\u0430-\u044f\u0451]+)?)|(?:\u043d\u0430\s+)?\u0442\u0435\u043a\u0443\u0449(?:[\u0430-\u044f\u0451]+)?\s+\u043c\u043e\u043c\u0435\u043d\u0442|today|current\s+date|current\s+moment|now)|(?:\u043f\u043e\s+\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044e\s+\u043d\u0430|as\s+of)\s+(?:\u0441\u0435\u0433\u043e\u0434\u043d\u044f|today|current\s+date))(?=$|[\s,.;:!?])/iu.test(normalized); +} function hasStandaloneAddressTopicSignal(text) { const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); if (!normalized) { @@ -2551,6 +2565,11 @@ function hasAddressFollowupContextSignal(userMessage) { if (shortVatCue) { return true; } + const shortCurrentDateCue = shortFollowup && + samples.some((sample) => hasShortCurrentDateFollowupLiteral(sample)); + if (shortCurrentDateCue) { + return true; + } if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) { return true; } @@ -2603,6 +2622,9 @@ function hasAddressFollowupContextSignal(userMessage) { if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { return true; } + if (shortFollowup && samples.some((sample) => hasShortNamedPeriodFollowupLiteral(sample))) { + return true; + } return false; } function hasShortDebtMirrorFollowupSignal(userMessage) { diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index c3ffa7e..2993ff2 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -2093,6 +2093,13 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.intent).toBe("contract_usage_and_value"); }); + it("resolves VAT wording with debt-phrase as confirmed VAT payable intent", () => { + const result = resolveAddressIntent( + "\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017" + ); + expect(result.intent).toBe("vat_payable_confirmed_as_of_date"); + expect(result.reasons).toContain("vat_payable_confirmed_signal_detected"); + }); it("resolves contracts-by-counterparty intent from list wording", () => { const result = resolveAddressIntent("покажи договора все по жуковке 51"); expect(result.intent).toBe("list_contracts_by_counterparty"); @@ -3335,6 +3342,27 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.mcp_call_status).not.toBe("skipped"); }); + it("returns factual confirmed VAT snapshot instead of partial when payable rows are absent", 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(["FACTUAL_LIST", "FACTUAL_SUMMARY"]).toContain(result?.response_type); + expect(result?.reply_type).not.toBe("partial_coverage"); + expect(result?.debug.result_mode).toBe("confirmed_balance"); + expect(result?.debug.balance_confirmed).toBe(true); + }); + + it("keeps mixed VAT + debt wording in VAT lane (not payables/contracts)", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017" + ); + 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.result_mode).toBe("confirmed_balance"); + }); it("routes contracts-by-counterparty intent into dedicated catalog recipe", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("покажи договора все по жуковке 51"); @@ -3755,6 +3783,27 @@ describe("address decompose stage follow-up carryover", () => { expect(result?.baseReasons).toContain("intent_adjusted_to_vat_followup_context"); expect(result?.baseReasons).toContain("as_of_date_from_followup_context"); }); + + it("keeps explicit current-date VAT follow-up and does not inherit stale as-of date", () => { + const result = runAddressDecomposeStage("а на текущую дату", { + previous_intent: "vat_payable_confirmed_as_of_date", + previous_filters: { + period_from: "2016-03-01", + period_to: "2016-03-31", + as_of_date: "2016-03-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + }); + const todayIso = new Date().toISOString().slice(0, 10); + 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(todayIso); + expect(result?.filters.extracted_filters.as_of_date).not.toBe("2016-03-31"); + expect(result?.filters.extracted_filters.period_from).toBeUndefined(); + expect(result?.filters.extracted_filters.period_to).toBeUndefined(); + }); }); describe("address recipe catalog counterparty filtering", () => { @@ -4033,3 +4082,4 @@ describe("address recipe catalog counterparty filtering", () => { }); }); + diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index da39743..b95632f 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -1415,6 +1415,187 @@ describe("assistant address follow-up carryover", () => { expect(normalizerService.normalize).not.toHaveBeenCalled(); }); + it("keeps month-only VAT follow-up phrase in address lane", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = "\u0441\u043a\u043e\u043a \u043d\u0434\u0441 \u043d\u0430\u0434\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c \u0432 \u043d\u0430\u043b\u043e\u0433\u043e\u0432\u0443\u044e \u043d\u0430 \u0444\u0435\u0432\u0440\u0430\u043b\u044c 2017"; + const followupMessage = "\u0430 \u043d\u0430 \u043c\u0430\u0440\u0442"; + + const firstVatResult = buildAddressLaneResult({ + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "vat_liability_confirmed_for_tax_period", + extracted_filters: { + sort: "period_desc", + limit: 20, + period_from: "2017-01-01", + period_to: "2017-03-31" + }, + selected_recipe: "address_vat_liability_confirmed_tax_period_v1", + response_type: "FACTUAL_SUMMARY", + requested_result_mode: "confirmed_balance", + result_mode: "confirmed_balance", + balance_confirmed: true + } + }); + + const followupVatResult = buildAddressLaneResult({ + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "vat_liability_confirmed_for_tax_period", + extracted_filters: { + sort: "period_desc", + limit: 20, + period_from: "2017-01-01", + period_to: "2017-03-31" + }, + selected_recipe: "address_vat_liability_confirmed_tax_period_v1", + response_type: "FACTUAL_SUMMARY", + requested_result_mode: "confirmed_balance", + result_mode: "confirmed_balance", + balance_confirmed: true, + reasons: [ + "address_action_detected", + "vat_liability_confirmed_tax_period_signal_detected", + "address_followup_context_applied" + ] + } + }); + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === firstMessage) { + return firstVatResult; + } + if (!options?.followupContext) { + return null; + } + return followupVatResult; + }) + } 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-march-${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_liability_confirmed_for_tax_period"); + expect(second.debug?.selected_recipe).toBe("address_vat_liability_confirmed_tax_period_v1"); + expect(calls).toHaveLength(2); + expect(calls[1].message).toBe(followupMessage); + expect(calls[1].options?.followupContext?.previous_intent).toBe("vat_liability_confirmed_for_tax_period"); + expect(calls[1].options?.followupContext?.previous_filters?.period_from).toBe("2017-01-01"); + expect(calls[1].options?.followupContext?.previous_filters?.period_to).toBe("2017-03-31"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + + it("keeps 'a na tekushuyu datu' VAT follow-up in address lane", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = "\u0441\u043a\u043e\u043a \u043d\u0430\u0434\u043e \u043d\u0434\u0441 \u043f\u043b\u0430\u0442\u0438\u0442\u044c \u0441 \u0430\u043f\u0440\u0435\u043b\u0435 2017"; + const followupMessage = "\u0430 \u043d\u0430 \u0442\u0435\u043a\u0443\u0449\u0443\u044e \u0434\u0430\u0442\u0443"; + + const vatAsOfResult = buildAddressLaneResult({ + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "vat_payable_confirmed_as_of_date", + extracted_filters: { + sort: "period_desc", + limit: 20, + period_from: "2017-04-01", + period_to: "2017-04-30", + as_of_date: "2017-04-30" + }, + selected_recipe: "address_vat_payable_confirmed_as_of_date_v1", + response_type: "FACTUAL_SUMMARY", + 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 vatAsOfResult; + } + if (!options?.followupContext) { + return null; + } + return vatAsOfResult; + }) + } 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-current-date-${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(calls).toHaveLength(2); + expect(calls[1].message).toBe(followupMessage); + expect(calls[1].options?.followupContext?.previous_intent).toBe("vat_payable_confirmed_as_of_date"); + expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-04-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/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index 509cccf..06d8cbe 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -558,6 +558,40 @@ describe("assistant orchestration contract", () => { expect(decision.livingReason).toBe("address_lane_triggered"); }); + it("keeps 'a na tekushuyu datu' follow-up in address lane when previous VAT context exists", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "а на текущую дату", + effectiveAddressUserMessage: "а на текущую дату", + followupContext: { + previous_intent: "vat_payable_confirmed_as_of_date", + previous_filters: { + period_from: "2016-03-01", + period_to: "2016-03-31", + as_of_date: "2016-03-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + }, + llmPreDecomposeMeta: { + applied: false, + reason: "normalized_fragment_rejected_semantic_guard", + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + } + } 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("keeps explicit address-mode unknown-intent data query in address lane", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: