АДРЕСНЫЙ РЕЖИМ - локальная подель на декомпозе

This commit is contained in:
2026-04-01 17:55:02 +03:00
parent 4060a5e575
commit 4d59672576
90 changed files with 19595 additions and 785 deletions
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { executeAddressMcpQuery } from "../src/services/addressMcpClient";
const ORIGINAL_FETCH = globalThis.fetch;
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
vi.restoreAllMocks();
});
describe("address MCP encoding repair", () => {
it("repairs UTF-8/CP1251 mojibake in object rows", async () => {
const payload = {
success: true,
data: [
{
"Период": "2020-07-30T12:00:00Z",
"Регистратор": "Поступление на расчетный счет 0001",
"СчетДт": "51",
"СчетКт": "62.01",
"РЎСѓРјРјР°": "20000",
"Контрагент": "Группа СВК"
}
]
};
globalThis.fetch = vi.fn(async () =>
new Response(JSON.stringify(payload), {
status: 200,
headers: {
"content-type": "application/json"
}
})
) as typeof fetch;
const result = await executeAddressMcpQuery({
query: "SELECT 1",
limit: 20,
account_scope: []
});
expect(result.error).toBeNull();
expect(result.fetched_rows).toBe(1);
expect(result.rows[0]?.["Контрагент"]).toBe("Группа СВК");
expect(result.rows[0]?.["Регистратор"]).toContain("Поступление");
});
});
@@ -1,4 +1,5 @@
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";
@@ -21,6 +22,16 @@ describe("address query shape classifier", () => {
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)", () => {
@@ -38,6 +49,41 @@ describe("address intent resolver expansion (M2.3a)", () => {
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 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("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", () => {
@@ -68,6 +114,123 @@ describe("address filter extraction for balance drilldown", () => {
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("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", () => {
@@ -93,7 +256,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
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(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(["LIMITED_WITH_REASON", "FACTUAL_LIST"]).toContain(result?.response_type);
expect(result?.debug.anchor_type).toBe("account");
expect(result?.debug.rows_fetched).toBeTypeOf("number");
@@ -108,6 +271,7 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.match_failure_stage).toBeDefined();
expect([
"error",
"no_raw_rows",
"raw_rows_received_but_not_materialized",
"materialized_but_not_anchor_matched",
@@ -122,6 +286,50 @@ describe("address query limited taxonomy and stage diagnostics", () => {
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", () => {
@@ -134,7 +342,7 @@ describe("address recipe catalog counterparty filtering", () => {
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
expect(plan.limit).toBe(1000);
});
it("boosts limit for english all-time counterparty queries", () => {
@@ -146,7 +354,7 @@ describe("address recipe catalog counterparty filtering", () => {
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.limit).toBe(200);
expect(plan.limit).toBe(1000);
});
it("cuts english all-time tail from counterparty anchor", () => {
@@ -159,4 +367,16 @@ describe("address recipe catalog counterparty filtering", () => {
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);
});
});
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from "vitest";
import { AssistantService } from "../src/services/assistantService";
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
return {
handled: true,
reply_text: "Собран список документов по контрагенту.",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_mode_confidence: "high",
query_shape: "DOCUMENT_LIST",
query_shape_confidence: "medium",
detected_intent: "list_documents_by_counterparty",
detected_intent_confidence: "medium",
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "свк"
},
missing_required_filters: [],
selected_recipe: "address_documents_by_counterparty_v1",
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: "preferred",
account_scope_fallback_applied: false,
anchor_type: "counterparty",
anchor_value_raw: "свк",
anchor_value_resolved: "Группа СВК",
resolver_confidence: "medium",
ambiguity_count: 0,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: "matched_non_empty",
rows_fetched: 20,
raw_rows_received: 20,
rows_after_account_scope: 5,
rows_after_recipe_filter: 3,
rows_materialized: 5,
rows_matched: 3,
raw_row_keys_sample: [],
materialization_drop_reason: "none",
account_token_raw: null,
account_token_normalized: null,
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
account_scope_drop_reason: "not_applicable",
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: "FACTUAL_LIST",
limitations: [],
reasons: ["address_action_detected", "address_entity_detected"]
},
...(overrides ?? {})
};
}
describe("assistant address follow-up carryover", () => {
it("keeps short follow-up in address lane by reusing previous anchor context", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === "какие есть доки по свк с 2020 по 2025 год") {
return buildAddressLaneResult();
}
if (message === "а за все время?" && !options?.followupContext) {
return null;
}
if (message === "а за все время?" && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "Собран список документов по контрагенту за все время.",
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: "какие есть доки по свк с 2020 по 2025 год",
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: "а за все время?",
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_mode).toBe("address_query");
expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied");
expect(calls).toHaveLength(3);
expect(calls[0].message).toBe("какие есть доки по свк с 2020 по 2025 год");
expect(calls[1].message).toBe("а за все время?");
expect(calls[1].options?.followupContext).toBeUndefined();
expect(calls[2].message).toBe("а за все время?");
expect(calls[2].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[2].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(calls[2].options?.followupContext?.previous_anchor_value).toBe("Группа СВК");
expect(calls[2].options?.followupContext?.previous_filters?.counterparty).toBe("свк");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
});