import { describe, expect, it } from "vitest"; import { detectAddressQuestionMode } from "../src/services/addressQueryClassifier"; import { resolveAddressIntent } from "../src/services/addressIntentResolver"; import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier"; import { extractAddressFilters } from "../src/services/addressFilterExtractor"; import { AddressQueryService } from "../src/services/addressQueryService"; import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog"; describe("address query shape classifier", () => { it("classifies explain question as deep-shape", () => { const result = classifyAddressQueryShape("Why VAT chain does not match?"); expect(result.shape).toBe("EXPLAIN_OR_REASON"); expect(result.confidence).toBe("high"); }); it("classifies aggregate lookup question", () => { const result = classifyAddressQueryShape("who owes us today?"); expect(result.shape).toBe("AGGREGATE_LOOKUP"); }); it("classifies compound factual question", () => { const result = classifyAddressQueryShape("who owes us and who we owe today?"); expect(result.shape).toBe("COMPOUND_FACTUAL_QUERY"); }); it("keeps company lookup phrasing in address lane", () => { const result = detectAddressQuestionMode("какие компании есть в базе"); expect(result.mode).toBe("address_query"); }); it("keeps loose by-anchor follow-up phrasing in address lane", () => { const result = detectAddressQuestionMode("за любой период есть что-то по свк?"); expect(result.mode).toBe("address_query"); }); }); describe("address intent resolver expansion (M2.3a)", () => { it("resolves documents by counterparty intent", () => { const result = resolveAddressIntent("show documents by counterparty Alfa from 2020-07-01 to 2020-07-31"); expect(result.intent).toBe("list_documents_by_counterparty"); }); it("resolves bank operations by counterparty intent", () => { const result = resolveAddressIntent("show bank operations by counterparty Alfa"); expect(result.intent).toBe("bank_operations_by_counterparty"); }); it("resolves documents forming balance intent", () => { const result = resolveAddressIntent("which documents form balance for account 62 as of 2020-07-31"); expect(result.intent).toBe("documents_forming_balance"); }); it("resolves documents forming balance for russian participle phrasing", () => { const result = resolveAddressIntent("Показать документы, формирующие остаток по счету 60.01 на дату 2020-07-31"); expect(result.intent).toBe("documents_forming_balance"); }); it("resolves documents by company phrase as counterparty intent", () => { const result = resolveAddressIntent("Какие документы доступны по компании СВК за 2021 год?"); expect(result.intent).toBe("list_documents_by_counterparty"); }); it("resolves bank operations by supplier phrase", () => { const result = resolveAddressIntent("Покажи платежи по поставщику Альфа за июль 2020"); expect(result.intent).toBe("bank_operations_by_counterparty"); }); it("keeps bank_operations_by_counterparty even when account hints are present", () => { const result = resolveAddressIntent("Показать банковские операции (счета 51, 62) для контрагента СВК за 2020 год"); expect(result.intent).toBe("bank_operations_by_counterparty"); }); it("resolves documents by client phrase", () => { const result = resolveAddressIntent("Выведи документы по клиенту Бета за 2020-07"); expect(result.intent).toBe("list_documents_by_counterparty"); }); it("resolves short slang docs phrase with loose by-anchor", () => { const result = resolveAddressIntent("какие доки есть по свк за 2021"); expect(result.intent).toBe("list_documents_by_counterparty"); }); it("resolves typo slang docs phrase with implicit anchor", () => { const result = resolveAddressIntent("свк доки за 20год покеж"); expect(result.intent).toBe("list_documents_by_counterparty"); }); it("resolves noisy docs phrase with slang tail", () => { const result = resolveAddressIntent("свк 20 год - покажи доки плс"); expect(result.intent).toBe("list_documents_by_counterparty"); }); it("resolves loose by-anchor follow-up as documents by counterparty fallback", () => { const result = resolveAddressIntent("за любой период есть что-то по свк?"); expect(result.intent).toBe("list_documents_by_counterparty"); }); }); describe("address filter extraction for balance drilldown", () => { it("defaults as_of_date for documents_forming_balance when date is omitted", () => { const result = extractAddressFilters("which documents form balance for account 62", "documents_forming_balance"); expect(result.extracted_filters.account).toBe("62"); expect(result.extracted_filters.as_of_date).toBeDefined(); expect(result.missing_required_filters).toEqual([]); }); it("cuts period tail from counterparty anchor", () => { const result = extractAddressFilters( "Покажи документы по контрагенту test_cp с 2020-07-01 по 2020-07-31", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("test_cp"); expect(result.extracted_filters.period_from).toBe("2020-07-01"); expect(result.extracted_filters.period_to).toBe("2020-07-31"); }); it("cuts all-time tail from counterparty anchor and skips 90-day default window", () => { const result = extractAddressFilters( "Покажи документы по контрагенту тестовый за все время", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("тестовый"); expect(result.extracted_filters.period_from).toBeUndefined(); expect(result.extracted_filters.period_to).toBeUndefined(); expect(result.warnings).not.toContain("period_defaulted_last_90_days"); }); it("extracts counterparty from company phrase and derives year period", () => { const result = extractAddressFilters( "Какие документы доступны по компании СВК за 2021 год?", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("СВК"); expect(result.extracted_filters.period_from).toBe("2021-01-01"); expect(result.extracted_filters.period_to).toBe("2021-12-31"); expect(result.warnings).toContain("period_derived_from_year_phrase"); }); it("extracts counterparty from supplier phrase and derives month period", () => { const result = extractAddressFilters( "Покажи документы по поставщику Альфа за июль 2020", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("Альфа"); expect(result.extracted_filters.period_from).toBe("2020-07-01"); expect(result.extracted_filters.period_to).toBe("2020-07-31"); expect(result.warnings).toContain("period_derived_from_month_phrase"); }); it("derives month period for balance snapshot from 'на май 2020'", () => { const result = extractAddressFilters("Какой остаток по счету 60 на май 2020", "account_balance_snapshot"); expect(result.extracted_filters.account).toBe("60"); expect(result.extracted_filters.period_from).toBe("2020-05-01"); expect(result.extracted_filters.period_to).toBe("2020-05-31"); expect(result.extracted_filters.as_of_date).toBe("2020-05-31"); expect(result.warnings).toContain("period_derived_from_month_phrase"); expect(result.warnings).toContain("as_of_date_derived_from_period_to"); }); it("derives month period for balance snapshot from 'на 2020.05'", () => { const result = extractAddressFilters("Какой остаток по счету 60 на 2020.05", "account_balance_snapshot"); expect(result.extracted_filters.account).toBe("60"); expect(result.extracted_filters.period_from).toBe("2020-05-01"); expect(result.extracted_filters.period_to).toBe("2020-05-31"); expect(result.extracted_filters.as_of_date).toBe("2020-05-31"); expect(result.warnings).toContain("period_derived_from_month_phrase"); expect(result.warnings).toContain("as_of_date_derived_from_period_to"); }); it("derives month period for balance snapshot from 'на 2020 май'", () => { const result = extractAddressFilters("Какой остаток по счету 60 на 2020 май", "account_balance_snapshot"); expect(result.extracted_filters.account).toBe("60"); expect(result.extracted_filters.period_from).toBe("2020-05-01"); expect(result.extracted_filters.period_to).toBe("2020-05-31"); expect(result.extracted_filters.as_of_date).toBe("2020-05-31"); expect(result.warnings).toContain("period_derived_from_month_phrase"); expect(result.warnings).toContain("as_of_date_derived_from_period_to"); }); it("treats 'за весь период' as all-time hint and does not force 90-day default", () => { const result = extractAddressFilters( "Покажи банковские операции по клиенту Бета за весь период", "bank_operations_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("Бета"); expect(result.extracted_filters.period_from).toBeUndefined(); expect(result.extracted_filters.period_to).toBeUndefined(); expect(result.warnings).not.toContain("period_defaulted_last_90_days"); }); it("extracts loose by-anchor and year period for short slang docs phrase", () => { const result = extractAddressFilters( "какие доки есть по свк за 2021", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("свк"); expect(result.extracted_filters.period_from).toBe("2021-01-01"); expect(result.extracted_filters.period_to).toBe("2021-12-31"); expect(result.warnings).toContain("counterparty_anchor_derived_from_loose_by_phrase"); expect(result.warnings).toContain("period_derived_from_year_phrase"); }); it("extracts implicit counterparty and short-year period for typo slang docs phrase", () => { const result = extractAddressFilters( "свк доки за 20год покеж", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("свк"); expect(result.extracted_filters.period_from).toBe("2020-01-01"); expect(result.extracted_filters.period_to).toBe("2020-12-31"); expect(result.warnings).toContain("counterparty_anchor_derived_from_implicit_phrase"); expect(result.warnings).toContain("period_derived_from_year_phrase"); }); it("extracts free-text counterparty and relaxed short-year period from noisy phrase", () => { const result = extractAddressFilters( "свк 20 год - покажи доки плс", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("свк"); expect(result.extracted_filters.period_from).toBe("2020-01-01"); expect(result.extracted_filters.period_to).toBe("2020-12-31"); expect(result.warnings).toContain("counterparty_anchor_derived_from_free_text_heuristic"); expect(result.warnings).toContain("period_derived_from_year_phrase"); expect(result.extracted_filters.counterparty).not.toBe("плс"); }); it("extracts explicit year range period from phrase", () => { const result = extractAddressFilters( "Какие документы по СВК за 2000 - 2025 год?", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("СВК"); expect(result.extracted_filters.period_from).toBe("2000-01-01"); expect(result.extracted_filters.period_to).toBe("2025-12-31"); expect(result.warnings).toContain("period_derived_from_year_range_phrase"); }); it("extracts multiline year range period from phrase", () => { const result = extractAddressFilters( "Какие документы по СВК за 2000 - 2025\n год?", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("СВК"); expect(result.extracted_filters.period_from).toBe("2000-01-01"); expect(result.extracted_filters.period_to).toBe("2025-12-31"); expect(result.warnings).toContain("period_derived_from_year_range_phrase"); expect(result.warnings).not.toContain("period_derived_from_year_phrase"); }); it("extracts russian year range period from 'с ... по ...' phrase", () => { const result = extractAddressFilters( "какие есть доки по свк с 2020 по 2025 год", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("свк"); expect(result.extracted_filters.period_from).toBe("2020-01-01"); expect(result.extracted_filters.period_to).toBe("2025-12-31"); expect(result.warnings).toContain("period_derived_from_year_range_phrase"); expect(result.warnings).not.toContain("period_defaulted_last_90_days"); }); it("treats 'за любой период' as all-time hint and keeps loose by-anchor", () => { const result = extractAddressFilters( "за любой период есть что-то по свк?", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("свк"); expect(result.extracted_filters.period_from).toBeUndefined(); expect(result.extracted_filters.period_to).toBeUndefined(); expect(result.warnings).toContain("counterparty_anchor_derived_from_loose_by_phrase"); expect(result.warnings).not.toContain("period_defaulted_last_90_days"); }); }); describe("address query limited taxonomy and stage diagnostics", () => { it("returns missing_anchor for open items without concrete counterparty/contract anchor", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("show open items by contract"); expect(result?.handled).toBe(true); expect(result?.response_type).toBe("LIMITED_WITH_REASON"); expect(result?.debug.limited_reason_category).toBe("missing_anchor"); expect(result?.debug.mcp_call_status).toBe("skipped"); }); it("returns unsupported for not-implemented contract document list intent", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("show documents by contract 15/24"); expect(result?.handled).toBe(true); expect(result?.response_type).toBe("LIMITED_WITH_REASON"); expect(result?.debug.limited_reason_category).toBe("unsupported"); expect(result?.debug.mcp_call_status).toBe("skipped"); }); it("includes resolver and row-stage diagnostics", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("which documents form balance for account 62 as of 2020-07-31"); expect(result?.handled).toBe(true); expect(["LIMITED_WITH_REASON", "FACTUAL_LIST"]).toContain(result?.response_type); expect(result?.debug.anchor_type).toBe("account"); expect(result?.debug.rows_fetched).toBeTypeOf("number"); expect(result?.debug.raw_rows_received).toBeTypeOf("number"); expect(result?.debug.rows_after_account_scope).toBeTypeOf("number"); expect(result?.debug.rows_materialized).toBeTypeOf("number"); expect(result?.debug.rows_after_recipe_filter).toBeTypeOf("number"); expect(result?.debug.rows_matched).toBeTypeOf("number"); expect(["strict", "preferred"]).toContain(result?.debug.account_scope_mode); expect(result?.debug.account_scope_fallback_applied).toBeTypeOf("boolean"); expect(result?.debug.mcp_call_status_legacy).toBeDefined(); expect(result?.debug.match_failure_stage).toBeDefined(); expect([ "error", "no_raw_rows", "raw_rows_received_but_not_materialized", "materialized_but_not_anchor_matched", "materialized_but_filtered_out_by_recipe", "materialized_but_not_matched", "matched_non_empty" ]).toContain(result?.debug.mcp_call_status); expect(result?.debug.raw_row_keys_sample).toBeDefined(); expect(result?.debug.materialization_drop_reason).toBeDefined(); expect(result?.debug.account_scope_fields_checked).toBeDefined(); expect(result?.debug.account_scope_match_strategy).toBe("account_code_regex_plus_alias_map_v1"); expect(result?.debug.account_scope_drop_reason).toBeDefined(); }); it("keeps short slang docs request in address lane (no deep fallback)", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("какие доки есть по свк за 2021"); expect(result?.handled).toBe(true); expect(result?.debug.detected_mode).toBe("address_query"); expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty"); expect(result?.debug.extracted_filters.counterparty).toBe("свк"); expect(result?.debug.extracted_filters.period_from).toBe("2021-01-01"); expect(result?.debug.extracted_filters.period_to).toBe("2021-12-31"); }); it("keeps typo slang docs request in address lane and extracts implicit anchor", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("свк доки за 20год покеж"); expect(result?.handled).toBe(true); expect(result?.debug.detected_mode).toBe("address_query"); expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty"); expect(result?.debug.extracted_filters.counterparty).toBe("свк"); expect(result?.debug.extracted_filters.period_from).toBe("2020-01-01"); expect(result?.debug.extracted_filters.period_to).toBe("2020-12-31"); }); it("keeps noisy docs request in address lane and ignores slang tail token", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("свк 20 год - покажи доки плс"); expect(result?.handled).toBe(true); expect(result?.debug.detected_mode).toBe("address_query"); expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty"); expect(result?.debug.extracted_filters.counterparty).toBe("свк"); expect(result?.debug.extracted_filters.counterparty).not.toBe("плс"); expect(result?.debug.extracted_filters.period_from).toBe("2020-01-01"); expect(result?.debug.extracted_filters.period_to).toBe("2020-12-31"); }); it("auto-broadens out-of-window period and returns available factual rows", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("Какие документы по СВК за 2000 год?"); expect(result?.handled).toBe(true); expect(["FACTUAL_LIST", "LIMITED_WITH_REASON"]).toContain(result?.response_type); if (result?.response_type === "FACTUAL_LIST") { expect(result?.debug.limited_reason_category).toBeNull(); } }); }); describe("address recipe catalog counterparty filtering", () => { it("boosts limit for all-time counterparty queries", () => { const filters = extractAddressFilters( "Покажи документы по контрагенту тестовый за все время", "list_documents_by_counterparty" ).extracted_filters; const selected = selectAddressRecipe("list_documents_by_counterparty", filters); expect(selected.selected_recipe).toBeTruthy(); const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); expect(plan.limit).toBe(1000); }); it("boosts limit for english all-time counterparty queries", () => { const filters = extractAddressFilters( "show documents by counterparty test_cp for all time", "list_documents_by_counterparty" ).extracted_filters; const selected = selectAddressRecipe("list_documents_by_counterparty", filters); expect(selected.selected_recipe).toBeTruthy(); const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); expect(plan.limit).toBe(1000); }); it("cuts english all-time tail from counterparty anchor", () => { const result = extractAddressFilters( "show documents by counterparty test_cp for all time", "list_documents_by_counterparty" ); expect(result.extracted_filters.counterparty).toBe("test_cp"); expect(result.extracted_filters.period_from).toBeUndefined(); expect(result.extracted_filters.period_to).toBeUndefined(); expect(result.warnings).not.toContain("period_defaulted_last_90_days"); }); it("boosts limit for account snapshot queries with explicit account", () => { const filters = extractAddressFilters( "Какой остаток по счету 60 на дату 2020-07-31", "account_balance_snapshot" ).extracted_filters; const selected = selectAddressRecipe("account_balance_snapshot", filters); expect(selected.selected_recipe).toBeTruthy(); const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); expect(plan.limit).toBe(200); }); it("injects account condition into movements query for account snapshot", () => { const filters = extractAddressFilters( "Какой остаток по счету 60 на дату 2020-07-31", "account_balance_snapshot" ).extracted_filters; const selected = selectAddressRecipe("account_balance_snapshot", filters); expect(selected.selected_recipe).toBeTruthy(); const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); expect(plan.query).toContain("Движения.СчетДт.Код"); expect(plan.query).toContain("ПОДОБНО \"60%\""); }); it("injects subaccount condition variants into movements query for documents_forming_balance", () => { const filters = extractAddressFilters( "Какие документы формируют остаток по счету 60.01 на дату 2020-07-31", "documents_forming_balance" ).extracted_filters; const selected = selectAddressRecipe("documents_forming_balance", filters); expect(selected.selected_recipe).toBeTruthy(); const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); expect(plan.query).toContain("ПОДОБНО \"60.01%\""); expect(plan.query).toContain("ПОДОБНО \"60.1%\""); }); });