diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index ccc7d06..31b1280 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -528,6 +528,25 @@ function extractLooseByAnchorValue(text) { } return normalizedToken; } +function extractSpecificCounterpartyRevenueAnchor(text) { + const source = String(text ?? ""); + const normalized = source.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + const hasRevenueCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\w*|\u0434\u043e\u0445\u043e\u0434\w*|revenue|turnover)/iu.test(normalized); + const hasExcludedCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|supplier|vendor|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0434\u043e\u043a\u0438|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0431\u0430\u043d\u043a|\u043f\u043b\u0430\u0442\u0435\u0436|docs?|documents?|contract|bank|payment)/iu.test(normalized); + if (!hasRevenueCue || hasExcludedCue) { + return undefined; + } + const match = source.match(/(?:^|[\s,.;:!?])(?:\u043f\u043e|by|for)\s+([\p{L}\d][\p{L}\d._-]{1,})\s*$/iu) ?? + source.match(/(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\w*|\u0434\u043e\u0445\u043e\u0434\w*|revenue|turnover)\s+(?:(?:\u0431\u044b\u043b(?:\u0430|\u043e)?|was)\s+)?(?:(?:\u0443|\u043f\u043e|by|for)\s+)?([\p{L}\d][\p{L}\d._-]{1,})\s*$/iu); + const token = cleanupAnchorValue(String(match?.[1] ?? "")); + if (!token || !hasStrongCounterpartyTokenShape(token) || !isLikelyCounterpartyToken(token)) { + return undefined; + } + return token; +} function extractContractTokenHeuristic(text) { const source = String(text ?? ""); const explicit = source.match(/(?:№|#|n)\s*([a-zа-яё0-9][a-zа-яё0-9./_-]{1,})/iu); @@ -1494,6 +1513,13 @@ function extractAddressFilters(userMessage, intent) { if (counterpartyMatch && !filters.counterparty) { filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1])); } + if (!filters.counterparty && allowGenericCounterpartyAnchor && intent === "customer_revenue_and_payments") { + const revenueCounterparty = extractSpecificCounterpartyRevenueAnchor(text); + if (revenueCounterparty) { + filters.counterparty = cleanupAnchorValue(revenueCounterparty); + warnings.push("counterparty_anchor_derived_from_revenue_phrase"); + } + } if (!filters.counterparty && allowGenericCounterpartyAnchor && (intent === "list_documents_by_counterparty" || diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 7ce0e70..788ce00 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1467,6 +1467,39 @@ function hasCustomerRevenueRankingBridgeSignal(text) { const hasRevenueAggregateCue = /(?:(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f)\s+(?:\u0443\s+\u043d\u0430\u0441\s+)?\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d\w*\s+\u0433\u043e\u0434|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|скок)\s+(?:\u0432\u043e\u043e\u0431\u0449\u0435\s+)?(?:\u0434\u0435\u043d\u0435\u0433\s+)?\u043c\u044b\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|(?:\u0437\u0430|for)\s+\d{4}\s+\u043c\u044b\s+(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|скок)\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|\u0432\u044b\u0440\u0443\u0447\u043a\w*\s+\u0437\u0430\s+\d{4})/iu.test(normalized); return hasCustomerRankingCue || hasRevenueAggregateCue; } +function hasSpecificCounterpartyRevenueBridgeSignal(text) { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasSupplierCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0432\u0435\u043d\u0434\u043e\u0440|supplier|vendor)/iu.test(normalized); + const hasNonRevenueEntityCue = /(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0434\u043e\u043a\u0438|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0431\u0430\u043d\u043a|\u043f\u043b\u0430\u0442\u0435\u0436|docs?|documents?|contract|bank|payment)/iu.test(normalized); + if (hasSupplierCue || hasNonRevenueEntityCue) { + return false; + } + const hasRevenueCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\w*|\u0434\u043e\u0445\u043e\u0434\w*|revenue|turnover)/iu.test(normalized); + if (!hasRevenueCue) { + return false; + } + const explicitEntityMatch = normalized.match(/(?:^|[\s,.;:!?])(?:\u043f\u043e|by|for)\s+([\p{L}\d][\p{L}\d._-]{1,})\s*$/iu) ?? + normalized.match(/(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\w*|\u0434\u043e\u0445\u043e\u0434\w*|revenue|turnover)\s+(?:(?:\u0431\u044b\u043b(?:\u0430|\u043e)?|was)\s+)?(?:(?:\u0443|\u043f\u043e|by|for)\s+)?([\p{L}\d][\p{L}\d._-]{1,})\s*$/iu); + const entity = explicitEntityMatch?.[1] ? String(explicitEntityMatch[1]).toLowerCase() : null; + if (!entity || /^\d+$/.test(entity)) { + return false; + } + const ignoredEntityTails = new Set([ + "\u043d\u0430\u043c", + "\u043d\u0430\u0441", + "\u0432\u0441\u0435", + "\u0432\u0441\u0435\u043c", + "\u0433\u043e\u0434", + "\u0433\u043e\u0434\u0430", + "\u043c\u0435\u0441\u044f\u0446", + "year", + "month" + ]); + return !ignoredEntityTails.has(entity); +} function hasInventoryProvenanceBridgeSignal(text) { const normalized = String(text ?? "").trim().toLowerCase(); if (!normalized) { @@ -1565,11 +1598,18 @@ function resolveAddressIntent(userMessage) { }; } const hasDirectRevenueAggregateBridge = /(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u043a\u043b\u0438\u0435\u043d\u0442|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f)\s+(?:\u0443\s+\u043d\u0430\u0441\s+)?\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u0433\u043e\u0434|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a).*(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|\u0432\u044b\u0440\u0443\u0447\u0438\u043b\u0438)|\u0432\u044b\u0440\u0443\u0447\u043a\u0430\s+\u0437\u0430\s+\d{4})/iu.test(bridgeText); - if (hasDirectRevenueAggregateBridge || hasCustomerRevenueRankingBridgeSignal(bridgeText)) { + const hasSpecificCounterpartyRevenueBridge = [text, repairedText, turnNoiseNormalizedBridgeText, currentTurnBridgeText].some((sample) => hasSpecificCounterpartyRevenueBridgeSignal(sample)); + if (hasDirectRevenueAggregateBridge || + hasCustomerRevenueRankingBridgeSignal(bridgeText) || + hasSpecificCounterpartyRevenueBridge) { return { intent: "customer_revenue_and_payments", confidence: "medium", - reasons: ["customer_revenue_ranking_bridge_signal_detected"] + reasons: [ + hasSpecificCounterpartyRevenueBridge + ? "specific_counterparty_revenue_bridge_signal_detected" + : "customer_revenue_ranking_bridge_signal_detected" + ] }; } const hasHistoricalInventorySnapshotBridge = [text, repairedText, bridgeText].some((sample) => /(?:\u043e\u0441\u0442\u0430\u0442|inventory|stock|\u0441\u043a\u043b\u0430\u0434|остат|склад)/iu.test(sample) && diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 5774561..62795da 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -400,6 +400,30 @@ function createAssistantTransitionPolicy(deps) { ? deps.hasFollowupMarker(String(alternateMessage ?? "")) || deps.hasReferentialPointer(String(alternateMessage ?? "")) : false); + const hasConcreteFollowupReference = hasPrimaryIndexReferenceSignal || + hasAlternateIndexReferenceSignal || + hasOrganizationClarificationContinuation || + inventoryShortFollowupPrimary || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupPrimary || + hasInventoryRootTemporalFollowupAlternate || + hasInventoryRootRestatementPrimary || + hasInventoryRootRestatementAlternate || + inventoryPurchaseDateVatBridge || + Boolean(debtRoleSwapIntent) || + deps.hasFollowupMarker(userMessage) || + deps.hasReferentialPointer(userMessage) || + (deps.toNonEmptyString(alternateMessage) + ? deps.hasFollowupMarker(String(alternateMessage ?? "")) || + deps.hasReferentialPointer(String(alternateMessage ?? "")) + : false); + const hasCurrentTurnExplicitEntity = Array.isArray(assistantTurnMeaning?.explicit_entity_candidates) && + assistantTurnMeaning.explicit_entity_candidates.length > 0; + if (assistantTurnMeaning?.intent_override_strength === "explicit_current_turn_intent" && + hasCurrentTurnExplicitEntity && + !hasConcreteFollowupReference) { + return null; + } const hasStandaloneAddressTopic = deps.hasStandaloneAddressTopicSignal(userMessage) || (deps.toNonEmptyString(alternateMessage) ? deps.hasStandaloneAddressTopicSignal(alternateMessage) : false); if (hasStandaloneAddressTopic && diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index 49be55a..c3a538b 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -6,6 +6,7 @@ const SUPPORTED_ADDRESS_INTENTS = new Set([ "receivables_confirmed_as_of_date", "payables_confirmed_as_of_date", "list_documents_by_counterparty", + "customer_revenue_and_payments", "inventory_on_hand_as_of_date" ]); function fallbackCompactWhitespace(value) { diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index e39afc5..731f1c6 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -600,6 +600,35 @@ function extractLooseByAnchorValue(text: string): string | undefined { return normalizedToken; } +function extractSpecificCounterpartyRevenueAnchor(text: string): string | undefined { + const source = String(text ?? ""); + const normalized = source.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + const hasRevenueCue = + /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\w*|\u0434\u043e\u0445\u043e\u0434\w*|revenue|turnover)/iu.test( + normalized + ); + const hasExcludedCue = + /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|supplier|vendor|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0434\u043e\u043a\u0438|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0431\u0430\u043d\u043a|\u043f\u043b\u0430\u0442\u0435\u0436|docs?|documents?|contract|bank|payment)/iu.test( + normalized + ); + if (!hasRevenueCue || hasExcludedCue) { + return undefined; + } + const match = + source.match(/(?:^|[\s,.;:!?])(?:\u043f\u043e|by|for)\s+([\p{L}\d][\p{L}\d._-]{1,})\s*$/iu) ?? + source.match( + /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\w*|\u0434\u043e\u0445\u043e\u0434\w*|revenue|turnover)\s+(?:(?:\u0431\u044b\u043b(?:\u0430|\u043e)?|was)\s+)?(?:(?:\u0443|\u043f\u043e|by|for)\s+)?([\p{L}\d][\p{L}\d._-]{1,})\s*$/iu + ); + const token = cleanupAnchorValue(String(match?.[1] ?? "")); + if (!token || !hasStrongCounterpartyTokenShape(token) || !isLikelyCounterpartyToken(token)) { + return undefined; + } + return token; +} + function extractContractTokenHeuristic(text: string): string | undefined { const source = String(text ?? ""); const explicit = source.match(/(?:№|#|n)\s*([a-zа-яё0-9][a-zа-яё0-9./_-]{1,})/iu); @@ -1728,6 +1757,13 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent if (counterpartyMatch && !filters.counterparty) { filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1])); } + if (!filters.counterparty && allowGenericCounterpartyAnchor && intent === "customer_revenue_and_payments") { + const revenueCounterparty = extractSpecificCounterpartyRevenueAnchor(text); + if (revenueCounterparty) { + filters.counterparty = cleanupAnchorValue(revenueCounterparty); + warnings.push("counterparty_anchor_derived_from_revenue_phrase"); + } + } if ( !filters.counterparty && allowGenericCounterpartyAnchor && diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 6c76894..8cea930 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -1822,6 +1822,52 @@ function hasCustomerRevenueRankingBridgeSignal(text: string): boolean { return hasCustomerRankingCue || hasRevenueAggregateCue; } +function hasSpecificCounterpartyRevenueBridgeSignal(text: string): boolean { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasSupplierCue = + /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0432\u0435\u043d\u0434\u043e\u0440|supplier|vendor)/iu.test( + normalized + ); + const hasNonRevenueEntityCue = + /(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0434\u043e\u043a\u0438|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0431\u0430\u043d\u043a|\u043f\u043b\u0430\u0442\u0435\u0436|docs?|documents?|contract|bank|payment)/iu.test( + normalized + ); + if (hasSupplierCue || hasNonRevenueEntityCue) { + return false; + } + const hasRevenueCue = + /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\w*|\u0434\u043e\u0445\u043e\u0434\w*|revenue|turnover)/iu.test( + normalized + ); + if (!hasRevenueCue) { + return false; + } + const explicitEntityMatch = + normalized.match(/(?:^|[\s,.;:!?])(?:\u043f\u043e|by|for)\s+([\p{L}\d][\p{L}\d._-]{1,})\s*$/iu) ?? + normalized.match( + /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\w*|\u0434\u043e\u0445\u043e\u0434\w*|revenue|turnover)\s+(?:(?:\u0431\u044b\u043b(?:\u0430|\u043e)?|was)\s+)?(?:(?:\u0443|\u043f\u043e|by|for)\s+)?([\p{L}\d][\p{L}\d._-]{1,})\s*$/iu + ); + const entity = explicitEntityMatch?.[1] ? String(explicitEntityMatch[1]).toLowerCase() : null; + if (!entity || /^\d+$/.test(entity)) { + return false; + } + const ignoredEntityTails = new Set([ + "\u043d\u0430\u043c", + "\u043d\u0430\u0441", + "\u0432\u0441\u0435", + "\u0432\u0441\u0435\u043c", + "\u0433\u043e\u0434", + "\u0433\u043e\u0434\u0430", + "\u043c\u0435\u0441\u044f\u0446", + "year", + "month" + ]); + return !ignoredEntityTails.has(entity); +} + function hasInventoryProvenanceBridgeSignal(text: string): boolean { const normalized = String(text ?? "").trim().toLowerCase(); if (!normalized) { @@ -1972,11 +2018,22 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti /(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u043a\u043b\u0438\u0435\u043d\u0442|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f)\s+(?:\u0443\s+\u043d\u0430\u0441\s+)?\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u0433\u043e\u0434|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a).*(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|\u0432\u044b\u0440\u0443\u0447\u0438\u043b\u0438)|\u0432\u044b\u0440\u0443\u0447\u043a\u0430\s+\u0437\u0430\s+\d{4})/iu.test( bridgeText ); - if (hasDirectRevenueAggregateBridge || hasCustomerRevenueRankingBridgeSignal(bridgeText)) { + const hasSpecificCounterpartyRevenueBridge = [text, repairedText, turnNoiseNormalizedBridgeText, currentTurnBridgeText].some( + (sample) => hasSpecificCounterpartyRevenueBridgeSignal(sample) + ); + if ( + hasDirectRevenueAggregateBridge || + hasCustomerRevenueRankingBridgeSignal(bridgeText) || + hasSpecificCounterpartyRevenueBridge + ) { return { intent: "customer_revenue_and_payments", confidence: "medium", - reasons: ["customer_revenue_ranking_bridge_signal_detected"] + reasons: [ + hasSpecificCounterpartyRevenueBridge + ? "specific_counterparty_revenue_bridge_signal_detected" + : "customer_revenue_ranking_bridge_signal_detected" + ] }; } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 9b6df1b..0078a23 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -536,6 +536,34 @@ export function createAssistantTransitionPolicy(deps) { ? deps.hasFollowupMarker(String(alternateMessage ?? "")) || deps.hasReferentialPointer(String(alternateMessage ?? "")) : false); + const hasConcreteFollowupReference = + hasPrimaryIndexReferenceSignal || + hasAlternateIndexReferenceSignal || + hasOrganizationClarificationContinuation || + inventoryShortFollowupPrimary || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupPrimary || + hasInventoryRootTemporalFollowupAlternate || + hasInventoryRootRestatementPrimary || + hasInventoryRootRestatementAlternate || + inventoryPurchaseDateVatBridge || + Boolean(debtRoleSwapIntent) || + deps.hasFollowupMarker(userMessage) || + deps.hasReferentialPointer(userMessage) || + (deps.toNonEmptyString(alternateMessage) + ? deps.hasFollowupMarker(String(alternateMessage ?? "")) || + deps.hasReferentialPointer(String(alternateMessage ?? "")) + : false); + const hasCurrentTurnExplicitEntity = + Array.isArray(assistantTurnMeaning?.explicit_entity_candidates) && + assistantTurnMeaning.explicit_entity_candidates.length > 0; + if ( + assistantTurnMeaning?.intent_override_strength === "explicit_current_turn_intent" && + hasCurrentTurnExplicitEntity && + !hasConcreteFollowupReference + ) { + return null; + } const hasStandaloneAddressTopic = deps.hasStandaloneAddressTopicSignal(userMessage) || (deps.toNonEmptyString(alternateMessage) ? deps.hasStandaloneAddressTopicSignal(alternateMessage) : false); diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index 70527fe..02534eb 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -4,6 +4,7 @@ const SUPPORTED_ADDRESS_INTENTS = new Set([ "receivables_confirmed_as_of_date", "payables_confirmed_as_of_date", "list_documents_by_counterparty", + "customer_revenue_and_payments", "inventory_on_hand_as_of_date" ]); diff --git a/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts b/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts index 6128ec2..a212cf0 100644 --- a/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts +++ b/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts @@ -14,4 +14,13 @@ describe("address filter extractor regressions", () => { expect(extracted.warnings).toContain("period_derived_from_month_phrase"); expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability"); }); + it("extracts a compact counterparty tail for customer revenue profile", () => { + const extracted = extractAddressFilters( + "\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a", + "customer_revenue_and_payments" + ); + + expect(extracted.extracted_filters.counterparty).toBe("\u0441\u0432\u043a"); + expect(extracted.warnings).toContain("counterparty_anchor_derived_from_revenue_phrase"); + }); }); diff --git a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts index fa1fd64..0e94907 100644 --- a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts +++ b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts @@ -35,6 +35,23 @@ describe("addressIntentResolver regression bridges", () => { expect(result.intent).toBe("customer_revenue_and_payments"); }); + it("detects specific counterparty turnover wording as revenue profile", () => { + const result = resolveAddressIntent( + "\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a" + ); + + expect(result.intent).toBe("customer_revenue_and_payments"); + expect(result.reasons).toContain("specific_counterparty_revenue_bridge_signal_detected"); + }); + + it("keeps documents by counterparty wording out of revenue bridge", () => { + const result = resolveAddressIntent( + "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a" + ); + + expect(result.intent).toBe("list_documents_by_counterparty"); + }); + it("does not collapse very old stock request into generic inventory snapshot", () => { const result = resolveAddressIntent("Есть ли остатки товара, которые закупались очень давно"); diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 5001278..fff2a89 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -965,4 +965,52 @@ describe("assistantTransitionPolicy", () => { expect(carryover).toBeNull(); }); + + it("drops carryover for a supported current-turn counterparty revenue pivot with a new entity", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Documents by previous counterparty", + debug: { + execution_lane: "address_query", + answer_grounding_check: { status: "grounded" }, + detected_intent: "list_documents_by_counterparty", + extracted_filters: { + counterparty: "Previous Counterparty", + organization: "Org Alt" + }, + anchor_type: "counterparty", + anchor_value_resolved: "Previous Counterparty" + } + }), + hasAddressFollowupContextSignal: () => true, + isImplicitAddressContinuationByLlm: () => true, + resolveAssistantTurnMeaning: () => ({ + schema_version: "assistant_turn_meaning_v1", + asked_domain_family: "counterparty", + asked_action_family: "counterparty_value_or_turnover", + explicit_intent_candidate: "customer_revenue_and_payments", + intent_override_strength: "explicit_current_turn_intent", + explicit_entity_candidates: [ + { + type: "counterparty", + value: "svk", + source: "current_turn_loose_entity_tail" + } + ], + stale_replay_forbidden: false + }), + resolveAddressIntent: () => ({ intent: "customer_revenue_and_payments" }), + resolveAddressIntentFamily: () => "counterparty" + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a", + [], + null, + null, + null + ); + + expect(carryover).toBeNull(); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts index b60a172..938120f 100644 --- a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -33,7 +33,7 @@ describe("assistantTurnMeaningPolicy", () => { expect(meaning.stale_replay_forbidden).toBe(false); }); - it("marks unsupported counterparty turnover as understood and forbids stale replay", () => { + it("promotes specific counterparty turnover to the supported revenue intent", () => { const policy = buildPolicy(); const meaning = policy.resolveAssistantTurnMeaning({ @@ -41,11 +41,12 @@ describe("assistantTurnMeaningPolicy", () => { "\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a" }); - expect(meaning.explicit_intent_candidate).toBeNull(); + expect(meaning.explicit_intent_candidate).toBe("customer_revenue_and_payments"); expect(meaning.asked_domain_family).toBe("counterparty"); expect(meaning.asked_action_family).toBe("counterparty_value_or_turnover"); - expect(meaning.unsupported_but_understood_family).toBe("counterparty_value_or_turnover"); - expect(meaning.stale_replay_forbidden).toBe(true); + expect(meaning.unsupported_but_understood_family).toBeNull(); + expect(meaning.stale_replay_forbidden).toBe(false); + expect(meaning.carryover_budget).toBe("matching_family_only"); expect(meaning.explicit_entity_candidates).toEqual([ { type: "counterparty",