ARCH: защитить document→payments pivot от discovery override

This commit is contained in:
dctouch 2026-04-23 19:35:10 +03:00
parent 65d15c156b
commit 19137a698f
8 changed files with 495 additions and 8 deletions

View File

@ -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"]
}
]
}

View File

@ -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") &&

View File

@ -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 {

View File

@ -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") &&

View File

@ -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
};
}

View File

@ -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 = "А кто из них новые?";

View File

@ -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:

View File

@ -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: () => ({