import { describe, expect, it, vi } from "vitest"; import { AssistantService } from "../src/services/assistantService"; import { AssistantSessionStore } from "../src/services/assistantSessionStore"; function buildAddressLaneResult(overrides?: Record): any { return { handled: true, reply_text: "Собран список документов по контрагенту.", reply_type: "factual", response_type: "FACTUAL_LIST", debug: { detected_mode: "address_query", detected_mode_confidence: "high", query_shape: "DOCUMENT_LIST", query_shape_confidence: "medium", detected_intent: "list_documents_by_counterparty", detected_intent_confidence: "medium", extracted_filters: { sort: "period_desc", limit: 20, counterparty: "свк" }, missing_required_filters: [], selected_recipe: "address_documents_by_counterparty_v1", mcp_call_status_legacy: "matched_non_empty", account_scope_mode: "preferred", account_scope_fallback_applied: false, anchor_type: "counterparty", anchor_value_raw: "свк", anchor_value_resolved: "Группа СВК", resolver_confidence: "medium", ambiguity_count: 0, match_failure_stage: "none", match_failure_reason: null, mcp_call_status: "matched_non_empty", rows_fetched: 20, raw_rows_received: 20, rows_after_account_scope: 5, rows_after_recipe_filter: 3, rows_materialized: 5, rows_matched: 3, raw_row_keys_sample: [], materialization_drop_reason: "none", account_token_raw: null, account_token_normalized: null, account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"], account_scope_match_strategy: "account_code_regex_plus_alias_map_v1", account_scope_drop_reason: "not_applicable", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", limited_reason_category: null, response_type: "FACTUAL_LIST", limitations: [], reasons: ["address_action_detected", "address_entity_detected"] }, ...(overrides ?? {}) }; } function buildAddressLimitedLaneResult( category: "missing_anchor" | "empty_match" = "missing_anchor", overrides?: Record ): any { const base = buildAddressLaneResult(); return { ...base, reply_text: "Нужны уточнения по якорю.", reply_type: "partial_coverage", response_type: "LIMITED_WITH_REASON", debug: { ...base.debug, response_type: "LIMITED_WITH_REASON", limited_reason_category: category, reasons: ["address_action_detected", "address_entity_detected"] }, ...(overrides ?? {}) }; } describe("assistant address follow-up carryover", () => { it("keeps short follow-up in address lane by reusing previous anchor context", async () => { const calls: Array<{ message: string; options?: any }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === "а за все время?" && !options?.followupContext) { return null; } if (message === "а за все время?" && options?.followupContext) { return buildAddressLaneResult({ reply_text: "Собран список документов по контрагенту за все время.", debug: { ...buildAddressLaneResult().debug, reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult(); }) } 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-${Date.now()}`; const first = await service.handleMessage({ session_id: sessionId, user_message: "покажи документы по свк за 2020", 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: "а за все время?", useMock: true } as any); expect(second.ok).toBe(true); expect(second.reply_type).toBe("factual"); expect(second.debug?.detected_mode).toBe("address_query"); expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty"); expect(second.debug?.extracted_filters?.counterparty).toBe("свк"); expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied"); expect(calls).toHaveLength(2); expect(calls[0].message.toLowerCase()).toContain("свк"); expect(calls[1].message).toBe("а за все время?"); expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty"); expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(calls[1].options?.followupContext?.previous_anchor_value).toBe("Группа СВК"); expect(calls[1].options?.followupContext?.previous_filters?.counterparty).toBe("свк"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("treats short 'по также' phrase as follow-up for address lane", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020"; const followupMessage = "\u043f\u043e \u0441\u0432\u043a \u0442\u0430\u043a\u0436\u0435 \u043f\u043b\u0438\u0437"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && !options?.followupContext) { return null; } if (message === followupMessage && options?.followupContext) { return buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult(); }) } 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-short-${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).toBe(followupMessage); expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty"); expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(typeof calls[1].options?.followupContext?.previous_anchor_value).toBe("string"); expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("treats short affirmative 'давай' as follow-up for previous address answer", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020"; const followupMessage = "\u0434\u0430\u0432\u0430\u0439"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && !options?.followupContext) { return null; } if (message === followupMessage && options?.followupContext) { return buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult(); }) } 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-davai-${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).toBe(followupMessage); expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty"); expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "покажи документы по свк за 2020"; const followupMessage = "показывыай"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && !options?.followupContext) { return null; } if (message === followupMessage && options?.followupContext) { return buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult(); }) } 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-pokazyvai-${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).toBe(followupMessage); expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty"); expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("keeps previous counterparty context for referential 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 (message === followupMessage && options?.followupContext) { return buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, anchor_value_raw: "кроме", anchor_value_resolved: "ТСЖ \\Жуковка 51\\", reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, extracted_filters: { sort: "period_desc", limit: 20, counterparty: "жуковке 51" }, 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-followup-referential-${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].options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "")).toContain("Жуковка 51"); expect(String(calls[1].options?.followupContext?.previous_filters?.counterparty ?? "")).toContain("жуковке 51"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("retries with raw user message after rewrite degraded anchor and returns factual follow-up result", 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 }); const compact = String(message ?? "").trim().toLowerCase(); if (calls.length === 1) { return buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, extracted_filters: { sort: "period_desc", limit: 20, counterparty: "жуковке 51" }, anchor_value_raw: "жуковке 51", anchor_value_resolved: "ТСЖ \\Жуковка 51\\" } }); } if (compact === followupMessage && options?.followupContext) { return buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, extracted_filters: { sort: "period_desc", limit: 20, counterparty: "жуковке 51" }, anchor_value_raw: "жуковке 51", anchor_value_resolved: "ТСЖ \\Жуковка 51\\", reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } }); } if (compact.startsWith("документы по контрагенту") && options?.followupContext) { return buildAddressLimitedLaneResult("missing_anchor", { debug: { ...buildAddressLimitedLaneResult("missing_anchor").debug, extracted_filters: { sort: "period_desc", limit: 20, counterparty: "кроме" }, anchor_value_raw: "кроме", anchor_value_resolved: "кроме" } }); } 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-safe-retry-${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(second.debug?.address_retry_audit?.attempted).toBe(true); expect(second.debug?.address_retry_audit?.initial_limited_category).toBe("missing_anchor"); expect(second.debug?.address_retry_audit?.retry_message).toBe(followupMessage); expect(calls.some((entry) => String(entry.message).toLowerCase().startsWith("документы по контрагенту"))).toBe(true); expect(calls.some((entry) => String(entry.message).toLowerCase() === followupMessage)).toBe(true); 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 = "А кто из них новые?"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === "что там не так?") { return null; } if (message === lifecycleFollowupMessage && !options?.followupContext) { return null; } if (message === lifecycleFollowupMessage && options?.followupContext) { return buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, detected_intent: "counterparty_activity_lifecycle", extracted_filters: { period_from: "2020-01-01", period_to: "2020-12-31" }, reasons: ["address_action_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult(); }) } as any; const normalizerService = { normalize: vi.fn(async (payload: any) => ({ assistant_reply: payload?.userQuestion === "что там не так?" ? "Нужно уточнение по фокусу." : "unexpected_normalizer_call", reply_type: payload?.userQuestion === "что там не так?" ? "clarification_required" : "partial_coverage", debug: { prompt_version: "address_query_runtime_v1", detected_mode: null, detected_intent: null } })) } 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-clar-${Date.now()}`; const first = await service.handleMessage({ session_id: sessionId, user_message: "покажи документы по свк за 2020", 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: "что там не так?", useMock: true } as any); expect(second.ok).toBe(true); expect(second.reply_type).toBe("clarification_required"); const third = await service.handleMessage({ session_id: sessionId, user_message: lifecycleFollowupMessage, useMock: true } as any); expect(third.ok).toBe(true); expect(third.reply_type).toBe("factual"); expect(third.debug?.detected_mode).toBe("address_query"); expect(third.debug?.detected_intent).toBe("counterparty_activity_lifecycle"); expect(calls.length).toBeGreaterThanOrEqual(2); const contextualCall = calls.find((item) => item.message === lifecycleFollowupMessage && Boolean(item.options?.followupContext)); expect(contextualCall).toBeTruthy(); expect(contextualCall?.options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty"); expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(String(contextualCall?.options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0); expect(normalizerService.normalize).toHaveBeenCalledTimes(1); }); it("does not carry address follow-up context into capability question", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "покажи документы по свк за 2020"; const capabilityMessage = "и 1с можешь настроить?"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (String(message).toLowerCase().includes("свк")) { return buildAddressLaneResult(); } 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 chatClient = { chat: vi.fn().mockResolvedValue({ raw: { id: "chat-should-not-run" }, outputText: "unused", usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } }) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService, chatClient ); const sessionId = `asst-address-followup-capability-${Date.now()}`; const first = await service.handleMessage({ session_id: sessionId, user_message: firstMessage, llmProvider: "local", model: "qwen2.5", useMock: false } as any); expect(first.ok).toBe(true); expect(first.reply_type).toBe("factual"); const second = await service.handleMessage({ session_id: sessionId, user_message: capabilityMessage, llmProvider: "local", model: "qwen2.5", useMock: false } as any); expect(second.ok).toBe(true); expect(second.reply_type).toBe("factual_with_explanation"); expect(String(second.assistant_reply).toLowerCase()).toContain("не настраиваю 1с"); expect(calls).toHaveLength(1); expect(String(calls[0].message).toLowerCase()).toContain("свк"); expect(chatClient.chat).toHaveBeenCalledTimes(0); }); });