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(["factual", "factual_with_explanation"]).toContain(second.reply_type); 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(["factual", "factual_with_explanation"]).toContain(second.reply_type); 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(["factual", "factual_with_explanation"]).toContain(second.reply_type); 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("treats short buyer follow-up as continuation of the active provenance object", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "каму в итоге продано"; const provenanceResult = { handled: true, reply_text: "По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) до 31.01.2016 однозначный поставщик не подтвержден.", 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: "Рабочая станция универсального специалиста (индивидуальное изготовление)", warehouse: "Основной склад", organization: "ООО \\Альтернатива Плюс\\", as_of_date: "2016-01-31" }, missing_required_filters: [], selected_recipe: "address_inventory_purchase_provenance_for_item_v1", anchor_type: "item", anchor_value_raw: "Рабочая станция универсального специалиста (индивидуальное изготовление)", anchor_value_resolved: "Рабочая станция универсального специалиста (индивидуальное изготовление)", reasons: ["address_action_detected", "address_entity_detected"], dialog_continuation_contract_v2: { decision: "continue_previous" } } } as any; const saleTraceResult = { handled: true, reply_text: "По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) подтвержден покупатель: Комитет государственных услуг г. Москвы.", reply_type: "factual", response_type: "FACTUAL_LIST", debug: { detected_mode: "address_query", detected_intent: "inventory_sale_trace_for_item", detected_intent_confidence: "medium", extracted_filters: { item: "Рабочая станция универсального специалиста (индивидуальное изготовление)", organization: "ООО \\Альтернатива Плюс\\", as_of_date: "2016-01-31" }, selected_recipe: "address_inventory_sale_trace_for_item_v1", reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } } 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 saleTraceResult; } 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-buyer-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-inventory-provenance-buyer-seed", session_id: sessionId, role: "assistant", text: provenanceResult.reply_text, reply_type: provenanceResult.reply_type, created_at: "2026-04-15T12:24:22.251Z", trace_id: "address-provenance-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(["factual", "factual_with_explanation"]).toContain(second.reply_type); 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( "Рабочая станция универсального специалиста (индивидуальное изготовление)" ); expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад"); expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2016-01-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?.root_intent).toBe("inventory_on_hand_as_of_date"); expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс"); expect(calls[1].options?.followupContext?.root_filters?.as_of_date).toBe("2020-06-30"); expect(calls[1].options?.followupContext?.current_frame_kind).toBe("inventory_root"); 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); if (second.reply_type !== "factual") { throw new Error(JSON.stringify({ calls, secondReplyType: second.reply_type, secondDebug: second.debug }, null, 2)); } expect(second.ok).toBe(true); expect(second.reply_type).toBe("factual"); 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); if (second.reply_type !== "factual") { throw new Error(JSON.stringify({ calls, secondReplyType: second.reply_type, secondDebug: second.debug }, null, 2)); } expect(second.ok).toBe(true); expect(second.reply_type).toBe("factual"); expect(calls).toHaveLength(2); expect(calls[1].message).toBe(followupMessage); 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("prefers the raw referential document follow-up over a degraded rewrite 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); if (second.reply_type !== "factual") { throw new Error(JSON.stringify({ calls, secondReplyType: second.reply_type, secondDebug: second.debug }, null, 2)); } expect(second.reply_type).toBe("factual"); expect(second.debug?.address_retry_audit?.attempted).toBe(false); expect(calls).toHaveLength(2); expect(calls[1].message).toBe(followupMessage); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("switches from document drilldown to bank operations on a pronoun payment follow-up", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "покажи документы по жуковке 51"; const followupMessage = "а по нему платежи?"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && !options?.followupContext) { return null; } if (options?.followupContext?.previous_intent === "bank_operations_by_counterparty") { return buildAddressLaneResult({ reply_text: "Собран список банковских операций по контрагенту.", debug: { ...buildAddressLaneResult().debug, detected_intent: "bank_operations_by_counterparty", selected_recipe: "address_bank_operations_by_counterparty_v1", extracted_filters: { sort: "period_desc", limit: 20, counterparty: "жуковке 51" }, anchor_type: "counterparty", anchor_value_raw: "жуковке 51", anchor_value_resolved: "ТСЖ \\Жуковка 51\\", reasons: ["address_action_detected", "bank_ops_by_counterparty_signal_detected", "address_followup_context_applied"] } }); } return buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, detected_intent: "list_documents_by_counterparty", selected_recipe: "address_documents_by_counterparty_v1", extracted_filters: { sort: "period_desc", limit: 20, counterparty: "жуковке 51" }, anchor_type: "counterparty", anchor_value_raw: "жуковке 51", anchor_value_resolved: "ТСЖ \\Жуковка 51\\" } }); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ assistant_reply: "normalizer_fallback_should_not_be_used", reply_type: "partial_coverage", debug: {} })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const sessionId = `asst-address-docs-to-bank-pronoun-${Date.now()}`; const first = await service.handleMessage({ session_id: sessionId, user_message: firstMessage, useMock: true } as any); expect(first.ok).toBe(true); expect(first.reply_type).toBe("factual"); const second = await service.handleMessage({ session_id: sessionId, user_message: followupMessage, useMock: true } as any); expect(second.ok).toBe(true); expect(second.reply_type).toBe("factual"); expect(calls).toHaveLength(2); expect(calls[1].message).toMatch(/банковские операции|платежи/i); expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty"); expect(calls[1].options?.followupContext?.target_intent).toBe("bank_operations_by_counterparty"); expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("reuses last real address context after intermediate clarification fallback", async () => { const calls: Array<{ message: string; options?: any }> = []; const lifecycleFollowupMessage = "А кто из них новые?"; 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 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 = "\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"); }); it("continues the original inventory query after organization clarification with a bare company reply", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "покажи остатки по складу"; const secondMessage = "Альтернатива"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === firstMessage) { return buildAddressLimitedLaneResult("missing_anchor", { reply_text: [ "Нужно уточнить организацию, чтобы не смешивать компании в одном ответе.", "Сейчас в доступном контуре вижу такие организации:", "- ООО Альтернатива Плюс", "- ООО Лайсвуд" ].join("\n"), debug: { ...buildAddressLimitedLaneResult("missing_anchor").debug, detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { as_of_date: "2026-04-15" }, selected_recipe: null, organization_candidates: ["ООО Альтернатива Плюс", "ООО Лайсвуд"], reasons: ["organization_clarification_required", "multiple_known_organizations_detected"] } }); } if (message === secondMessage && options?.followupContext && options?.activeOrganization === "ООО Альтернатива Плюс") { return buildAddressLaneResult({ reply_text: "На 15.04.2026 по ООО Альтернатива Плюс подтвержден складской остаток.", debug: { ...buildAddressLaneResult().debug, detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { as_of_date: "2026-04-15", organization: "ООО Альтернатива Плюс" }, reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"] } }); } 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-org-clarification-${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("partial_coverage"); 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(calls).toHaveLength(2); expect(calls[1].message).toBe(secondMessage); expect(calls[1].options?.activeOrganization).toBe("ООО Альтернатива Плюс"); expect(calls[1].options?.knownOrganizations).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд"]); expect(calls[1].options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date"); expect(calls[1].options?.followupContext?.previous_filters?.organization).toBe("ООО Альтернатива Плюс"); expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("keeps historical inventory date follow-up alive after company clarification and a capability answer", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "покажи остатки по складу"; const secondMessage = "Альтернатива"; const historicalCapabilityMessage = "а исторические остатки на другие даты умеешь?"; const dateFollowupMessage = "давай на июль 2017"; const organization = "ООО Альтернатива Плюс"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === firstMessage) { return buildAddressLimitedLaneResult("missing_anchor", { reply_text: [ "Нужно уточнить организацию, чтобы не смешивать компании в одном ответе.", "Сейчас в доступном контуре вижу такие организации:", "- ООО Альтернатива Плюс", "- ООО Лайсвуд" ].join("\n"), debug: { ...buildAddressLimitedLaneResult("missing_anchor").debug, detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { as_of_date: "2026-04-19" }, organization_candidates: [organization, "ООО Лайсвуд"], reasons: ["organization_clarification_required", "multiple_known_organizations_detected"] } }); } if (message === secondMessage && options?.followupContext && options?.activeOrganization === organization) { return buildAddressLaneResult({ reply_text: "На 19.04.2026 по ООО Альтернатива Плюс подтвержден складской остаток.", debug: { ...buildAddressLaneResult().debug, detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { as_of_date: "2026-04-19", organization }, reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"] } }); } if (message === dateFollowupMessage && options?.followupContext) { return buildAddressLaneResult({ reply_text: "На 31.07.2017 по ООО Альтернатива Плюс подтвержден складской остаток.", debug: { ...buildAddressLaneResult().debug, detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { organization, as_of_date: "2017-07-31", period_from: "2017-07-01", period_to: "2017-07-31" }, reasons: ["address_followup_context_applied", "inventory_root_temporal_followup_detected"] } }); } 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-org-historical-${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("partial_coverage"); 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"); const third = await service.handleMessage({ session_id: sessionId, user_message: historicalCapabilityMessage, useMock: true } as any); expect(third.ok).toBe(true); expect(["factual", "factual_with_explanation"]).toContain(third.reply_type); const fourth = await service.handleMessage({ session_id: sessionId, user_message: dateFollowupMessage, useMock: true } as any); expect(fourth.ok).toBe(true); expect(fourth.reply_type).toBe("factual"); const dateCall = calls.find((entry) => entry.message === dateFollowupMessage); expect(dateCall).toBeTruthy(); expect(dateCall?.options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date"); expect(dateCall?.options?.followupContext?.previous_filters?.organization).toBe(organization); expect(dateCall?.options?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("sanitizes selected-item carryover when inventory drilldown pivots into VAT follow-up", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "\u0430 \u043d\u0434\u0441?"; const itemLabel = "\u041a\u0440\u043e\u043c\u043a\u0430 \u0441 \u043a\u043b\u0435\u0435\u043c 33 \u0434\u0443\u0431 \u043d\u0438\u0430\u0433\u0430\u0440\u0430 137 \u043c"; const organization = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; const warehouse = "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0441\u043a\u043b\u0430\u0434"; const vatResult = buildAddressLaneResult({ debug: { ...buildAddressLaneResult().debug, detected_intent: "vat_payable_confirmed_as_of_date", extracted_filters: { sort: "period_desc", period_from: "2021-03-01", period_to: "2021-03-31", as_of_date: "2021-03-31", organization }, 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, reasons: [ "address_action_detected", "address_entity_detected", "address_followup_context_applied" ] } }); const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); 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-inventory-vat-pivot-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-inventory-root-seed", session_id: sessionId, role: "assistant", text: "inventory root seed", reply_type: "factual", created_at: "2026-04-15T14:01:00.000Z", trace_id: "address-root-seed", debug: { detected_mode: "address_query", detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { as_of_date: "2021-03-31", period_from: "2021-03-01", period_to: "2021-03-31", organization, warehouse }, selected_recipe: "address_inventory_on_hand_as_of_date_v1" } } as any); sessions.appendItem(sessionId, { message_id: "msg-inventory-sale-seed", session_id: sessionId, role: "assistant", text: "inventory sale trace seed", reply_type: "factual", created_at: "2026-04-15T14:02:00.000Z", trace_id: "address-sale-seed", debug: { detected_mode: "address_query", detected_intent: "inventory_sale_trace_for_item", extracted_filters: { item: itemLabel, organization, as_of_date: "2021-03-31" }, selected_recipe: "address_inventory_sale_trace_for_item_v1", anchor_type: "item", anchor_value_raw: itemLabel, anchor_value_resolved: itemLabel } } 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(second.debug?.detected_intent).toBe("vat_payable_confirmed_as_of_date"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe(followupMessage); expect(calls[0].options?.followupContext?.root_context_only).toBe(true); expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined(); expect(calls[0].options?.followupContext?.previous_anchor_type).toBeUndefined(); expect(calls[0].options?.followupContext?.previous_anchor_value).toBeNull(); expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined(); expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization); expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe(warehouse); expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2021-03-31"); expect(calls[0].options?.followupContext?.previous_filters?.period_from).toBe("2021-03-01"); expect(calls[0].options?.followupContext?.previous_filters?.period_to).toBe("2021-03-31"); expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization); expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2021-03-31"); expect(calls[0].options?.followupContext?.root_filters?.period_from).toBe("2021-03-01"); expect(calls[0].options?.followupContext?.root_filters?.period_to).toBe("2021-03-31"); expect(calls[0].options?.followupContext?.current_frame_kind).toBe("inventory_root"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("treats short supplier follow-up after sale trace as continuation of the active selected object", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "а купили у кого"; const saleTraceResult = { handled: true, reply_text: "По позиции Столешница 600*3050*26 дуб ниагара подтвержден покупатель: ООО \\Ромашка\\.", reply_type: "factual", response_type: "FACTUAL_LIST", debug: { detected_mode: "address_query", detected_intent: "inventory_sale_trace_for_item", detected_intent_confidence: "medium", extracted_filters: { item: "Столешница 600*3050*26 дуб ниагара", organization: "ООО Альтернатива Плюс", as_of_date: "2020-05-31" }, selected_recipe: "address_inventory_sale_trace_for_item_v1", anchor_type: "item", anchor_value_raw: "Столешница 600*3050*26 дуб ниагара", anchor_value_resolved: "Столешница 600*3050*26 дуб ниагара", reasons: ["address_action_detected", "address_entity_detected"] } } as any; 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: "medium", extracted_filters: { item: "Столешница 600*3050*26 дуб ниагара", organization: "ООО Альтернатива Плюс", as_of_date: "2020-05-31" }, selected_recipe: "address_inventory_purchase_provenance_for_item_v1", reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } } as any; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && options?.followupContext) { return provenanceResult; } return saleTraceResult; }) } 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-sale-to-supplier-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-sale-trace-seed", session_id: sessionId, role: "assistant", text: saleTraceResult.reply_text, reply_type: saleTraceResult.reply_type, created_at: "2026-04-15T18:00:00.000Z", trace_id: "address-sale-seed", debug: saleTraceResult.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_sale_trace_for_item"); expect(calls[0].options?.followupContext?.previous_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара"); expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe("ООО Альтернатива Плюс"); expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("does not carry VAT previous_intent into a fresh inventory root query", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "прогноз ндс на март 2020"; const secondMessage = "остаток на складе за май 2020"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === firstMessage) { return { handled: true, reply_text: "Прогноз НДС на март 2020 собран.", reply_type: "factual", response_type: "FACTUAL_SUMMARY", debug: { detected_mode: "address_query", detected_intent: "vat_payable_forecast", detected_intent_confidence: "high", extracted_filters: { period_from: "2020-03-01", period_to: "2020-03-31" }, selected_recipe: "address_vat_payable_forecast_v1" } }; } return { handled: true, reply_text: "Нужно уточнить организацию.", reply_type: "partial_coverage", response_type: "LIMITED_WITH_REASON", debug: { detected_mode: "address_query", detected_intent: "inventory_on_hand_as_of_date", detected_intent_confidence: "high", extracted_filters: { period_from: "2020-05-01", period_to: "2020-05-31", as_of_date: "2020-05-31" }, selected_recipe: null, limited_reason_category: "missing_anchor", reasons: ["organization_clarification_required", "multiple_known_organizations_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-vat-inventory-${Date.now()}`; const first = await service.handleMessage({ session_id: sessionId, user_message: firstMessage, useMock: true } as any); expect(first.ok).toBe(true); const second = await service.handleMessage({ session_id: sessionId, user_message: secondMessage, useMock: true } as any); expect(second.ok).toBe(true); expect(calls.length).toBeGreaterThanOrEqual(2); const inventoryCalls = calls.slice(1); expect(inventoryCalls.every((call) => call.message === secondMessage)).toBe(true); expect(inventoryCalls.every((call) => call.options?.followupContext === undefined)).toBe(true); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("treats short inventory month follow-up after drilldown as continuation of the recent inventory root frame", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "остатки на июль 2019"; const itemLabel = "Четки Пост (84*117)"; const organization = 'ООО "Альтернатива Плюс"'; const inventoryResult = { handled: true, reply_text: "Подтвержденный складской срез на 31.07.2019 собран.", reply_type: "factual", response_type: "FACTUAL_SUMMARY", debug: { detected_mode: "address_query", detected_intent: "inventory_on_hand_as_of_date", detected_intent_confidence: "high", extracted_filters: { as_of_date: "2019-07-31", period_from: "2019-07-01", period_to: "2019-07-31", organization }, selected_recipe: "address_inventory_on_hand_as_of_date_v1", reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } } as any; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && options?.followupContext) { return inventoryResult; } 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-inventory-root-month-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-inventory-root-seed-july", session_id: sessionId, role: "assistant", text: "inventory root seed", reply_type: "factual", created_at: "2026-04-15T19:00:00.000Z", trace_id: "address-root-seed-july", debug: { detected_mode: "address_query", detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { as_of_date: "2020-03-31", period_from: "2020-03-01", period_to: "2020-03-31", organization }, selected_recipe: "address_inventory_on_hand_as_of_date_v1" } } as any); sessions.appendItem(sessionId, { message_id: "msg-inventory-drilldown-seed-july", session_id: sessionId, role: "assistant", text: "inventory provenance seed", reply_type: "factual", created_at: "2026-04-15T19:01:00.000Z", trace_id: "address-drilldown-seed-july", debug: { detected_mode: "address_query", detected_intent: "inventory_purchase_provenance_for_item", extracted_filters: { item: itemLabel, organization }, selected_recipe: "address_inventory_purchase_provenance_for_item_v1", anchor_type: "item", anchor_value_raw: itemLabel, anchor_value_resolved: itemLabel } } 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).toBeUndefined(); expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined(); expect(calls[0].options?.followupContext?.root_context_only).toBe(true); expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization); expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2020-03-31"); expect(calls[0].options?.followupContext?.current_frame_kind).toBe("inventory_root"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("restores inventory root filters from address_root_frame_context instead of drilldown extracted filters", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "остатки на эту же дату"; const itemLabel = "Рабочая станция универсального специалиста"; const organization = 'ООО "Альтернатива Плюс"'; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && options?.followupContext) { return buildAddressLaneResult({ reply_text: "Собран складской срез на дату из root frame.", debug: { ...buildAddressLaneResult().debug, detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { organization, as_of_date: "2021-03-31", period_from: "2021-03-01", period_to: "2021-03-31" }, reasons: ["address_action_detected", "address_entity_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-root-frame-authority-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-root-frame-authority-seed", session_id: sessionId, role: "assistant", text: "inventory purchase documents seed", reply_type: "factual", created_at: "2026-04-19T08:55:00.000Z", trace_id: "address-root-frame-authority-seed", debug: { detected_mode: "address_query", detected_intent: "inventory_purchase_documents_for_item", extracted_filters: { item: itemLabel, organization, as_of_date: "2021-04-15" }, selected_recipe: "address_inventory_purchase_documents_for_item_v1", anchor_type: "item", anchor_value_raw: itemLabel, anchor_value_resolved: itemLabel, address_root_frame_context: { root_intent: "inventory_on_hand_as_of_date", root_filters: { organization, warehouse: "Основной склад", as_of_date: "2021-03-31", period_from: "2021-03-01", period_to: "2021-03-31" }, root_anchor_type: "organization", root_anchor_value: organization, current_frame_kind: "inventory_drilldown" } } } 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].options?.followupContext?.root_context_only).toBe(true); expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization); expect(calls[0].options?.followupContext?.root_filters?.warehouse).toBe("Основной склад"); expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2021-03-31"); expect(calls[0].options?.followupContext?.root_filters?.period_from).toBe("2021-03-01"); expect(calls[0].options?.followupContext?.root_filters?.period_to).toBe("2021-03-31"); expect(calls[0].options?.followupContext?.root_filters?.as_of_date).not.toBe("2021-04-15"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("treats colloquial supplier follow-up from an inventory root slice as continuation of the focused item", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "у кого мы модуль прямоугольный купили кстати"; const itemLabel = "Модуль прямоугольый 1400*110*750"; const organization = 'ООО "Альтернатива Плюс"'; const provenanceResult = { handled: true, reply_text: `По позиции ${itemLabel} подтвержден поставщик: Торговый дом "Союз".`, 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: itemLabel, organization, as_of_date: "2020-05-31" }, selected_recipe: "address_inventory_purchase_provenance_for_item_v1", reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] } } as any; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && options?.followupContext) { return provenanceResult; } 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-root-item-focus-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-inventory-root-seed", session_id: sessionId, role: "assistant", text: `${itemLabel} был в остатках на 31.05.2020.`, reply_type: "factual", created_at: "2026-04-15T19:00:00.000Z", trace_id: "address-root-seed", debug: { detected_mode: "address_query", detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { organization, as_of_date: "2020-05-31", period_from: "2020-05-01", period_to: "2020-05-31" }, selected_recipe: "address_inventory_on_hand_as_of_date_v1" } } as any); sessions.setAddressNavigationState(sessionId, { session_id: sessionId, session_context: { active_focus_object: { object_type: "item", label: itemLabel }, organization_scope: organization, date_scope: { as_of_date: "2020-05-31", period_from: "2020-05-01", period_to: "2020-05-31" } } } as any); const response = await service.handleMessage({ session_id: sessionId, user_message: followupMessage, useMock: true } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe(followupMessage); expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date"); expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("item"); expect(calls[0].options?.followupContext?.previous_anchor_value).toBe(itemLabel); expect(calls[0].options?.followupContext?.previous_filters?.item).toBe(itemLabel); expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization); expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("treats short 'а нам?' as a receivables mirror follow-up after payables answer", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "а нам?"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && options?.followupContext) { return { handled: true, reply_text: "На ту же дату нам должны 125 000 руб.", reply_type: "factual", response_type: "FACTUAL_SUMMARY", debug: { detected_mode: "address_query", detected_intent: "receivables_confirmed_as_of_date", detected_intent_confidence: "high", extracted_filters: { as_of_date: "2026-04-16" }, selected_recipe: "address_receivables_confirmed_as_of_date_v1", reasons: ["address_action_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-a-nam-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-payables-seed", session_id: sessionId, role: "assistant", text: "На сегодня мы должны поставщикам 87 000 руб.", reply_type: "factual", created_at: "2026-04-16T10:00:00.000Z", trace_id: "address-payables-seed", debug: { detected_mode: "address_query", detected_intent: "payables_confirmed_as_of_date", extracted_filters: { as_of_date: "2026-04-16" }, selected_recipe: "address_payables_confirmed_as_of_date_v1" } } as any); const response = await service.handleMessage({ session_id: sessionId, user_message: followupMessage, useMock: true } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(response.debug?.detected_intent).toBe("receivables_confirmed_as_of_date"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe(followupMessage); expect(calls[0].options?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date"); expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2026-04-16"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("keeps document intent for short counterparty retarget like 'а по свк'", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "а по свк"; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && options?.followupContext) { return buildAddressLaneResult({ reply_text: "Собран список документов по контрагенту СВК.", debug: { ...buildAddressLaneResult().debug, extracted_filters: { sort: "period_desc", limit: 20, counterparty: "свк" }, detected_intent: "list_documents_by_counterparty", selected_recipe: "address_documents_by_counterparty_v1", reasons: ["address_action_detected", "address_entity_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-po-svk-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-docs-seed", session_id: sessionId, role: "assistant", text: "Собран список документов по контрагенту Чапурнов.", reply_type: "factual", created_at: "2026-04-16T10:01:00.000Z", trace_id: "address-docs-seed", debug: { detected_mode: "address_query", detected_intent: "list_documents_by_counterparty", extracted_filters: { counterparty: "чапурнов", sort: "period_desc", limit: 20 }, selected_recipe: "address_documents_by_counterparty_v1", anchor_type: "counterparty", anchor_value_raw: "чапурнов", anchor_value_resolved: "Чапурнов" } } as any); const response = await service.handleMessage({ session_id: sessionId, user_message: followupMessage, useMock: true } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(response.debug?.detected_intent).toBe("list_documents_by_counterparty"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe(followupMessage); expect(calls[0].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty"); expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(calls[0].options?.followupContext?.previous_anchor_value).toBe("Чапурнов"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it("does not backfill stale counterparty anchors into inventory root temporal follow-ups", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "остатки РЅР° июль 2019"; const organization = 'ООО "Альтернатива Плюс"'; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (options?.followupContext) { return buildAddressLaneResult({ reply_text: "РќР° 31.07.2019 РЅР° складе подтверждено 4 позиции.", reply_type: "factual", debug: { ...buildAddressLaneResult().debug, detected_intent: "inventory_on_hand_as_of_date", selected_recipe: "address_inventory_on_hand_as_of_date_v1", extracted_filters: { organization, period_from: "2019-07-01", period_to: "2019-07-31", as_of_date: "2019-07-31" }, reasons: ["inventory_on_hand_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-root-month-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-docs-seed", session_id: sessionId, role: "assistant", text: "Собран СЃРїРёСЃРѕРє документов РїРѕ контрагенту РЎР’Рљ.", reply_type: "factual", created_at: "2026-04-16T09:55:00.000Z", trace_id: "address-docs-seed", debug: { detected_mode: "address_query", detected_intent: "list_documents_by_counterparty", extracted_filters: { counterparty: "СЃРІРє", sort: "period_desc", limit: 20 }, selected_recipe: "address_documents_by_counterparty_v1", anchor_type: "counterparty", anchor_value_raw: "СЃРІРє", anchor_value_resolved: "РЎР’Рљ" } } as any); sessions.appendItem(sessionId, { message_id: "msg-inventory-root-seed", session_id: sessionId, role: "assistant", text: "РќР° 16.04.2026 РЅР° складе подтверждено 11 позиций.", reply_type: "factual", created_at: "2026-04-16T10:00:00.000Z", trace_id: "address-inventory-root-seed", debug: { detected_mode: "address_query", detected_intent: "inventory_on_hand_as_of_date", extracted_filters: { organization, as_of_date: "2026-04-16" }, selected_recipe: "address_inventory_on_hand_as_of_date_v1", anchor_type: "organization", anchor_value_raw: organization, anchor_value_resolved: organization } } as any); sessions.setAddressNavigationState(sessionId, { session_id: sessionId, session_context: { active_result_set_id: "rs-inventory-root", active_focus_object: null, last_confirmed_route: "address_inventory_on_hand_as_of_date_v1", date_scope: { as_of_date: "2026-04-16", period_from: null, period_to: null }, organization_scope: organization }, result_sets: [ { result_set_id: "rs-inventory-root", type: "inventory_snapshot", route_id: "address_inventory_on_hand_as_of_date_v1", filters: { organization, as_of_date: "2026-04-16" }, entity_refs: [], source_refs: [], created_from_turn: 2, created_at: "2026-04-16T10:00:00.000Z" } ], navigation_history: [ { event_id: "nav-inventory-root", action: "open", source_result_set_id: null, target_object_id: null, derived_result_set_id: "rs-inventory-root", turn_index: 2, created_at: "2026-04-16T10:00:00.000Z" } ] } as any); const response = await service.handleMessage({ session_id: sessionId, user_message: followupMessage, useMock: true } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].options?.followupContext?.root_context_only).toBe(true); expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined(); expect(calls[0].options?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date"); expect(calls[0].options?.followupContext?.previous_filters?.counterparty).toBeUndefined(); expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization); expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); expect(calls[0].options?.followupContext?.root_filters?.counterparty).toBeUndefined(); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); it.skip("passes grounded MCP discovery payout context into a short year-switch follow-up", async () => { const followupMessage = "а теперь за 2021?"; const calls: Array<{ message: string; options?: any }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string, options?: any) => { calls.push({ message, options }); if (message === followupMessage && options?.followupContext) { return buildAddressLaneResult({ reply_text: "Подтверждены исходящие платежи по Группа СВК за 2021 год.", debug: { ...buildAddressLaneResult().debug, detected_intent: "supplier_payouts_profile", selected_recipe: "address_supplier_payouts_profile_v1", extracted_filters: { counterparty: "Группа СВК", organization: "ООО Альтернатива Плюс", period_from: "2021-01-01", period_to: "2021-12-31" }, reasons: ["address_action_detected", "address_entity_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-discovery-followup-year-switch-${Date.now()}`; sessions.appendItem(sessionId, { message_id: "msg-discovery-payout-seed", session_id: sessionId, role: "assistant", text: "Подтверждены исходящие платежи по Группа СВК за 2020 год.", reply_type: "partial_coverage", created_at: "2026-04-20T10:00:00.000Z", trace_id: "living-discovery-seed", debug: { execution_lane: "living_chat", mcp_discovery_response_applied: true, assistant_active_organization: "ООО Альтернатива Плюс", assistant_mcp_discovery_entry_point_v1: { schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", entry_status: "bridge_executed", turn_input: { turn_meaning_ref: { asked_action_family: "payout", explicit_entity_candidates: ["Группа СВК"], explicit_organization_scope: "ООО Альтернатива Плюс", explicit_date_scope: "2020" } }, bridge: { bridge_status: "answer_draft_ready", business_fact_answer_allowed: true, pilot: { pilot_scope: "counterparty_supplier_payout_query_movements_v1" }, answer_draft: { answer_mode: "confirmed_with_bounded_inference" } } } } } as any); const response = await service.handleMessage({ session_id: sessionId, user_message: followupMessage, useMock: true } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe(followupMessage); expect(calls[0].options?.followupContext?.previous_intent).toBe("supplier_payouts_profile"); expect(calls[0].options?.followupContext?.previous_discovery_pilot_scope).toBe( "counterparty_supplier_payout_query_movements_v1" ); expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("counterparty"); expect(calls[0].options?.followupContext?.previous_anchor_value).toBe("Группа СВК"); expect(calls[0].options?.followupContext?.previous_filters).toMatchObject({ counterparty: "Группа СВК", organization: "ООО Альтернатива Плюс", period_from: "2020-01-01", period_to: "2020-12-31" }); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); });