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 ?? {}) }; } 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("list_documents_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(); }); });