diff --git a/docs/orchestration/address_truth_harness_phase75_contracts_to_payments_year_switch.json b/docs/orchestration/address_truth_harness_phase75_contracts_to_payments_year_switch.json new file mode 100644 index 0000000..ba5fdbc --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase75_contracts_to_payments_year_switch.json @@ -0,0 +1,65 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase75_contracts_to_payments_year_switch", + "domain": "address_phase75_contracts_to_payments_year_switch", + "title": "Phase 75 contracts to payments year-switch continuity", + "description": "Replay for a human chain where the user opens documents by counterparty, pivots to contracts, then pivots again to payments via pronoun follow-up, and finally switches the year without renaming the counterparty.", + "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", "pivot_seed", "integrity_guard"] + }, + { + "step_id": "step_02_contracts_by_pronoun_followup", + "title": "Pivot to contracts", + "question": "А по нему договоры?", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)жуковк|контрагент", + "(?i)договор|контракт|соглаш" + ], + "criticality": "critical", + "semantic_tags": ["contracts_followup", "counterparty_pronoun_resolution", "integrity_guard"] + }, + { + "step_id": "step_03_payments_after_contracts_pivot", + "title": "Pivot again to payments", + "question": "А по нему платежи?", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)жуковк|контрагент", + "(?i)платеж|операц|банк|поступлен|списан" + ], + "criticality": "critical", + "semantic_tags": ["payments_followup", "second_pivot", "integrity_guard"] + }, + { + "step_id": "step_04_year_switch_after_second_pivot", + "title": "Switch the year after the second pivot", + "question": "А за 2021?", + "allowed_reply_types": ["factual", "factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)2021", + "(?i)жуковк|контрагент", + "(?i)платеж|операц|банк|поступлен|списан" + ], + "forbidden_answer_patterns": [ + "(?i)уточните .* контрагент", + "(?i)метадан", + "(?i)схем", + "(?i)объект[а-я]* 1с" + ], + "criticality": "critical", + "semantic_tags": ["year_switch_after_second_pivot", "payments_followup", "integrity_guard"] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 384ba0c..a96d7f4 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2483,11 +2483,19 @@ function findRecentInventoryRootFrame(items) { const ADDRESS_FOLLOWUP_OFFER_BY_INTENT = { list_documents_by_counterparty: ["bank_operations_by_counterparty", "list_contracts_by_counterparty"], bank_operations_by_counterparty: ["list_documents_by_counterparty", "list_contracts_by_counterparty"], - list_contracts_by_counterparty: ["list_documents_by_contract", "bank_operations_by_contract"], list_documents_by_contract: ["bank_operations_by_contract"], bank_operations_by_contract: ["list_documents_by_contract"], open_items_by_counterparty_or_contract: ["list_documents_by_counterparty", "bank_operations_by_counterparty"] }; +function resolveAddressFollowupSuggestedIntents(intent, anchorType) { + if (intent === "list_contracts_by_counterparty") { + if (anchorType === "contract") { + return ["list_documents_by_contract", "bank_operations_by_contract"]; + } + return ["list_documents_by_counterparty", "bank_operations_by_counterparty"]; + } + return ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent] ?? null; +} function buildAddressFollowupOffer(addressDebug) { if (!isAddressLaneDebugPayload(addressDebug)) { return null; @@ -2496,11 +2504,11 @@ function buildAddressFollowupOffer(addressDebug) { if (!intent) { return null; } - const suggestedIntents = ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent]; + const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString); + const suggestedIntents = resolveAddressFollowupSuggestedIntents(intent, anchorContext.anchorType); if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) { return null; } - const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString); return { enabled: true, source_intent: intent, diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 937962f..cc2b15c 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2439,11 +2439,19 @@ function findRecentInventoryRootFrame(items) { const ADDRESS_FOLLOWUP_OFFER_BY_INTENT = { list_documents_by_counterparty: ["bank_operations_by_counterparty", "list_contracts_by_counterparty"], bank_operations_by_counterparty: ["list_documents_by_counterparty", "list_contracts_by_counterparty"], - list_contracts_by_counterparty: ["list_documents_by_contract", "bank_operations_by_contract"], list_documents_by_contract: ["bank_operations_by_contract"], bank_operations_by_contract: ["list_documents_by_contract"], open_items_by_counterparty_or_contract: ["list_documents_by_counterparty", "bank_operations_by_counterparty"] } as Record; +function resolveAddressFollowupSuggestedIntents(intent, anchorType) { + if (intent === "list_contracts_by_counterparty") { + if (anchorType === "contract") { + return ["list_documents_by_contract", "bank_operations_by_contract"]; + } + return ["list_documents_by_counterparty", "bank_operations_by_counterparty"]; + } + return ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent] ?? null; +} function buildAddressFollowupOffer(addressDebug) { if (!isAddressLaneDebugPayload(addressDebug)) { return null; @@ -2452,11 +2460,11 @@ function buildAddressFollowupOffer(addressDebug) { if (!intent) { return null; } - const suggestedIntents = ADDRESS_FOLLOWUP_OFFER_BY_INTENT[intent]; + const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString); + const suggestedIntents = resolveAddressFollowupSuggestedIntents(intent, anchorContext.anchorType); if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) { return null; } - const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString); return { enabled: true, source_intent: intent, diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index f76e46d..ab3c519 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -1496,6 +1496,125 @@ describe("assistant address follow-up carryover", () => { expect(String(calls[0].message).toLowerCase()).toContain("свк"); expect(chatClient.chat).toHaveBeenCalledTimes(0); }); + it("keeps counterparty scope when pivoting from contracts list to payments by pronoun", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = "Покажи документы по Жуковке 51."; + const secondMessage = "А по нему договоры?"; + const thirdMessage = "А по нему платежи?"; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + const normalizedMessage = String(message ?? "").toLowerCase(); + if (normalizedMessage.includes("документ") && normalizedMessage.includes("жуков")) { + return buildAddressLaneResult({ + reply_text: "Собран список документов по контрагенту ТСЖ \\Жуковка 51\\.", + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "list_documents_by_counterparty", + selected_recipe: "address_documents_by_counterparty_v1", + extracted_filters: { + counterparty: "ТСЖ \\Жуковка 51\\" + }, + anchor_type: "counterparty", + anchor_value_raw: "Жуковке 51", + anchor_value_resolved: "ТСЖ \\Жуковка 51\\", + reasons: ["documents_by_counterparty_signal_detected"] + } + }); + } + + if (normalizedMessage.includes("договор")) { + return buildAddressLaneResult({ + reply_text: [ + "Коротко: найдено 1 договоров по контрагенту.", + "Контрагент: ТСЖ \\Жуковка 51\\.", + "1. Счет № 5 от 05.04.2017" + ].join("\n"), + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "list_contracts_by_counterparty", + selected_recipe: "address_contracts_by_counterparty_v1", + extracted_filters: { + counterparty: "ТСЖ \\Жуковка 51\\" + }, + anchor_type: "counterparty", + anchor_value_raw: "нему", + anchor_value_resolved: "ТСЖ \\Жуковка 51\\", + reasons: ["contracts_by_counterparty_signal_detected", "address_followup_context_applied"] + } + }); + } + + if (normalizedMessage.includes("плат") || normalizedMessage.includes("банк")) { + return buildAddressLaneResult({ + reply_text: "Собран список банковских операций по контрагенту ТСЖ \\Жуковка 51\\.", + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "bank_operations_by_counterparty", + selected_recipe: "address_bank_operations_by_counterparty_v1", + extracted_filters: { + counterparty: "ТСЖ \\Жуковка 51\\" + }, + anchor_type: "counterparty", + anchor_value_raw: "нему", + anchor_value_resolved: "ТСЖ \\Жуковка 51\\", + reasons: ["bank_operations_by_counterparty_signal_detected", "address_followup_context_applied"] + } + }); + } + + return null; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-followup-contracts-payments-${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: secondMessage, + useMock: true + } as any); + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(second.debug?.detected_intent).toBe("list_contracts_by_counterparty"); + + const third = await service.handleMessage({ + session_id: sessionId, + user_message: thirdMessage, + useMock: true + } as any); + expect(third.ok).toBe(true); + expect(third.reply_type).toBe("factual"); + expect(third.debug?.detected_intent).toBe("bank_operations_by_counterparty"); + expect(third.debug?.dialog_continuation_contract_v2?.target_intent).toBe("bank_operations_by_counterparty"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + it("keeps debt lifecycle follow-up context for 'а нам кто должен?.' after payables as-of answer", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage =