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"); expect(response.debug?.llm_predecompose_contract?.schema_version).toBe("address_llm_predecompose_contract_v1"); expect(["unknown", "list_documents_by_counterparty"]).toContain(response.debug?.llm_predecompose_contract?.intent); }); it("keeps counterparty anchor for docy typo when llm fragment drops anchor", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); if (message === "получить остатки по складу для организации 'альтернатива'") { 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: "UNKNOWN", query_shape_confidence: "low", detected_intent: "inventory_on_hand_as_of_date", detected_intent_confidence: "high", extracted_filters: { sort: "period_desc", organization: "альтернатива", counterparty: "альтернатива", as_of_date: "2026-04-15" }, missing_required_filters: [], selected_recipe: "address_inventory_on_hand_as_of_date_v1", mcp_call_status_legacy: "matched_non_empty", account_scope_mode: "strict", 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: 1, raw_rows_received: 1, rows_after_account_scope: 1, rows_after_recipe_filter: 1, rows_materialized: 1, rows_matched: 1, 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: ["inventory_on_hand_signal_detected"] } }; } return buildAddressLaneResult(message); }) } as any; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-docy", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "svk poka docy za 2020", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [ { fragment_id: "F1", raw_fragment_text: "svk poka docy za 2020", 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-docy-${Date.now()}`, user_message: "svk poka docy za 2020", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(calls).toHaveLength(1); expect(calls[0].message.toLowerCase()).toContain("svk"); expect(calls[0].message).not.toBe("Покажи документы за 2020 год"); expect(String(response.debug?.llm_decomposition_effective_message ?? "").toLowerCase()).toContain("svk"); expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied"); }); it("keeps colloquial warehouse snapshot questions in exact inventory contour when llm rewrites them abstractly", 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-inventory-colloquial", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "что у нас на складе на март 2017", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [ { fragment_id: "F1", raw_fragment_text: "что у нас на складе на март 2017", normalized_fragment_text: "информация о наличии товаров на складе за март 2017 года", domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: [], account_hints: [], document_hints: [], register_hints: [], time_scope: { type: "explicit", value: "2017-03", 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-inventory-colloquial-${Date.now()}`, user_message: "что у нас на складе на март 2017", 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.toLowerCase()).toContain("складе"); expect(response.debug?.tool_gate_decision).toBe("run_address_lane"); expect(response.debug?.llm_predecompose_contract?.intent).toBe("inventory_on_hand_as_of_date"); expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied"); }); it("rejects selected-object rewrite that drops object context and injects generic documents intent", 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-selected-object-context-loss", 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: [ { fragment_id: "F1", raw_fragment_text: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это', normalized_fragment_text: "Покупка рабочей станции универсального специалиста (индивидуальное изготовление). Кто поставщик и какие документы подтверждают?", domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: [], account_hints: [], document_hints: [], register_hints: [], time_scope: { type: "missing", value: null, confidence: "low" }, 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: true, 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-selected-object-${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([ "normalized_fragment_rejected_intent_conflict", "normalized_fragment_rejected_selected_object_context_loss" ]).toContain(response.debug?.llm_decomposition_reason); }); it("accepts exact selected-object sale rewrite when llm candidate stays on the same item", 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 (payload: any) => { if (payload?.userQuestion === "какие остатки по складу у альтернативы") { return { trace_id: "norm-predecompose-root-stock", 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: [ { fragment_id: "F1", raw_fragment_text: "какие остатки по складу у альтернативы", normalized_fragment_text: "получить остатки по складу для организации 'альтернатива'", domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: [], account_hints: [], document_hints: [], register_hints: [], time_scope: { type: "missing", value: null, confidence: "low" }, 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 }; } return { trace_id: "norm-predecompose-selected-object-sale-drift", 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: [ { fragment_id: "F1", raw_fragment_text: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге', normalized_fragment_text: "Определить контрагента, которому была реализована позиция «Рабочая станция универсального специалиста (индивидуальное изготовление)» по выбранному объекту", domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: [], account_hints: [], document_hints: [], register_hints: [], time_scope: { type: "missing", value: null, confidence: "low" }, 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: true, 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 sessionId = `asst-predecompose-selected-object-sale-${Date.now()}`; await service.handleMessage({ session_id: sessionId, user_message: "какие остатки по складу у альтернативы", llmProvider: "local", useMock: false } as any); const response = await service.handleMessage({ session_id: sessionId, user_message: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге', llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual"); expect(calls).toHaveLength(2); expect(calls[1].message).toBe( "Определить контрагента, которому была реализована позиция «Рабочая станция универсального специалиста (индивидуальное изготовление)» по выбранному объекту" ); expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_applied"); }); it("keeps a canonical selected-object sale rewrite executable even when llm semantic hints collapse the item noun", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const sourceMessage = 'По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": кому продали'; const candidateMessage = "Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту"; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-item-anchor-degradation", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: sourceMessage, message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [ { fragment_id: "F1", raw_fragment_text: sourceMessage, normalized_fragment_text: candidateMessage, semantic_hints: { scope_target_kind: "item", scope_target_text: "Кромка", date_scope_kind: "missing", self_scope_detected: false, selected_object_scope_detected: true }, domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: [], account_hints: [], document_hints: [], register_hints: [], time_scope: { type: "missing", value: null, confidence: "low" }, 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: true, 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-item-anchor-degradation-${Date.now()}`, user_message: sourceMessage, 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(candidateMessage); expect(String(response.debug?.llm_decomposition_effective_message ?? "")).toBe(candidateMessage); }); it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", 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-skazhi-anchor", 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: [ { fragment_id: "F1", raw_fragment_text: "свк списания/поступления за 2020", 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-skazhi-anchor-${Date.now()}`, user_message: "свк списания/поступления за 2020", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(calls).toHaveLength(1); expect(calls[0].message.toLowerCase()).toContain("свк"); expect(calls[0].message.toLowerCase()).not.toContain("скажи"); expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied"); }); it("rejects llm fragment when counterparty anchor is substituted by unrelated noun", 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-anchor-substitution", 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: [ { fragment_id: "F1", raw_fragment_text: "свк доки за 20й", normalized_fragment_text: "сканирование документов за 20-й период", 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-anchor-substitution-${Date.now()}`, user_message: "свк доки за 20й", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(calls).toHaveLength(1); expect(calls[0].message.toLowerCase()).toContain("свк"); expect(calls[0].message.toLowerCase()).not.toContain("сканирование"); expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_anchor_substitution"); }); it("rejects diagnostic canonical rewrite like 'Неясно...' for debt-intent repair message", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { tryHandle: vi.fn(async (message: string) => { calls.push({ message }); return buildAddressLaneResult(message); }) } as any; const sourceMessage = "нет вопрос кто нам в целом должен на денег на эту дату"; const candidateMessage = "Неясно, кто должен компании деньги на данную дату."; const normalizerService = { normalize: vi.fn(async () => ({ trace_id: "norm-predecompose-diagnostic-rewrite", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: sourceMessage, message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [ { fragment_id: "F1", raw_fragment_text: sourceMessage, normalized_fragment_text: candidateMessage, domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: [], account_hints: [], document_hints: [], register_hints: [], time_scope: { type: "implicit", value: null, confidence: "low" }, 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-diagnostic-rewrite-${Date.now()}`, user_message: sourceMessage, 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(sourceMessage); expect(calls[0].message).not.toBe(candidateMessage); expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_diagnostic_rewrite"); expect(String(response.debug?.llm_decomposition_effective_message ?? "")).toBe(sourceMessage); }); it("rejects follow-up intent injection when llm adds documents to same-date account prompt", 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-followup-injection", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "а на ту же дату по 62", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [ { fragment_id: "F1", raw_fragment_text: "а на ту же дату по 62", normalized_fragment_text: "документы или проводки по счету 62 на ту же дату", domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: [], account_hints: ["62"], document_hints: ["документы"], register_hints: [], time_scope: { type: "explicit", value: "same-date", confidence: "medium" }, 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 sourceMessage = "а на ту же дату по 62"; const response = await service.handleMessage({ session_id: `asst-predecompose-followup-injection-${Date.now()}`, user_message: sourceMessage, llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(calls).toHaveLength(1); expect(calls[0].message).toBe(sourceMessage); expect(response.debug?.llm_decomposition_applied).toBe(false); expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_followup_intent_injection"); expect(response.debug?.llm_decomposition_effective_message).toBe(sourceMessage); }); 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"); expect(response.debug?.llm_predecompose_contract?.period?.scope).toBe("year"); }); 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: "Покажи документы по договору 19/15 за 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: "Покажи документы по договору 19/15 за 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("документы по договору 19/15 за 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: "Покажи банковские операции по договору 19/15", 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: "Покажи банковские операции по договору 19/15", 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("банковские операции по договору 19/15"); 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("rejects account injection when LLM truncates a numeric counterparty suffix", 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-counterparty-suffix-account-injection", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "Покажи документы по Жуковке 51.", message_in_scope: true, scope_confidence: "high", contains_multiple_tasks: false, fragments: [ { fragment_id: "F1", raw_fragment_text: "Покажи документы по Жуковке 51.", normalized_fragment_text: "Показать документы, связанные с контрагентом Жуковка по счету 51", domain_relevance: "in_scope", business_scope: "company_specific_accounting", entity_hints: ["Жуковка"], account_hints: ["51"], document_hints: ["документы"], register_hints: [], semantic_hints: { scope_target_kind: "counterparty", scope_target_text: "Жуковка", date_scope_kind: "implicit_current", self_scope_detected: false, selected_object_scope_detected: false }, time_scope: { type: "unspecified", value: null, confidence: "low" }, 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: "high", 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-counterparty-suffix-${Date.now()}`, user_message: "Покажи документы по Жуковке 51.", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("Покажи документы по Жуковке 51."); expect(response.debug?.llm_decomposition_applied).toBe(false); expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_anchor_injection"); expect(response.debug?.llm_predecompose_contract?.entities?.account).toBeNull(); expect(response.debug?.llm_predecompose_contract?.entities?.counterparty).toBe("Жуковке 51"); }); 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: "покажи банк опер по дог 19/15 пж", 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: "покажи банк опер по дог 19/15 пж", 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("банковские операции по договору 19/15"); 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_attempted).toBe(true); expect(response.debug?.llm_decomposition_applied).toBe(false); expect(response.debug?.llm_decomposition_reason).toBe("no_usable_fragment"); expect(response.debug?.fallback_rule_hit).toBeNull(); expect(response.debug?.tool_gate_decision).toBe("run_address_lane"); }); it("uses llm canonical candidate as gate signal when regex path has no address markers", 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-llm-gate-signal", ok: true, normalized: { schema_version: "normalized_query_v2_0_2", user_message_raw: "svk gib list", message_in_scope: true, scope_confidence: "medium", contains_multiple_tasks: false, fragments: [ { fragment_id: "F1", raw_fragment_text: "svk gib list", normalized_fragment_text: "заказчики компании svk", domain_relevance: "in_scope", confidence: "medium", execution_readiness: "no_route", route_status: "no_route" } ] }, 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-llm-gate-signal-${Date.now()}`, user_message: "svk gib list", llmProvider: "local", useMock: false } as any); expect(response.ok).toBe(true); expect(response.reply_type).toBe("clarification_required"); expect(calls).toHaveLength(0); expect(response.debug?.address_tool_gate_decision).toBe("skip_address_lane"); expect( [ "llm_predecompose_semantic_guard_rejected", "llm_predecompose_unsupported_mode", "address_signal_unsupported_intent_fallback_to_deep", "non_domain_query_indexed" ] ).toContain(response.debug?.address_tool_gate_reason); }); 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"); }); });