From 19137a698fa245bf6584e64bab08ceae0c6e4acd Mon Sep 17 00:00:00 2001 From: dctouch Date: Thu, 23 Apr 2026 19:35:10 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20document=E2=86=92payments=20pivot=20=D0=BE=D1=82?= =?UTF-8?q?=20discovery=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...69_document_to_payments_pronoun_pivot.json | 41 ++++++ .../assistantMcpDiscoveryResponsePolicy.js | 26 ++++ .../services/assistantTransitionPolicy.js | 91 +++++++++++++- .../assistantMcpDiscoveryResponsePolicy.ts | 33 +++++ .../src/services/assistantTransitionPolicy.ts | 117 +++++++++++++++++- .../assistantAddressFollowupContext.test.ts | 99 +++++++++++++++ ...ssistantMcpDiscoveryResponsePolicy.test.ts | 50 ++++++++ .../tests/assistantTransitionPolicy.test.ts | 46 +++++++ 8 files changed, 495 insertions(+), 8 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase69_document_to_payments_pronoun_pivot.json diff --git a/docs/orchestration/address_truth_harness_phase69_document_to_payments_pronoun_pivot.json b/docs/orchestration/address_truth_harness_phase69_document_to_payments_pronoun_pivot.json new file mode 100644 index 0000000..72b4775 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase69_document_to_payments_pronoun_pivot.json @@ -0,0 +1,41 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase69_document_to_payments_pronoun_pivot", + "domain": "address_phase69_document_to_payments_pronoun_pivot", + "title": "Phase 69 document to payments pronoun pivot", + "description": "Replay for a human follow-up where the user first asks for documents by counterparty and then pivots with 'а по нему платежи?', expecting the same counterparty to survive and the contour to switch to payments.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_documents_by_counterparty", + "title": "Open documents for the counterparty", + "question": "Покажи документы по Жуковке 51.", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)жуковк", + "(?i)документ|сч[её]т|акт|накладн|строк" + ], + "criticality": "critical", + "semantic_tags": ["documents_by_counterparty", "pronoun_pivot_seed", "integrity_guard"] + }, + { + "step_id": "step_02_payments_by_pronoun_followup", + "title": "Pivot to payments via pronoun follow-up", + "question": "А по нему платежи?", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)жуковк|контрагент", + "(?i)платеж|операц|банк|поступлен|списан" + ], + "forbidden_answer_patterns": [ + "(?i)метадан", + "(?i)схем", + "(?i)объект[а-я]* 1с", + "(?i)регистр", + "(?i)уточните .* контрагент" + ], + "criticality": "critical", + "semantic_tags": ["payments_followup", "counterparty_pronoun_resolution", "integrity_guard"] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 1f73344..5980b8a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -274,6 +274,27 @@ function hasMatchedFactualAddressContinuationTarget(input, entryPoint) { const targetIntent = toNonEmptyString(dialogContinuationContract?.target_intent); return Boolean(detectedIntent && targetIntent && detectedIntent === targetIntent); } +function hasMatchedFactualSuggestedIntentPivotTarget(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const dialogContinuationContract = toRecordObject(input.addressRuntimeMeta?.dialogContinuationContract) ?? + toRecordObject(input.addressRuntimeMeta?.dialog_continuation_contract_v2); + const targetIntent = toNonEmptyString(dialogContinuationContract?.target_intent); + const decision = toNonEmptyString(dialogContinuationContract?.decision); + const selectionMode = toNonEmptyString(dialogContinuationContract?.intent_selection_mode); + const suggestedPivotSignal = dialogContinuationContract?.suggested_intent_pivot_signal === true; + return Boolean(detectedIntent && + targetIntent && + detectedIntent === targetIntent && + (decision === "switch_to_suggested" || + selectionMode === "switch_to_suggested_intent" || + suggestedPivotSignal)); +} function hasFullConfirmedFactualAddressReply(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; @@ -309,6 +330,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const alignedFactualAddressReply = hasAlignedFactualAddressReply(input, entryPoint); const semanticConflictWithDiscoveryTurnMeaning = hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint); const matchedFactualAddressContinuationTarget = hasMatchedFactualAddressContinuationTarget(input, entryPoint); + const matchedFactualSuggestedIntentPivotTarget = hasMatchedFactualSuggestedIntentPivotTarget(input, entryPoint); const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); if (!entryPoint) { @@ -335,6 +357,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (matchedFactualAddressContinuationTarget) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_factual_address_continuation_target"); } + if (matchedFactualSuggestedIntentPivotTarget) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_factual_suggested_intent_pivot_target"); + } if (fullConfirmedFactualAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); } @@ -360,6 +385,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate || discoveryReadyAddressCandidate) && !alignedFactualAddressReply && !matchedFactualAddressContinuationTarget && + !matchedFactualSuggestedIntentPivotTarget && !fullConfirmedFactualAddressReply && !runtimeAdjustedExactReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 6e18e39..0705db8 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -7,6 +7,78 @@ function createAssistantTransitionPolicy(deps) { function normalizeFollowupText(value) { return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"); } + function hasBankOperationsPivotCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + return /(?:платеж|платёж|банк|банковск|операц|выписк|поступлен|списан)/iu.test(normalized); + } + function hasContractsPivotCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + return /(?:РґРѕРіРѕРІРѕСЂ)/iu.test(normalized); + } + function hasDocumentsPivotCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + return /(?:документ|счет|счёт|накладн|акт)/iu.test(normalized); + } + function hasReadableBankOperationsPivotCue(text) { + const normalized = normalizeFollowupText(text).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + return /(?:платеж|оплат|банк|банковск|операц|поступлен|списан|выписк|перевод|payment|bank|transaction)/iu.test(normalized); + } + function hasReadableContractsPivotCue(text) { + const normalized = normalizeFollowupText(text).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + return /(?:договор|контракт|соглашен|contract|agreement)/iu.test(normalized); + } + function hasReadableDocumentsPivotCue(text) { + const normalized = normalizeFollowupText(text).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + return /(?:документ|счет|счет-фактур|накладн|акт|реализац|document|invoice|receipt)/iu.test(normalized); + } + function selectSuggestedIntentByPivotCue(suggestedIntents, userMessage, alternateMessage = null) { + if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) { + return null; + } + const samples = [userMessage, alternateMessage].filter((item) => deps.toNonEmptyString(item)); + if (samples.length === 0) { + return null; + } + if (suggestedIntents.includes("bank_operations_by_counterparty") && + samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample))) { + return "bank_operations_by_counterparty"; + } + if (suggestedIntents.includes("bank_operations_by_contract") && + samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample))) { + return "bank_operations_by_contract"; + } + if (suggestedIntents.includes("list_contracts_by_counterparty") && + samples.some((sample) => hasContractsPivotCue(sample) || hasReadableContractsPivotCue(sample))) { + return "list_contracts_by_counterparty"; + } + if (suggestedIntents.includes("list_documents_by_counterparty") && + samples.some((sample) => hasDocumentsPivotCue(sample) || hasReadableDocumentsPivotCue(sample))) { + return "list_documents_by_counterparty"; + } + if (suggestedIntents.includes("list_documents_by_contract") && + samples.some((sample) => hasDocumentsPivotCue(sample) || hasReadableDocumentsPivotCue(sample))) { + return "list_documents_by_contract"; + } + return null; + } function hasSelectedObjectInventoryScopeSignal(text) { const normalized = normalizeFollowupText(text); if (!normalized) { @@ -352,6 +424,10 @@ function createAssistantTransitionPolicy(deps) { const carryoverSourceDebug = previousAddressDebug ?? (hasOrganizationClarificationContinuation ? lastOrganizationClarificationDebug : null); const followupOffer = carryoverSourceDebug ? deps.buildAddressFollowupOffer(carryoverSourceDebug) : null; + const suggestedIntentFromPivotCue = selectSuggestedIntentByPivotCue(Array.isArray(followupOffer?.suggested_intents) ? followupOffer.suggested_intents : [], userMessage, alternateMessage); + const hasSuggestedIntentPivotSignal = Boolean(previousAddressDebug) && + Boolean(followupOffer?.enabled) && + Boolean(suggestedIntentFromPivotCue); const hasImplicitContinuationSignal = Boolean(previousAddressDebug) && Boolean(followupOffer?.enabled) && (deps.isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) || @@ -423,6 +499,7 @@ function createAssistantTransitionPolicy(deps) { hasAlternateIndexReferenceSignal || hasOrganizationClarificationContinuation || hasImplicitContinuationSignal || + hasSuggestedIntentPivotSignal || inventoryShortFollowupPrimary || inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || @@ -575,9 +652,9 @@ function createAssistantTransitionPolicy(deps) { if (debtRoleSwapIntent) { previousIntent = debtRoleSwapIntent; } - if (hasImplicitContinuationSignal) { + if (hasImplicitContinuationSignal || hasSuggestedIntentPivotSignal) { const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) - ? deps.toNonEmptyString(followupOffer.suggested_intents[0]) + ? suggestedIntentFromPivotCue ?? deps.toNonEmptyString(followupOffer.suggested_intents[0]) : null; const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent); if (suggestedIntent && !keepPreviousIntent) { @@ -789,7 +866,8 @@ function createAssistantTransitionPolicy(deps) { previousAddressAnchor: previousAnchor, previousSourceIntent: sourceIntent, followupSelectionMode, - hasImplicitContinuationSignal + hasImplicitContinuationSignal, + hasSuggestedIntentPivotSignal }; } function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage, carryoverMeta, llmPreDecomposeMeta) { @@ -809,6 +887,7 @@ function createAssistantTransitionPolicy(deps) { ? carryoverTargetIntent ?? rootIntent ?? explicitIntent ?? null : carryoverTargetIntent ?? explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal); + const hasSuggestedIntentPivotSignal = Boolean(carryoverMeta?.hasSuggestedIntentPivotSignal); const rewrittenByPredecompose = deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase()); const hasExplicitIntent = Boolean(explicitIntent); const decision = !hasFollowupContext @@ -823,6 +902,9 @@ function createAssistantTransitionPolicy(deps) { if (hasImplicitContinuationSignal) { reasons.push("implicit_continuation_by_llm"); } + if (hasSuggestedIntentPivotSignal) { + reasons.push("suggested_intent_followup_pivot"); + } if (rewrittenByPredecompose) { reasons.push("effective_message_rewritten_by_predecompose"); } @@ -847,7 +929,8 @@ function createAssistantTransitionPolicy(deps) { intent_selection_mode: selectionMode, anchor_type: carryoverMeta?.followupContext?.previous_anchor_type ?? null, anchor_value: carryoverMeta?.followupContext?.previous_anchor_value ?? null, - implicit_continuation_signal: hasImplicitContinuationSignal + implicit_continuation_signal: hasImplicitContinuationSignal, + suggested_intent_pivot_signal: hasSuggestedIntentPivotSignal }; } return { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index d8ff481..4e94851 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -391,6 +391,34 @@ function hasMatchedFactualAddressContinuationTarget( return Boolean(detectedIntent && targetIntent && detectedIntent === targetIntent); } +function hasMatchedFactualSuggestedIntentPivotTarget( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const dialogContinuationContract = + toRecordObject(input.addressRuntimeMeta?.dialogContinuationContract) ?? + toRecordObject(input.addressRuntimeMeta?.dialog_continuation_contract_v2); + const targetIntent = toNonEmptyString(dialogContinuationContract?.target_intent); + const decision = toNonEmptyString(dialogContinuationContract?.decision); + const selectionMode = toNonEmptyString(dialogContinuationContract?.intent_selection_mode); + const suggestedPivotSignal = dialogContinuationContract?.suggested_intent_pivot_signal === true; + return Boolean( + detectedIntent && + targetIntent && + detectedIntent === targetIntent && + (decision === "switch_to_suggested" || + selectionMode === "switch_to_suggested_intent" || + suggestedPivotSignal) + ); +} + function hasFullConfirmedFactualAddressReply( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null @@ -433,6 +461,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const alignedFactualAddressReply = hasAlignedFactualAddressReply(input, entryPoint); const semanticConflictWithDiscoveryTurnMeaning = hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint); const matchedFactualAddressContinuationTarget = hasMatchedFactualAddressContinuationTarget(input, entryPoint); + const matchedFactualSuggestedIntentPivotTarget = hasMatchedFactualSuggestedIntentPivotTarget(input, entryPoint); const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); @@ -460,6 +489,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (matchedFactualAddressContinuationTarget) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_factual_address_continuation_target"); } + if (matchedFactualSuggestedIntentPivotTarget) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_factual_suggested_intent_pivot_target"); + } if (fullConfirmedFactualAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); } @@ -493,6 +525,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate || discoveryReadyAddressCandidate) && !alignedFactualAddressReply && !matchedFactualAddressContinuationTarget && + !matchedFactualSuggestedIntentPivotTarget && !fullConfirmedFactualAddressReply && !runtimeAdjustedExactReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 5c38a0c..ebf4286 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -47,6 +47,99 @@ export function createAssistantTransitionPolicy(deps) { return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"); } + function hasBankOperationsPivotCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + return /(?:платеж|платёж|банк|банковск|операц|выписк|поступлен|списан)/iu.test( + normalized + ); + } + + function hasContractsPivotCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + return /(?:РґРѕРіРѕРІРѕСЂ)/iu.test(normalized); + } + + function hasDocumentsPivotCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + return /(?:документ|счет|счёт|накладн|акт)/iu.test(normalized); + } + + function hasReadableBankOperationsPivotCue(text) { + const normalized = normalizeFollowupText(text).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + return /(?:платеж|оплат|банк|банковск|операц|поступлен|списан|выписк|перевод|payment|bank|transaction)/iu.test( + normalized + ); + } + + function hasReadableContractsPivotCue(text) { + const normalized = normalizeFollowupText(text).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + return /(?:договор|контракт|соглашен|contract|agreement)/iu.test(normalized); + } + + function hasReadableDocumentsPivotCue(text) { + const normalized = normalizeFollowupText(text).replace(/ё/g, "е"); + if (!normalized) { + return false; + } + return /(?:документ|счет|счет-фактур|накладн|акт|реализац|document|invoice|receipt)/iu.test(normalized); + } + + function selectSuggestedIntentByPivotCue(suggestedIntents, userMessage, alternateMessage = null) { + if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) { + return null; + } + const samples = [userMessage, alternateMessage].filter((item) => deps.toNonEmptyString(item)); + if (samples.length === 0) { + return null; + } + if ( + suggestedIntents.includes("bank_operations_by_counterparty") && + samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample)) + ) { + return "bank_operations_by_counterparty"; + } + if ( + suggestedIntents.includes("bank_operations_by_contract") && + samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample)) + ) { + return "bank_operations_by_contract"; + } + if ( + suggestedIntents.includes("list_contracts_by_counterparty") && + samples.some((sample) => hasContractsPivotCue(sample) || hasReadableContractsPivotCue(sample)) + ) { + return "list_contracts_by_counterparty"; + } + if ( + suggestedIntents.includes("list_documents_by_counterparty") && + samples.some((sample) => hasDocumentsPivotCue(sample) || hasReadableDocumentsPivotCue(sample)) + ) { + return "list_documents_by_counterparty"; + } + if ( + suggestedIntents.includes("list_documents_by_contract") && + samples.some((sample) => hasDocumentsPivotCue(sample) || hasReadableDocumentsPivotCue(sample)) + ) { + return "list_documents_by_contract"; + } + return null; + } + function hasSelectedObjectInventoryScopeSignal(text) { const normalized = normalizeFollowupText(text); if (!normalized) { @@ -478,6 +571,15 @@ export function createAssistantTransitionPolicy(deps) { previousAddressDebug ?? (hasOrganizationClarificationContinuation ? lastOrganizationClarificationDebug : null); const followupOffer = carryoverSourceDebug ? deps.buildAddressFollowupOffer(carryoverSourceDebug) : null; + const suggestedIntentFromPivotCue = selectSuggestedIntentByPivotCue( + Array.isArray(followupOffer?.suggested_intents) ? followupOffer.suggested_intents : [], + userMessage, + alternateMessage + ); + const hasSuggestedIntentPivotSignal = + Boolean(previousAddressDebug) && + Boolean(followupOffer?.enabled) && + Boolean(suggestedIntentFromPivotCue); const hasImplicitContinuationSignal = Boolean(previousAddressDebug) && Boolean(followupOffer?.enabled) && @@ -588,6 +690,7 @@ export function createAssistantTransitionPolicy(deps) { hasAlternateIndexReferenceSignal || hasOrganizationClarificationContinuation || hasImplicitContinuationSignal || + hasSuggestedIntentPivotSignal || inventoryShortFollowupPrimary || inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || @@ -809,9 +912,9 @@ export function createAssistantTransitionPolicy(deps) { if (debtRoleSwapIntent) { previousIntent = debtRoleSwapIntent; } - if (hasImplicitContinuationSignal) { + if (hasImplicitContinuationSignal || hasSuggestedIntentPivotSignal) { const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) - ? deps.toNonEmptyString(followupOffer.suggested_intents[0]) + ? suggestedIntentFromPivotCue ?? deps.toNonEmptyString(followupOffer.suggested_intents[0]) : null; const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetargetV2(userMessage, sourceIntent); if (suggestedIntent && !keepPreviousIntent) { @@ -1124,7 +1227,8 @@ export function createAssistantTransitionPolicy(deps) { previousAddressAnchor: previousAnchor, previousSourceIntent: sourceIntent, followupSelectionMode, - hasImplicitContinuationSignal + hasImplicitContinuationSignal, + hasSuggestedIntentPivotSignal }; } @@ -1151,6 +1255,7 @@ export function createAssistantTransitionPolicy(deps) { ? carryoverTargetIntent ?? rootIntent ?? explicitIntent ?? null : carryoverTargetIntent ?? explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal); + const hasSuggestedIntentPivotSignal = Boolean(carryoverMeta?.hasSuggestedIntentPivotSignal); const rewrittenByPredecompose = deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase()); const hasExplicitIntent = Boolean(explicitIntent); @@ -1166,6 +1271,9 @@ export function createAssistantTransitionPolicy(deps) { if (hasImplicitContinuationSignal) { reasons.push("implicit_continuation_by_llm"); } + if (hasSuggestedIntentPivotSignal) { + reasons.push("suggested_intent_followup_pivot"); + } if (rewrittenByPredecompose) { reasons.push("effective_message_rewritten_by_predecompose"); } @@ -1190,7 +1298,8 @@ export function createAssistantTransitionPolicy(deps) { intent_selection_mode: selectionMode, anchor_type: carryoverMeta?.followupContext?.previous_anchor_type ?? null, anchor_value: carryoverMeta?.followupContext?.previous_anchor_value ?? null, - implicit_continuation_signal: hasImplicitContinuationSignal + implicit_continuation_signal: hasImplicitContinuationSignal, + suggested_intent_pivot_signal: hasSuggestedIntentPivotSignal }; } diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index c9018f6..f76e46d 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -649,6 +649,9 @@ describe("assistant address follow-up carryover", () => { user_message: followupMessage, useMock: true } as any); + if (second.reply_type !== "factual") { + throw new Error(JSON.stringify({ calls, secondReplyType: second.reply_type, secondDebug: second.debug }, null, 2)); + } expect(second.ok).toBe(true); expect(second.reply_type).toBe("factual"); @@ -727,6 +730,9 @@ describe("assistant address follow-up carryover", () => { user_message: followupMessage, useMock: true } as any); + if (second.reply_type !== "factual") { + throw new Error(JSON.stringify({ calls, secondReplyType: second.reply_type, secondDebug: second.debug }, null, 2)); + } expect(second.ok).toBe(true); expect(second.reply_type).toBe("factual"); @@ -826,6 +832,9 @@ describe("assistant address follow-up carryover", () => { useMock: true } as any); expect(second.ok).toBe(true); + if (second.reply_type !== "factual") { + throw new Error(JSON.stringify({ calls, secondReplyType: second.reply_type, secondDebug: second.debug }, null, 2)); + } expect(second.reply_type).toBe("factual"); expect(second.debug?.address_retry_audit?.attempted).toBe(false); @@ -834,6 +843,96 @@ describe("assistant address follow-up carryover", () => { expect(normalizerService.normalize).not.toHaveBeenCalled(); }); + it("switches from document drilldown to bank operations on a pronoun payment follow-up", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = "покажи документы по жуковке 51"; + const followupMessage = "а по нему платежи?"; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === followupMessage && !options?.followupContext) { + return null; + } + if (options?.followupContext?.previous_intent === "bank_operations_by_counterparty") { + return buildAddressLaneResult({ + reply_text: "Собран список банковских операций по контрагенту.", + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "bank_operations_by_counterparty", + selected_recipe: "address_bank_operations_by_counterparty_v1", + extracted_filters: { + sort: "period_desc", + limit: 20, + counterparty: "жуковке 51" + }, + anchor_type: "counterparty", + anchor_value_raw: "жуковке 51", + anchor_value_resolved: "ТСЖ \\Жуковка 51\\", + reasons: ["address_action_detected", "bank_ops_by_counterparty_signal_detected", "address_followup_context_applied"] + } + }); + } + return buildAddressLaneResult({ + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "list_documents_by_counterparty", + selected_recipe: "address_documents_by_counterparty_v1", + extracted_filters: { + sort: "period_desc", + limit: 20, + counterparty: "жуковке 51" + }, + anchor_type: "counterparty", + anchor_value_raw: "жуковке 51", + anchor_value_resolved: "ТСЖ \\Жуковка 51\\" + } + }); + }) + } 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-docs-to-bank-pronoun-${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).toMatch(/банковские операции|платежи/i); + expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty"); + expect(calls[1].options?.followupContext?.target_intent).toBe("bank_operations_by_counterparty"); + expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + it("reuses last real address context after intermediate clarification fallback", async () => { const calls: Array<{ message: string; options?: any }> = []; const lifecycleFollowupMessage = "А кто из них новые?"; diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index 68d0892..d33bc97 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -235,6 +235,56 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_factual_address_continuation_target"); }); + it("keeps factual suggested-intent pivot replies over discovery clarification candidates", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Найдены банковские операции по контрагенту Жуковка 51.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "bank_operations_by_counterparty", + dialog_continuation_contract_v2: { + decision: "switch_to_suggested", + target_intent: "bank_operations_by_counterparty", + intent_selection_mode: "switch_to_suggested_intent", + suggested_intent_pivot_signal: true + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["ТСЖ \\Жуковка 51\\"], + unsupported_but_understood_family: "counterparty_value_or_turnover" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "needs_clarification", + headline: "Нужно уточнить контекст перед поиском в 1С.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: "Уточните контрагента, период или организацию." + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toBe("Найдены банковские операции по контрагенту Жуковка 51."); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_factual_suggested_intent_pivot_target"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_candidate_applied"); + }); + it("overrides an exact ranking-shaped address reply when open-scope ranking still needs organization", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index e05578b..b5ba1dd 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -691,6 +691,52 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupSelectionMode).toBe("carry_previous_intent"); }); + it("switches from documents to suggested bank operations on a pronoun follow-up with payment cue", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Собран СЃРїРёСЃРѕРє документов РїРѕ контрагенту Р–СѓРєРѕРІРєР° 51.", + debug: { + detected_intent: "list_documents_by_counterparty", + extracted_filters: { + counterparty: "Р–СѓРєРѕРІРєР° 51" + }, + anchor_type: "counterparty", + anchor_value_resolved: "РўРЎР– \\Р–СѓРєРѕРІРєР° 51\\" + } + }), + buildAddressFollowupOffer: () => ({ + enabled: true, + source_intent: "list_documents_by_counterparty", + suggested_intents: ["bank_operations_by_counterparty", "list_contracts_by_counterparty"] + }), + hasAddressFollowupContextSignal: () => true, + hasReferentialPointer: () => true + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext("Р° РїРѕ нему платежи?", [], null, null, null); + + expect(carryover?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty"); + expect(carryover?.followupContext?.target_intent).toBe("bank_operations_by_counterparty"); + expect(carryover?.followupContext?.previous_anchor_type).toBe("counterparty"); + expect(carryover?.followupSelectionMode).toBe("switch_to_suggested_intent"); + expect(carryover?.hasSuggestedIntentPivotSignal).toBe(true); + + const contract = policy.buildAddressDialogContinuationContractV2( + "Р° РїРѕ нему платежи?", + "Покажи платежи, связанные СЃ этим объектом", + carryover, + { + predecomposeContract: { + intent: "unknown" + } + } + ); + + expect(contract.decision).toBe("switch_to_suggested"); + expect(contract.target_intent).toBe("bank_operations_by_counterparty"); + expect(contract.decision_reasons).toContain("suggested_intent_followup_pivot"); + }); + it("keeps root-scoped carryover for foreign accounting pivot over inventory drilldown", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => ({