From acacada0f634b26fe87889b86fdaa4af2925a132 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 22 Apr 2026 15:07:20 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B7=D0=B0=D0=BC=D0=BA=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=8C=20grounded=20entity=20retarget=20=D0=BD=D0=B0=20va?= =?UTF-8?q?lue-flow=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...s_phase28_entity_value_retarget_chain.json | 95 +++++++++++++++++++ .../assistantMcpDiscoveryTurnInputAdapter.js | 5 +- .../backend/dist/services/assistantService.js | 6 ++ .../services/assistantTransitionPolicy.js | 42 +++++++- .../assistantMcpDiscoveryTurnInputAdapter.ts | 5 +- .../backend/src/services/assistantService.ts | 6 ++ .../src/services/assistantTransitionPolicy.ts | 52 +++++++++- ...istantMcpDiscoveryTurnInputAdapter.test.ts | 74 +++++++++++++++ .../tests/assistantTransitionPolicy.test.ts | 46 ++++++++- 9 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase28_entity_value_retarget_chain.json diff --git a/docs/orchestration/address_truth_harness_phase28_entity_value_retarget_chain.json b/docs/orchestration/address_truth_harness_phase28_entity_value_retarget_chain.json new file mode 100644 index 0000000..4700d21 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase28_entity_value_retarget_chain.json @@ -0,0 +1,95 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase28_entity_value_retarget_chain", + "domain": "address_phase28_entity_value_retarget_chain", + "title": "Phase 28 grounded entity value-flow retarget replay", + "description": "Targeted AGENT replay for Big Block C where a grounded 1C counterparty must survive side-switch and year-switch follow-ups across incoming, payout, and net value-flow questions without forcing the user to restate the resolved name.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_resolve_counterparty_alias", + "title": "Entity resolution grounds the checked 1C counterparty from a loose alias", + "question": "найди в 1С контрагента СВК", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": ["(?i)свк", "(?i)контрагент"], + "required_answer_patterns_any": [ + "(?i)группа\\s+свк", + "(?i)каталог", + "(?i)найден", + "(?i)наиболее вероят" + ], + "forbidden_answer_patterns": [ + "(?i)получили", + "(?i)заплатили", + "(?i)нетто", + "(?i)оборот", + "(?i)выручк", + "(?i)сумм(а|ы)" + ], + "criticality": "critical", + "semantic_tags": ["entity_resolution", "alias_grounding", "followup_anchor"] + }, + { + "step_id": "step_02_incoming_by_resolved_entity", + "title": "Incoming value-flow follow-up reuses the resolved counterparty anchor", + "question": "сколько получили по нему за 2020 год", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": ["(?i)2020", "(?i)получил|входящ|поступ", "(?i)руб"], + "required_answer_patterns_any": ["(?i)группа\\s+свк", "(?i)свк"], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните, какого контрагента", + "(?i)по какому контрагенту" + ], + "criticality": "critical", + "semantic_tags": ["entity_resolution", "incoming_value_flow", "followup_reuse"] + }, + { + "step_id": "step_03_payout_switch_by_resolved_entity", + "title": "Outgoing payment follow-up keeps the same grounded counterparty and checked year", + "question": "а теперь сколько заплатили?", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": ["(?i)2020", "(?i)заплатил|исходящ|списан|платеж", "(?i)руб"], + "required_answer_patterns_any": ["(?i)группа\\s+свк", "(?i)свк"], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните, какого контрагента", + "(?i)по какому контрагенту", + "(?i)за какой год" + ], + "criticality": "critical", + "semantic_tags": ["entity_resolution", "payout_switch", "followup_reuse", "date_carryover"] + }, + { + "step_id": "step_04_year_switch_on_payout", + "title": "Short year switch keeps the payout contour and grounded counterparty", + "question": "а за 2021?", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": ["(?i)2021", "(?i)заплатил|исходящ|списан|платеж", "(?i)руб"], + "required_answer_patterns_any": ["(?i)группа\\s+свк", "(?i)свк"], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните, какого контрагента", + "(?i)по какому контрагенту" + ], + "criticality": "critical", + "semantic_tags": ["entity_resolution", "payout_year_switch", "followup_reuse"] + }, + { + "step_id": "step_05_net_switch_after_payout", + "title": "Net-flow follow-up keeps the same grounded counterparty and switched year", + "question": "а какое нетто?", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": ["(?i)2021", "(?i)нетто|сальдо|разниц", "(?i)руб"], + "required_answer_patterns_any": ["(?i)группа\\s+свк", "(?i)свк"], + "forbidden_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните, какого контрагента", + "(?i)по какому контрагенту", + "(?i)за какой год" + ], + "criticality": "critical", + "semantic_tags": ["entity_resolution", "net_switch", "followup_reuse", "date_carryover"] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 7a228e4..80cad55 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -533,7 +533,10 @@ function buildAssistantMcpDiscoveryTurnInput(input) { (followupSeed.counterparty || followupSeed.discoveryEntity) && (followupSeed.pilotScope === "entity_resolution_search_v1" || followupSeed.pilotScope === "counterparty_document_evidence_query_documents_v1" || - followupSeed.pilotScope === "counterparty_movement_evidence_query_movements_v1")); + followupSeed.pilotScope === "counterparty_movement_evidence_query_movements_v1" || + followupSeed.pilotScope === "counterparty_value_flow_query_movements_v1" || + followupSeed.pilotScope === "counterparty_supplier_payout_query_movements_v1" || + followupSeed.pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1")); const documentEvidenceGroundedMovementFollowupApplicable = Boolean(followupSeed.pilotScope === "counterparty_document_evidence_query_documents_v1" && (followupSeed.counterparty || followupSeed.discoveryEntity) && !rawLifecycleSignal && diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index e6541a9..384ba0c 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2607,6 +2607,12 @@ function hasAddressFollowupContextSignal(userMessage) { if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu)) { return true; } + const shortValueFlowRetargetCue = shortFollowup && + (hasMarker() || hasPointer() || hasAny(/^(?:Р°|a|Рё|i|also|then|now)(?=$|[\s,.;:!?])/iu)) && + hasAny(/(?:нетто|сальдо|разниц|получил|заплатил|поступ|РІС…РѕРґСЏС‰|РёСЃС…РѕРґСЏС‰|РѕР±РѕСЂРѕС‚|выручк|денеж)/iu); + if (shortValueFlowRetargetCue) { + return true; + } if (hasStandaloneAddressTopicSignal(rawText || repairedText)) { return false; } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 5997045..3961e9a 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -241,6 +241,23 @@ function createAssistantTransitionPolicy(deps) { ]; return sameDatePhrases.some((phrase) => normalized.includes(phrase)); } + function hasShortValueFlowRetargetCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + const tokenCount = deps.countTokens(normalized); + if (!Number.isFinite(tokenCount) || tokenCount > 8) { + return false; + } + const hasLeadCue = deps.hasFollowupMarker(text) || + deps.hasReferentialPointer(text) || + /^(?:\u0430|\u0438|also|then|now)(?=$|[\s,.;:!?])/iu.test(normalized); + if (!hasLeadCue) { + return false; + } + return /(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0440\u0430\u0437\u043d\u0438\u0446|\u043f\u043e\u043b\u0443\u0447|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u043e\u0441\u0442\u0443\u043f|\u0432\u0445\u043e\u0434\u044f\u0449|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u0435\u043d\u0435\u0436)/iu.test(normalized); + } function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) { const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); if (!normalized || deps.countTokens(normalized) > 4) { @@ -342,6 +359,11 @@ function createAssistantTransitionPolicy(deps) { ? deps.isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); const sourceIntentHint = (0, assistantContinuityPolicy_1.readAddressDebugIntent)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryPilotScopeHint = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryPilotScope)(carryoverSourceDebug, deps.toNonEmptyString); + const hasValueFlowCarryoverSourceHint = sourceIntentHint === "customer_revenue_and_payments" || + sourceDiscoveryPilotScopeHint === "counterparty_value_flow_query_movements_v1" || + sourceDiscoveryPilotScopeHint === "counterparty_supplier_payout_query_movements_v1" || + sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1"; const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue); const navigationFocusObjectHint = navigationSessionState.focusObject; const hasNavigationInventoryItemFocusHint = Boolean(deps.toNonEmptyString(navigationFocusObjectHint?.label) && @@ -363,13 +385,19 @@ function createAssistantTransitionPolicy(deps) { ? deps.resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) : null; const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; + const shortValueFlowRetargetPrimary = hasValueFlowCarryoverSourceHint && hasShortValueFlowRetargetCue(userMessage); + const shortValueFlowRetargetAlternate = hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) + ? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) + : false; let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || + shortValueFlowRetargetPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge; let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || + shortValueFlowRetargetAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge : false; @@ -403,6 +431,8 @@ function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementAlternate || inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || + shortValueFlowRetargetPrimary || + shortValueFlowRetargetAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) @@ -420,6 +450,8 @@ function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementAlternate || inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || + shortValueFlowRetargetPrimary || + shortValueFlowRetargetAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) @@ -442,6 +474,8 @@ function createAssistantTransitionPolicy(deps) { !hasInventoryRootTemporalFollowupAlternate && !hasInventoryRootRestatementPrimary && !hasInventoryRootRestatementAlternate && + !shortValueFlowRetargetPrimary && + !shortValueFlowRetargetAlternate && !hasImplicitContinuationSignal && !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal) { @@ -453,6 +487,8 @@ function createAssistantTransitionPolicy(deps) { !hasInventoryRootTemporalFollowupAlternate && !hasInventoryRootRestatementPrimary && !hasInventoryRootRestatementAlternate && + !shortValueFlowRetargetPrimary && + !shortValueFlowRetargetAlternate && !hasImplicitContinuationSignal && !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal) { @@ -462,7 +498,7 @@ function createAssistantTransitionPolicy(deps) { return null; } const sourceIntent = (0, assistantContinuityPolicy_1.readAddressDebugIntent)(carryoverSourceDebug, deps.toNonEmptyString); - const sourceDiscoveryPilotScope = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryPilotScope)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryPilotScope = sourceDiscoveryPilotScopeHint; const sourceDiscoveryMetadataRouteFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataRouteFamily)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryMetadataSelectedEntitySet = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataSelectedEntitySet)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryMetadataAmbiguityDetected = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataAmbiguityDetected)(carryoverSourceDebug); @@ -556,12 +592,14 @@ function createAssistantTransitionPolicy(deps) { hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || + shortValueFlowRetargetPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge || hasInventoryRootTemporalFollowupPrimary; hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || + shortValueFlowRetargetAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge || hasInventoryRootTemporalFollowupAlternate @@ -577,6 +615,8 @@ function createAssistantTransitionPolicy(deps) { hasInventoryRootTemporalFollowupAlternate || inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || + shortValueFlowRetargetPrimary || + shortValueFlowRetargetAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 4eb6d70..4828738 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -727,7 +727,10 @@ export function buildAssistantMcpDiscoveryTurnInput( (followupSeed.counterparty || followupSeed.discoveryEntity) && (followupSeed.pilotScope === "entity_resolution_search_v1" || followupSeed.pilotScope === "counterparty_document_evidence_query_documents_v1" || - followupSeed.pilotScope === "counterparty_movement_evidence_query_movements_v1") + followupSeed.pilotScope === "counterparty_movement_evidence_query_movements_v1" || + followupSeed.pilotScope === "counterparty_value_flow_query_movements_v1" || + followupSeed.pilotScope === "counterparty_supplier_payout_query_movements_v1" || + followupSeed.pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1") ); const documentEvidenceGroundedMovementFollowupApplicable = Boolean( followupSeed.pilotScope === "counterparty_document_evidence_query_documents_v1" && diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 7f94a5a..937962f 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2563,6 +2563,12 @@ function hasAddressFollowupContextSignal(userMessage) { if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu)) { return true; } + const shortValueFlowRetargetCue = shortFollowup && + (hasMarker() || hasPointer() || hasAny(/^(?:Р°|a|Рё|i|also|then|now)(?=$|[\s,.;:!?])/iu)) && + hasAny(/(?:нетто|сальдо|разниц|получил|заплатил|поступ|РІС…РѕРґСЏС‰|РёСЃС…РѕРґСЏС‰|РѕР±РѕСЂРѕС‚|выручк|денеж)/iu); + if (shortValueFlowRetargetCue) { + return true; + } if (hasStandaloneAddressTopicSignal(rawText || repairedText)) { return false; } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 86a9f1f..b4390fd 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -321,6 +321,27 @@ export function createAssistantTransitionPolicy(deps) { return sameDatePhrases.some((phrase) => normalized.includes(phrase)); } + function hasShortValueFlowRetargetCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + const tokenCount = deps.countTokens(normalized); + if (!Number.isFinite(tokenCount) || tokenCount > 8) { + return false; + } + const hasLeadCue = + deps.hasFollowupMarker(text) || + deps.hasReferentialPointer(text) || + /^(?:\u0430|\u0438|also|then|now)(?=$|[\s,.;:!?])/iu.test(normalized); + if (!hasLeadCue) { + return false; + } + return /(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0440\u0430\u0437\u043d\u0438\u0446|\u043f\u043e\u043b\u0443\u0447|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u043e\u0441\u0442\u0443\u043f|\u0432\u0445\u043e\u0434\u044f\u0449|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u0435\u043d\u0435\u0436)/iu.test( + normalized + ); + } + function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) { const normalized = deps.compactWhitespace( deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase() @@ -450,6 +471,15 @@ export function createAssistantTransitionPolicy(deps) { ? deps.isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); const sourceIntentHint = readAddressDebugIntent(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryPilotScopeHint = readAssistantMcpDiscoveryPilotScope( + carryoverSourceDebug, + deps.toNonEmptyString + ); + const hasValueFlowCarryoverSourceHint = + sourceIntentHint === "customer_revenue_and_payments" || + sourceDiscoveryPilotScopeHint === "counterparty_value_flow_query_movements_v1" || + sourceDiscoveryPilotScopeHint === "counterparty_supplier_payout_query_movements_v1" || + sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1"; const navigationSessionState = resolveNavigationSessionContextState( addressNavigationState, deps.toNonEmptyString, @@ -485,14 +515,22 @@ export function createAssistantTransitionPolicy(deps) { ? deps.resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) : null; const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; + const shortValueFlowRetargetPrimary = + hasValueFlowCarryoverSourceHint && hasShortValueFlowRetargetCue(userMessage); + const shortValueFlowRetargetAlternate = + hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) + ? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) + : false; let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || + shortValueFlowRetargetPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge; let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || + shortValueFlowRetargetAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge : false; @@ -543,6 +581,8 @@ export function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementAlternate || inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || + shortValueFlowRetargetPrimary || + shortValueFlowRetargetAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) @@ -561,6 +601,8 @@ export function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementAlternate || inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || + shortValueFlowRetargetPrimary || + shortValueFlowRetargetAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) @@ -588,6 +630,8 @@ export function createAssistantTransitionPolicy(deps) { !hasInventoryRootTemporalFollowupAlternate && !hasInventoryRootRestatementPrimary && !hasInventoryRootRestatementAlternate && + !shortValueFlowRetargetPrimary && + !shortValueFlowRetargetAlternate && !hasImplicitContinuationSignal && !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal @@ -601,6 +645,8 @@ export function createAssistantTransitionPolicy(deps) { !hasInventoryRootTemporalFollowupAlternate && !hasInventoryRootRestatementPrimary && !hasInventoryRootRestatementAlternate && + !shortValueFlowRetargetPrimary && + !shortValueFlowRetargetAlternate && !hasImplicitContinuationSignal && !hasOrganizationClarificationContinuation && !hasIndexReferenceSignal @@ -611,7 +657,7 @@ export function createAssistantTransitionPolicy(deps) { return null; } const sourceIntent = readAddressDebugIntent(carryoverSourceDebug, deps.toNonEmptyString); - const sourceDiscoveryPilotScope = readAssistantMcpDiscoveryPilotScope(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryPilotScope = sourceDiscoveryPilotScopeHint; const sourceDiscoveryMetadataRouteFamily = readAssistantMcpDiscoveryMetadataRouteFamily( carryoverSourceDebug, deps.toNonEmptyString @@ -730,12 +776,14 @@ export function createAssistantTransitionPolicy(deps) { hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || + shortValueFlowRetargetPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge || hasInventoryRootTemporalFollowupPrimary; hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || + shortValueFlowRetargetAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge || hasInventoryRootTemporalFollowupAlternate @@ -751,6 +799,8 @@ export function createAssistantTransitionPolicy(deps) { hasInventoryRootTemporalFollowupAlternate || inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || + shortValueFlowRetargetPrimary || + shortValueFlowRetargetAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 9bc9376..0ba9d6c 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -348,6 +348,80 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); }); + it("overrides a supported exact payout intent when a grounded value-flow follow-up switches from incoming to outgoing", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а теперь сколько заплатили?", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "turnover", + explicit_intent_candidate: "customer_revenue_and_payments" + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1", + previous_filters: { + counterparty: "Группа СВК", + organization: "ООО Альтернатива Плюс", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "Группа СВК" + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_payouts_or_outflow", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_grounded_value_flow_followup"); + expect(result.reason_codes).toContain("mcp_discovery_payout_signal_detected"); + expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + }); + + it("overrides a supported exact net intent when a grounded payout follow-up switches into net flow", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а какое нетто?", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "turnover", + explicit_intent_candidate: "customer_revenue_and_payments" + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_supplier_payout_query_movements_v1", + previous_filters: { + counterparty: "Группа СВК", + period_from: "2021-01-01", + period_to: "2021-12-31" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "Группа СВК" + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2021", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_grounded_value_flow_followup"); + expect(result.reason_codes).toContain("mcp_discovery_bidirectional_value_flow_signal_detected"); + expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + }); + it("seeds short monthly follow-up from prior bidirectional discovery context", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "а по месяцам?", diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index b2390b1..0990df9 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -796,6 +796,7 @@ describe("assistantTransitionPolicy", () => { it("retargets selected-object provenance follow-up from inventory root when semantic scope is already detected", () => { const policy = buildPolicy({ + hasAddressFollowupContextSignal: () => true, findLastAddressAssistantItem: () => ({ text: "На 31.03.2016 на складе подтверждено 2 позиции.", debug: { @@ -810,8 +811,7 @@ describe("assistantTransitionPolicy", () => { anchor_type: "organization", anchor_value_resolved: 'ООО "Альтернатива Плюс"' } - }), - hasAddressFollowupContextSignal: () => true + }) }); const carryover = policy.resolveAddressFollowupCarryoverContext( @@ -1173,6 +1173,48 @@ describe("assistantTransitionPolicy", () => { }); }); + it("keeps exact payout carryover for a short net follow-up without restating counterparty or year", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + role: "assistant", + text: "Платежи по Группа СВК за 2021", + debug: { + execution_lane: "address_query", + answer_grounding_check: { status: "grounded" }, + detected_intent: "customer_revenue_and_payments", + selected_recipe: "address_customer_revenue_and_payments_v1", + extracted_filters: { + counterparty: "Группа СВК", + period_from: "2021-01-01", + period_to: "2021-12-31" + }, + anchor_type: "counterparty", + anchor_value_resolved: "Группа СВК" + } + }), + hasAddressFollowupContextSignal: () => true + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "а какое нетто?", + [], + null, + null, + null + ); + + expect(carryover?.followupSelectionMode).toBe("carry_previous_intent"); + expect(carryover?.followupContext?.previous_intent).toBe("customer_revenue_and_payments"); + expect(carryover?.followupContext?.target_intent).toBe("customer_revenue_and_payments"); + expect(carryover?.followupContext?.previous_anchor_type).toBe("counterparty"); + expect(carryover?.followupContext?.previous_anchor_value).toBe("Группа СВК"); + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + counterparty: "Группа СВК", + period_from: "2021-01-01", + period_to: "2021-12-31" + }); + }); + it("carries grounded metadata downstream route hints into followup context", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => null,