import { describe, expect, it, vi } from "vitest"; import { AssistantService } from "../src/services/assistantService"; import { AssistantSessionStore } from "../src/services/assistantSessionStore"; function buildAddressLaneResult(message: string): any { return { handled: true, reply_text: `handled: ${message}`, 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: "svk", period_from: "2020-01-01", period_to: "2020-12-31" }, 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: "svk", 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", "document_list_signal_detected"] } }; } describe("assistant address llm pre-decompose candidate preference", () => { it("prefers raw fragment when normalized fragment loses counterparty anchor", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-1", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "бля svk doki za 20 god pokezh pls", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [ { fragment_id: "F1", raw_fragment_text: "svk doki za 20 god pokezh", normalized_fragment_text: "Показать документы за 2020 год", domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: [], account_hints: [], document_hints: ["документы"], register_hints: [], time_scope: { type: "explicit", value: "2020", confidence: "high" }, flags: { has_multi_entity_scope: false, asks_for_chain_explanation: false, asks_for_ranking_or_top: false, asks_for_period_summary: false, asks_for_rule_check: false, asks_for_anomaly_scan: false, asks_for_exact_object_trace: false, asks_for_evidence: false, mentions_period_close_context: false }, candidate_labels: ["simple_factual"], confidence: "medium", execution_readiness: "executable", clarification_reason: null, soft_assumption_used: [], route_status: "routed", no_route_reason: null } ], discarded_fragments: [], global_notes: { needs_clarification: false, clarification_reason: null } }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-${Date.now()}`, user_message: "бля svk doki za 20 god pokezh pls", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("svk doki za 20 god pokezh"); expect(response.debug?.llm_decomposition_attempted).toBe(true); expect(response.debug?.llm_decomposition_applied).toBe(true); expect(response.debug?.llm_decomposition_effective_message).toBe("svk doki za 20 god pokezh"); expect(response.debug?.fallback_rule_hit).toBeNull(); expect(response.debug?.sanitized_user_message).toBeTypeOf("string"); expect(response.debug?.tool_gate_decision).toBe("run_address_lane"); }); it("applies deterministic fallback rule when llm fragment is unusable", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-2", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "свк доки за 20год покеж плс", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-fallback-${Date.now()}`, user_message: "свк доки за 20год покеж плс", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("документы по контрагенту свк за 2020 год"); expect(response.debug?.llm_decomposition_attempted).toBe(true); expect(response.debug?.llm_decomposition_applied).toBe(true); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm"); expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite"); expect(response.debug?.sanitized_user_message).toContain("свк"); expect(response.debug?.tool_gate_decision).toBe("run_address_lane"); }); it("keeps contract anchor in deterministic fallback when llm output is unusable", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-contract-docs", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "Покажи документы по договору 15/24 за 2020", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-contract-docs-${Date.now()}`, user_message: "Покажи документы по договору 15/24 за 2020", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("документы по договору 15/24 за 2020 год"); expect(response.debug?.llm_decomposition_applied).toBe(true); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm"); expect(response.debug?.fallback_rule_hit).toBe("documents_contract_year_rewrite"); }); it("keeps bank-by-contract intent in deterministic fallback when llm output is unusable", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-contract-bank", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "Покажи банковские операции по договору 15/24", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-contract-bank-${Date.now()}`, user_message: "Покажи банковские операции по договору 15/24", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("банковские операции по договору 15/24"); expect(response.debug?.llm_decomposition_applied).toBe(true); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm"); expect(response.debug?.fallback_rule_hit).toBe("bank_operations_contract_rewrite"); }); it("keeps month scope for balance fallback in 'year month' phrasing", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-balance-year-month", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "Какой остаток по счету 60 на 2020 май", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-balance-year-month-${Date.now()}`, user_message: "Какой остаток по счету 60 на 2020 май", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("остаток по счету 60 на 2020-05"); expect(response.debug?.llm_decomposition_applied).toBe(true); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm"); expect(response.debug?.fallback_rule_hit).toBe("balance_month_period_rewrite"); }); it("does not pick service words as counterparty anchor in noisy docs query", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-noisy-counterparty", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "что по свк за 2020 год выведи все доки плиз что есть", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-noisy-counterparty-${Date.now()}`, user_message: "что по свк за 2020 год выведи все доки плиз что есть", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("документы по контрагенту свк за 2020 год"); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm"); expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite"); }); it("rewrites payment-style counterparty phrasing to bank operations", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-bank-counterparty", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "какие платежи были по свк в 2020", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-bank-counterparty-${Date.now()}`, user_message: "какие платежи были по свк в 2020", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("банковские операции по контрагенту свк за 2020 год"); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm"); expect(response.debug?.fallback_rule_hit).toBe("bank_operations_counterparty_year_rewrite"); }); it("rewrites shorthand bank/contract slang phrase to bank operations by contract", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-bank-contract-slang", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "покажи банк опер по дог 15/24 пж", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-bank-contract-slang-${Date.now()}`, user_message: "покажи банк опер по дог 15/24 пж", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("банковские операции по договору 15/24"); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm"); expect(response.debug?.fallback_rule_hit).toBe("bank_operations_contract_rewrite"); }); it("keeps loose all-time colloquial lookup in address lane without forcing rewrite", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-loose-all-time", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "по свк за весь период че есть", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-loose-all-time-${Date.now()}`, user_message: "по свк за весь период че есть", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("по свк за весь период че есть"); expect(response.debug?.llm_decomposition_applied).toBe(false); expect(response.debug?.llm_decomposition_reason).toBe("not_address_like"); expect(response.debug?.fallback_rule_hit).toBeNull(); expect(response.debug?.tool_gate_decision).toBe("run_address_lane"); }); it("normalizes short ordinal year like '20й' in noisy docs phrasing", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-short-ordinal-year", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "бля епт покажи доки по свк за 20й", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-short-ordinal-year-${Date.now()}`, user_message: "бля епт покажи доки по свк за 20й", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("документы по контрагенту свк за 2020 год"); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm"); expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite"); }); it("does not treat date fragments as account in balance fallback", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-date-not-account", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "есть ли долг по договору 1-ПМ/2020 на 2020-07-31", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [] }, raw_model_output: null, validation: { passed: true, errors: [] }, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, latency_ms: 10, prompt_version: "normalizer_v2_0_2", schema_version: "v2_0_2", request_count_for_case: 1 })) } as any; const sessions = new AssistantSessionStore(); const service = new AssistantService( normalizerService, sessions as any, {} as any, { persistSession: vi.fn() } as any, addressQueryService ); const response = await service.handleMessage({ session_id: `asst-predecompose-date-not-account-${Date.now()}`, user_message: "есть ли долг по договору 1-ПМ/2020 на 2020-07-31", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(1); expect(calls[0].message).not.toContain("остаток по счету 07"); expect(calls[0].message.toLowerCase()).toContain("договору 1-пм/2020"); }); });