АДРЕСНЫЙ РЕЖИМ - локальная подель на декомпозе
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user