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 bare 'когда' as a selected-item inventory follow-up for the active provenance object", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "когда"; const provenanceResult = { handled: true, reply_text: "Товар Столешница 600*3050*26 дуб ниагара по доступным закупочным движениям связан с поставщиком: Торговый дом \\Союз\\.", reply_type: "factual", response_type: "FACTUAL_SUMMARY", debug: { detected_mode: "address_query", detected_intent: "inventory_purchase_provenance_for_item", detected_intent_confidence: "high", extracted_filters: { item: "Столешница 600*3050*26 дуб ниагара", warehouse: "Основной склад", as_of_date: "2019-03-31" }, missing_required_filters: [], selected_recipe: "address_inventory_purchase_provenance_for_item_v1", anchor_type: "unknown", anchor_value_raw: null, anchor_value_resolved: null, reasons: ["address_action_detected", "address_entity_detected"], dialog_continuation_contract_v2: { decision: "continue_previous" } } } as any; 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 { ...provenanceResult, reply_text: "Позиция Столешница 600*3050*26 дуб ниагара куплена 12.02.2019.\n\nПодтверждение:\n- Первый подтверждающий документ: Поступление товаров и услуг 00000000003 от 12.02.2019.", debug: { ...provenanceResult.debug, reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } }; } return provenanceResult; }) } 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-when-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-inventory-provenance-seed", session_id: sessionId, role: "assistant", text: provenanceResult.reply_text, reply_type: provenanceResult.reply_type, created_at: "2026-04-14T18:00:00.000Z", trace_id: "address-seed", debug: provenanceResult.debug } as any); 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(1); expect(calls[0].message).toBe(followupMessage); expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item"); expect(calls[0].options?.followupContext?.previous_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара"); expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад"); expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2019-03-31"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("keeps historical stock date window for selected-object supplier wording 'у кого куплено'", async () => { const calls: Array<{ message: string; options?: any }> = []; const rootMessage = 'какие у нас остатки на складе на июнь 2020'; const followupMessage = 'По выбранному объекту "Конструкция трансформер рабочей станции 1300*900*2000": у кого куплено'; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === rootMessage) { return { handled: true, reply_text: "На 30.06.2020 на складе подтверждено 12 позиций с остатком на 755.392,33 ₽.\n1. Конструкция трансформер рабочей станции 1300*900*2000 | склад: Основной склад", reply_type: "factual", response_type: "FACTUAL_LIST", debug: { detected_mode: "address_query", detected_intent: "inventory_on_hand_as_of_date", detected_intent_confidence: "high", extracted_filters: { as_of_date: "2020-06-30", period_from: "2020-06-01", period_to: "2020-06-30", warehouse: "Основной склад", organization: "ООО \\Альтернатива Плюс\\" }, selected_recipe: "address_inventory_on_hand_as_of_date_v1", capability_id: "confirmed_inventory_on_hand_as_of_date", capability_route_mode: "exact", anchor_type: "unknown", anchor_value_raw: null, anchor_value_resolved: null, reasons: ["address_action_detected", "address_entity_detected"] } }; } return { handled: true, reply_text: "Поставщик по позиции Конструкция трансформер рабочей станции 1300*900*2000: ООО \\Гамма-мебель\\.", reply_type: "factual", response_type: "FACTUAL_SUMMARY", debug: { detected_mode: "address_query", detected_intent: "inventory_purchase_provenance_for_item", detected_intent_confidence: "medium", extracted_filters: { item: "Конструкция трансформер рабочей станции 1300*900*2000", as_of_date: "2020-06-30", period_from: "2020-06-01", period_to: "2020-06-30" }, selected_recipe: "address_inventory_purchase_provenance_for_item_v1", capability_id: "inventory_inventory_purchase_provenance_for_item", capability_route_mode: "exact", reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] }, ...(options?.followupContext ? {} : {}) }; }) } 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-u-kogo-kupleno-${Date.now()}`; const first = await service.handleMessage({ session_id: sessionId, user_message: rootMessage, 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("inventory_on_hand_as_of_date"); expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-06-30"); expect(calls[1].options?.followupContext?.previous_filters?.period_from).toBe("2020-06-01"); expect(calls[1].options?.followupContext?.previous_filters?.period_to).toBe("2020-06-30"); expect(calls[1].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад"); 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("resolves counterparty mention from previous displayed list and carries it into value follow-up", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "с кем мы работали в 2020 годы- покажи клиентов"; const followupMessage = "сколько денег за 2020 принес калинин?"; const lifecycleReply = [ "Активные заказчики в 2020 году: 3.", "1. Группа | операций: 13 | последняя активность: 2020-12-30T12:00:00Z | лет в базе: 1", "2. ИП Калинин Н.М. | операций: 2 | последняя активность: 2020-03-02T12:00:03Z | лет в базе: 1", "3. Смарт | операций: 1 | последняя активность: 2020-02-07T12:00:03Z | лет в базе: 1" ].join("\n"); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage) { if (options?.followupContext?.previous_filters?.counterparty !== "ИП Калинин Н.М.") { return null; } return buildAddressLaneResult({ reply_text: "ИП Калинин Н.М. | сумма: 216600 | операций: 2", debug: { ...buildAddressLaneResult().debug, detected_intent: "customer_revenue_and_payments", selected_recipe: "address_customer_revenue_and_payments_v1", extracted_filters: { period_from: "2020-01-01", period_to: "2020-12-31", counterparty: "ИП Калинин Н.М." }, anchor_type: "counterparty", anchor_value_raw: "калинин", anchor_value_resolved: "ИП Калинин Н.М.", reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult({ reply_text: lifecycleReply, debug: { ...buildAddressLaneResult().debug, detected_intent: "counterparty_activity_lifecycle", selected_recipe: "address_counterparty_activity_lifecycle_v1", extracted_filters: { period_from: "2020-01-01", period_to: "2020-12-31" }, anchor_type: "unknown", anchor_value_raw: null, anchor_value_resolved: null, reasons: ["address_action_detected", "counterparty_activity_lifecycle_signal_detected"] } }); }) } 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-kalinin-${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?.detected_intent).toBe("customer_revenue_and_payments"); expect(second.debug?.extracted_filters?.counterparty).toBe("ИП Калинин Н.М."); const contextualCall = calls.find( (entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "ИП Калинин Н.М." ); expect(contextualCall).toBeTruthy(); expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("ИП Калинин Н.М."); expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true); expect(second.debug?.dialog_continuation_contract_v2?.target_intent).toBe("customer_revenue_and_payments"); expect(second.debug?.dialog_continuation_contract_v2?.decision).toBe("continue_previous"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("resolves short declined counterparty mention from displayed top list into contracts follow-up", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "топ топ клиентов по приходам за 2020"; const followupMessage = "покажи договор по гамме"; const topReply = [ "Топ-6 заказчиков по сумме поступлений:", "1. Группа | сумма: 12093465 | операций: 13 | средний чек: 930266.54 | макс: 3320600", "2. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | сумма: 1642764.88 | операций: 1 | средний чек: 1642764.88 | макс: 1642764.88", "4. Гамма-мебель, ООО | сумма: 471000 | операций: 2 | средний чек: 235500.00 | макс: 250000", "5. «Олимпстрой» | сумма: 276873.6 | операций: 1 | средний чек: 276873.60 | макс: 276873.6" ].join("\n"); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage) { if (options?.followupContext?.previous_filters?.counterparty !== "Гамма-мебель, ООО") { return null; } return buildAddressLaneResult({ reply_text: "Собран список договоров по контрагенту Гамма-мебель, ООО.", debug: { ...buildAddressLaneResult().debug, detected_intent: "list_contracts_by_counterparty", selected_recipe: "address_contracts_by_counterparty_v1", extracted_filters: { period_from: "2020-01-01", period_to: "2020-12-31", counterparty: "Гамма-мебель, ООО" }, anchor_type: "counterparty", anchor_value_raw: "гамме", anchor_value_resolved: "Гамма-мебель, ООО", reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult({ reply_text: topReply, debug: { ...buildAddressLaneResult().debug, detected_intent: "customer_revenue_and_payments", selected_recipe: "address_customer_revenue_and_payments_v1", extracted_filters: { period_from: "2020-01-01", period_to: "2020-12-31" }, anchor_type: "unknown", anchor_value_raw: null, anchor_value_resolved: null, reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected"] } }); }) } 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-gamma-${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?.detected_intent).toBe("list_contracts_by_counterparty"); expect(second.debug?.extracted_filters?.counterparty).toBe("Гамма-мебель, ООО"); const contextualCall = calls.find( (entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "Гамма-мебель, ООО" ); expect(contextualCall).toBeTruthy(); expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Гамма-мебель, ООО"); expect(contextualCall?.options?.followupContext?.previous_filters?.period_from).toBe("2020-01-01"); expect(contextualCall?.options?.followupContext?.previous_filters?.period_to).toBe("2020-12-31"); expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true); expect(second.debug?.dialog_continuation_contract_v2?.target_intent).toBe("list_contracts_by_counterparty"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("resolves numbered item from displayed top list into counterparty drill-down", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "топ топ клиентов по приходам за 2020"; const followupMessage = "покажи договор по пункту 4"; const topReply = [ "Топ-6 заказчиков по сумме поступлений:", "1. Группа | сумма: 12093465 | операций: 13 | средний чек: 930266.54 | макс: 3320600", "2. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | сумма: 1642764.88 | операций: 1 | средний чек: 1642764.88 | макс: 1642764.88", "4. Гамма-мебель, ООО | сумма: 471000 | операций: 2 | средний чек: 235500.00 | макс: 250000", "5. «Олимпстрой» | сумма: 276873.6 | операций: 1 | средний чек: 276873.60 | макс: 276873.6" ].join("\n"); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage) { if (options?.followupContext?.previous_filters?.counterparty !== "Гамма-мебель, ООО") { return null; } return buildAddressLaneResult({ reply_text: "Собран список договоров по контрагенту Гамма-мебель, ООО.", debug: { ...buildAddressLaneResult().debug, detected_intent: "list_contracts_by_counterparty", selected_recipe: "address_contracts_by_counterparty_v1", extracted_filters: { period_from: "2020-01-01", period_to: "2020-12-31", counterparty: "Гамма-мебель, ООО" }, anchor_type: "counterparty", anchor_value_raw: "пункт 4", anchor_value_resolved: "Гамма-мебель, ООО", reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult({ reply_text: topReply, debug: { ...buildAddressLaneResult().debug, detected_intent: "customer_revenue_and_payments", selected_recipe: "address_customer_revenue_and_payments_v1", extracted_filters: { period_from: "2020-01-01", period_to: "2020-12-31" }, anchor_type: "unknown", anchor_value_raw: null, anchor_value_resolved: null, reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected"] } }); }) } 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-index-counterparty-${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?.detected_intent).toBe("list_contracts_by_counterparty"); expect(second.debug?.extracted_filters?.counterparty).toBe("Гамма-мебель, ООО"); const contextualCall = calls.find( (entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "Гамма-мебель, ООО" ); expect(contextualCall).toBeTruthy(); expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Гамма-мебель, ООО"); expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("resolves numbered contract item from previous contract list into documents-by-contract follow-up", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "покажи договоры по гамме"; const followupMessage = "покажи документы по пункту 2"; const contractsReply = [ "Собран список договоров по контрагенту Гамма-мебель, ООО.", "1. Договор № 1-ГМ/2020 | операций: 4 | последняя активность: 2020-12-14T12:00:00Z", "2. Договор № 2-ГМ/2020 | операций: 3 | последняя активность: 2020-12-30T12:00:00Z", "3. Договор № 3-ГМ/2020 | операций: 1 | последняя активность: 2020-08-11T13:15:30Z" ].join("\n"); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage) { if (options?.followupContext?.previous_filters?.contract !== "Договор № 2-ГМ/2020") { return null; } return buildAddressLaneResult({ reply_text: "Собран список документов по договору № 2-ГМ/2020.", debug: { ...buildAddressLaneResult().debug, detected_intent: "list_documents_by_contract", selected_recipe: "address_documents_by_contract_v1", extracted_filters: { contract: "Договор № 2-ГМ/2020" }, anchor_type: "contract", anchor_value_raw: "пункт 2", anchor_value_resolved: "Договор № 2-ГМ/2020", reasons: ["address_action_detected", "documents_by_contract_signal_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult({ reply_text: contractsReply, debug: { ...buildAddressLaneResult().debug, detected_intent: "list_contracts_by_counterparty", selected_recipe: "address_contracts_by_counterparty_v1", extracted_filters: { counterparty: "Гамма-мебель, ООО", period_from: "2020-01-01", period_to: "2020-12-31" }, anchor_type: "counterparty", anchor_value_raw: "гамма", anchor_value_resolved: "Гамма-мебель, ООО", reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected"] } }); }) } 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-index-contract-${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?.detected_intent).toBe("list_documents_by_contract"); expect(second.debug?.extracted_filters?.contract).toBe("Договор № 2-ГМ/2020"); const contextualCall = calls.find( (entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.contract === "Договор № 2-ГМ/2020" ); expect(contextualCall).toBeTruthy(); expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("contract"); expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Договор № 2-ГМ/2020"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); 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); }); it("keeps debt lifecycle follow-up context for 'а нам кто должен?.' after payables as-of answer", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "\u043a\u043e\u043c\u0443 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017"; const followupMessage = "\u0430 \u043d\u0430\u043c \u043a\u0442\u043e \u0434\u043e\u043b\u0436\u0435\u043d?."; const payablesResult = buildAddressLaneResult({ reply_text: "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u0441\u0442\u0432 \u043a \u043e\u043f\u043b\u0430\u0442\u0435 \u043d\u0430 30.09.2017", debug: { ...buildAddressLaneResult().debug, query_shape: "UNKNOWN", query_shape_confidence: "low", detected_intent: "payables_confirmed_as_of_date", detected_intent_confidence: "high", extracted_filters: { sort: "period_desc", limit: 20, period_from: "2017-09-01", period_to: "2017-09-30", as_of_date: "2017-09-30" }, selected_recipe: "address_payables_confirmed_as_of_date_v1", response_type: "FACTUAL_LIST", requested_result_mode: "confirmed_balance", result_mode: "confirmed_balance", balance_confirmed: true, reasons: ["payables_debt_lifecycle_signal_detected", "confirmed_balance_exact_payables_intent"] } }); const receivablesResult = buildAddressLaneResult({ reply_text: "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a\u043e\u0439 \u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u0438 \u043d\u0430 30.09.2017", debug: { ...buildAddressLaneResult().debug, query_shape: "UNKNOWN", query_shape_confidence: "low", detected_intent: "receivables_confirmed_as_of_date", detected_intent_confidence: "high", extracted_filters: { sort: "period_desc", limit: 20, period_from: "2017-09-01", period_to: "2017-09-30", as_of_date: "2017-09-30" }, selected_recipe: "address_receivables_confirmed_as_of_date_v1", response_type: "FACTUAL_LIST", requested_result_mode: "confirmed_balance", result_mode: "confirmed_balance", balance_confirmed: true, reasons: ["receivables_debt_lifecycle_signal_detected", "confirmed_balance_exact_receivables_intent"] } }); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === firstMessage) { return payablesResult; } if (message === followupMessage) { if (!options?.followupContext) { return null; } return receivablesResult; } 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-debt-${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?.detected_intent).toBe("receivables_confirmed_as_of_date"); expect(second.debug?.selected_recipe).toBe("address_receivables_confirmed_as_of_date_v1"); expect(calls).toHaveLength(2); expect(calls[1].message).toBe(followupMessage); expect(calls[1].options?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date"); expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("mirrors receivables->payables for short follow-up 'a мы кому' and keeps as-of date", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "кто нам должен на сентябрь 2017"; const followupMessage = "a мы кому"; const receivablesResult = buildAddressLaneResult({ reply_text: "Подтвержденный срез дебиторской задолженности на 30.09.2017", debug: { ...buildAddressLaneResult().debug, query_shape: "UNKNOWN", query_shape_confidence: "low", detected_intent: "receivables_confirmed_as_of_date", detected_intent_confidence: "high", extracted_filters: { sort: "period_desc", limit: 20, period_from: "2017-09-01", period_to: "2017-09-30", as_of_date: "2017-09-30" }, selected_recipe: "address_receivables_confirmed_as_of_date_v1", response_type: "FACTUAL_LIST", requested_result_mode: "confirmed_balance", result_mode: "confirmed_balance", balance_confirmed: true, reasons: ["receivables_debt_lifecycle_signal_detected", "confirmed_balance_exact_receivables_intent"] } }); const payablesResult = buildAddressLaneResult({ reply_text: "Подтвержденный срез обязательств к оплате на 30.09.2017", debug: { ...buildAddressLaneResult().debug, query_shape: "UNKNOWN", query_shape_confidence: "low", detected_intent: "payables_confirmed_as_of_date", detected_intent_confidence: "high", extracted_filters: { sort: "period_desc", limit: 20, period_from: "2017-09-01", period_to: "2017-09-30", as_of_date: "2017-09-30" }, selected_recipe: "address_payables_confirmed_as_of_date_v1", response_type: "FACTUAL_LIST", requested_result_mode: "confirmed_balance", result_mode: "confirmed_balance", balance_confirmed: true, reasons: ["payables_debt_lifecycle_signal_detected", "confirmed_balance_exact_payables_intent"] } }); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === firstMessage) { return receivablesResult; } if (message === followupMessage) { if (!options?.followupContext) { return null; } if (options?.followupContext?.previous_intent !== "payables_confirmed_as_of_date") { return null; } return payablesResult; } 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-debt-mirror-${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?.detected_intent).toBe("payables_confirmed_as_of_date"); expect(second.debug?.selected_recipe).toBe("address_payables_confirmed_as_of_date_v1"); expect(calls).toHaveLength(2); expect(calls[1].message).toBe(followupMessage); expect(calls[1].options?.followupContext?.previous_intent).toBe("payables_confirmed_as_of_date"); expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("keeps short VAT follow-up in address lane after debt as-of answer", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "\u043a\u043e\u043c\u0443 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017"; const followupMessage = "\u0430 \u043d\u0434\u0441?"; const payablesResult = buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, detected_intent: "payables_confirmed_as_of_date", extracted_filters: { sort: "period_desc", limit: 20, period_from: "2017-09-01", period_to: "2017-09-30", as_of_date: "2017-09-30" }, selected_recipe: "address_payables_confirmed_as_of_date_v1", response_type: "FACTUAL_LIST", requested_result_mode: "confirmed_balance", result_mode: "confirmed_balance", balance_confirmed: true } }); const vatResult = buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, detected_intent: "vat_payable_confirmed_as_of_date", extracted_filters: { sort: "period_desc", limit: 20, period_from: "2017-09-01", period_to: "2017-09-30", as_of_date: "2017-09-30" }, selected_recipe: "address_vat_payable_confirmed_as_of_date_v1", response_type: "FACTUAL_LIST", requested_result_mode: "confirmed_balance", result_mode: "confirmed_balance", balance_confirmed: true } }); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === firstMessage) { return payablesResult; } if (!options?.followupContext) { return null; } return vatResult; }) } 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-vat-${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?.detected_intent).toBe("vat_payable_confirmed_as_of_date"); expect(second.debug?.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1"); expect(calls).toHaveLength(2); expect(typeof calls[1].message).toBe("string"); expect(String(calls[1].message).length).toBeGreaterThan(0); expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("keeps month-only VAT follow-up phrase in address lane", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "\u0441\u043a\u043e\u043a \u043d\u0434\u0441 \u043d\u0430\u0434\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c \u0432 \u043d\u0430\u043b\u043e\u0433\u043e\u0432\u0443\u044e \u043d\u0430 \u0444\u0435\u0432\u0440\u0430\u043b\u044c 2017"; const followupMessage = "\u0430 \u043d\u0430 \u043c\u0430\u0440\u0442"; const firstVatResult = buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, detected_intent: "vat_liability_confirmed_for_tax_period", extracted_filters: { sort: "period_desc", limit: 20, period_from: "2017-01-01", period_to: "2017-03-31" }, selected_recipe: "address_vat_liability_confirmed_tax_period_v1", response_type: "FACTUAL_SUMMARY", requested_result_mode: "confirmed_balance", result_mode: "confirmed_balance", balance_confirmed: true } }); const followupVatResult = buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, detected_intent: "vat_liability_confirmed_for_tax_period", extracted_filters: { sort: "period_desc", limit: 20, period_from: "2017-01-01", period_to: "2017-03-31" }, selected_recipe: "address_vat_liability_confirmed_tax_period_v1", response_type: "FACTUAL_SUMMARY", requested_result_mode: "confirmed_balance", result_mode: "confirmed_balance", balance_confirmed: true, reasons: [ "address_action_detected", "vat_liability_confirmed_tax_period_signal_detected", "address_followup_context_applied" ] } }); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === firstMessage) { return firstVatResult; } if (!options?.followupContext) { return null; } return followupVatResult; }) } 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-vat-march-${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?.detected_intent).toBe("vat_liability_confirmed_for_tax_period"); expect(second.debug?.selected_recipe).toBe("address_vat_liability_confirmed_tax_period_v1"); expect(calls).toHaveLength(2); expect(calls[1].message).toBe(followupMessage); expect(calls[1].options?.followupContext?.previous_intent).toBe("vat_liability_confirmed_for_tax_period"); expect(calls[1].options?.followupContext?.previous_filters?.period_from).toBe("2017-01-01"); expect(calls[1].options?.followupContext?.previous_filters?.period_to).toBe("2017-03-31"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("keeps 'a na tekushuyu datu' VAT follow-up in address lane", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "\u0441\u043a\u043e\u043a \u043d\u0430\u0434\u043e \u043d\u0434\u0441 \u043f\u043b\u0430\u0442\u0438\u0442\u044c \u0441 \u0430\u043f\u0440\u0435\u043b\u0435 2017"; const followupMessage = "\u0430 \u043d\u0430 \u0442\u0435\u043a\u0443\u0449\u0443\u044e \u0434\u0430\u0442\u0443"; const vatAsOfResult = buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, detected_intent: "vat_payable_confirmed_as_of_date", extracted_filters: { sort: "period_desc", limit: 20, period_from: "2017-04-01", period_to: "2017-04-30", as_of_date: "2017-04-30" }, selected_recipe: "address_vat_payable_confirmed_as_of_date_v1", response_type: "FACTUAL_SUMMARY", requested_result_mode: "confirmed_balance", result_mode: "confirmed_balance", balance_confirmed: true } }); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === firstMessage) { return vatAsOfResult; } if (!options?.followupContext) { return null; } return vatAsOfResult; }) } 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-vat-current-date-${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("vat_payable_confirmed_as_of_date"); expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-04-30"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("passes active organization scope into address lane follow-up context", async () => { const calls: Array<{ message: string; options?: any }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); 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-org-scope-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-seed-org", session_id: sessionId, role: "assistant", text: "Data scope organizations are available.", reply_type: "factual_with_explanation", created_at: new Date().toISOString(), trace_id: "chat-org-seed", debug: { trace_id: "chat-org-seed", living_chat_data_scope_probe_status: "resolved", living_chat_data_scope_probe_organizations: ["Alternative Plus LLC", "Lacewood LLC", "RIME"], assistant_active_organization: "Alternative Plus LLC" } } as any); const response = await service.handleMessage({ session_id: sessionId, user_message: "show docs by svk for 2020", useMock: true } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls.length).toBeGreaterThan(0); const scopedCall = calls.find((entry) => Boolean(entry.options?.followupContext?.previous_filters?.organization)); expect(scopedCall).toBeTruthy(); expect(scopedCall?.options?.followupContext?.previous_filters?.organization).toBe("Alternative Plus LLC"); }); });