АДРЕСНЫЙ РЕЖИМ - стабилизация LLM fallback якорей и периода

This commit is contained in:
2026-04-02 00:30:38 +03:00
parent 2d95ce6332
commit 7dd6607ded
19 changed files with 2727 additions and 49 deletions
@@ -6,6 +6,7 @@ import { extractAddressFilters } from "../src/services/addressFilterExtractor";
import { AddressQueryService } from "../src/services/addressQueryService";
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
describe("address query shape classifier", () => {
it("classifies explain question as deep-shape", () => {
@@ -35,6 +36,36 @@ describe("address query shape classifier", () => {
});
});
describe("address compose stage utf8 headers", () => {
it("renders readable russian header for contract document list", () => {
const reply = composeFactualReply("list_documents_by_contract", [
{
period: "2020-10-15T13:34:53Z",
registrator: "Списание с расчетного счета 00000000246",
account_dt: "66.02",
account_kt: "51",
amount: 30819.47,
analytics: []
}
]);
expect(reply.text).toContain("Собран список документов по договору (live address lane).");
});
it("renders readable russian header for contract bank operations", () => {
const reply = composeFactualReply("bank_operations_by_contract", [
{
period: "2020-10-15T13:34:53Z",
registrator: "Списание с расчетного счета 00000000246",
account_dt: "66.02",
account_kt: "51",
amount: 30819.47,
analytics: []
}
]);
expect(reply.text).toContain("Собран список банковских операций по договору (live address lane).");
});
});
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");
@@ -66,6 +97,23 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("bank_operations_by_counterparty");
});
it("resolves documents by contract intent", () => {
const result = resolveAddressIntent("Покажи документы по договору 15/24 за 2020");
expect(result.intent).toBe("list_documents_by_contract");
});
it("resolves bank operations by contract intent", () => {
const result = resolveAddressIntent("Покажи банковские операции по договору 15/24");
expect(result.intent).toBe("bank_operations_by_contract");
});
it("resolves bank operations by contract for normalized phrase with linked contract wording", () => {
const result = resolveAddressIntent(
"Показать банковские операции (счета 51, 60, 62) связанные с договором 15/24."
);
expect(result.intent).toBe("bank_operations_by_contract");
});
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");
@@ -287,6 +335,49 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.warnings).toContain("period_derived_from_year_range_phrase");
});
it("extracts contract and year period for contract document list", () => {
const result = extractAddressFilters(
"Покажи документы по договору 15/24 за 2020 год",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
expect(result.warnings).toContain("period_derived_from_year_phrase");
});
it("cuts trailing as-of date from contract anchor", () => {
const result = extractAddressFilters(
"Покажи документы по договору 1-ПМ/2020 на дату 31.07.2020",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("1-ПМ/2020");
expect(result.extracted_filters.period_from).toBe("2020-07-01");
expect(result.extracted_filters.period_to).toBe("2020-07-31");
});
it("does not force 90-day default window for by-contract query without explicit period", () => {
const result = extractAddressFilters(
"Покажи документы по договору 15/24",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
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 heuristic contract token for noisy contract phrase", () => {
const result = extractAddressFilters(
"доки 15/24 за 2020",
"list_documents_by_contract"
);
expect(result.extracted_filters.contract).toBe("15/24");
expect(result.warnings).toContain("contract_anchor_derived_from_heuristic_token");
expect(result.extracted_filters.period_from).toBe("2020-01-01");
expect(result.extracted_filters.period_to).toBe("2020-12-31");
});
it("extracts multiline year range period from phrase", () => {
const result = extractAddressFilters(
"Какие документы по СВК за 2000 - 2025\n год?",
@@ -334,13 +425,24 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.mcp_call_status).toBe("skipped");
});
it("returns unsupported for not-implemented contract document list intent", async () => {
it("routes contract document list intent into address recipe", 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");
expect(result?.debug.detected_intent).toBe("list_documents_by_contract");
expect(result?.debug.selected_recipe).toBe("address_documents_by_contract_v1");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
});
it("routes bank operations by contract intent into address recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Покажи банковские операции по договору 15/24");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("bank_operations_by_contract");
expect(result?.debug.selected_recipe).toBe("address_bank_operations_by_contract_v1");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
});
it("includes resolver and row-stage diagnostics", async () => {
@@ -461,6 +563,24 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("as_of_date_from_followup_context");
expect(result?.baseReasons).toContain("address_followup_context_applied");
});
it("does not downgrade inherited follow-up anchor to missing_anchor when period has no rows", async () => {
const service = new AddressQueryService();
const seed = await service.tryHandle("покажи документы по свк за 2020");
expect(seed?.handled).toBe(true);
const followup = await service.tryHandle("а теперь только за май 2020", {
followupContext: {
previous_intent: (seed?.debug.detected_intent as any) ?? "list_documents_by_counterparty",
previous_filters: seed?.debug.extracted_filters,
previous_anchor_type: (seed?.debug.anchor_type as any) ?? "counterparty",
previous_anchor_value: seed?.debug.anchor_value_resolved ?? seed?.debug.anchor_value_raw ?? null
}
});
expect(followup?.handled).toBe(true);
if (followup?.reply_type === "partial_coverage") {
expect(followup?.debug.limited_reason_category).not.toBe("missing_anchor");
}
});
});
describe("address recipe catalog counterparty filtering", () => {
@@ -215,4 +215,391 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.sanitized_user_message).toContain("свк");
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
});
it("keeps contract anchor in deterministic fallback when llm output is unusable", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-contract-docs",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Покажи документы по договору 15/24 за 2020",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-contract-docs-${Date.now()}`,
user_message: "Покажи документы по договору 15/24 за 2020",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("документы по договору 15/24 за 2020 год");
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("documents_contract_year_rewrite");
});
it("keeps bank-by-contract intent in deterministic fallback when llm output is unusable", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-contract-bank",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Покажи банковские операции по договору 15/24",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-contract-bank-${Date.now()}`,
user_message: "Покажи банковские операции по договору 15/24",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("банковские операции по договору 15/24");
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("bank_operations_contract_rewrite");
});
it("keeps month scope for balance fallback in 'year month' phrasing", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-balance-year-month",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Какой остаток по счету 60 на 2020 май",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-balance-year-month-${Date.now()}`,
user_message: "Какой остаток по счету 60 на 2020 май",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("остаток по счету 60 на 2020-05");
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("balance_month_period_rewrite");
});
it("does not pick service words as counterparty anchor in noisy docs query", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-noisy-counterparty",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "что по свк за 2020 год выведи все доки плиз что есть",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-noisy-counterparty-${Date.now()}`,
user_message: "что по свк за 2020 год выведи все доки плиз что есть",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("документы по контрагенту свк за 2020 год");
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite");
});
it("rewrites payment-style counterparty phrasing to bank operations", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-bank-counterparty",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "какие платежи были по свк в 2020",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-bank-counterparty-${Date.now()}`,
user_message: "какие платежи были по свк в 2020",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("банковские операции по контрагенту свк за 2020 год");
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("bank_operations_counterparty_year_rewrite");
});
it("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");
});
});