АДРЕСНЫЙ РЕЖИМ - Batch-2 (Phase A/B): стабилизация LLM-маршрутизации жизненного цикла контрагентов и договоров + актуализация документации

This commit is contained in:
2026-04-08 12:42:02 +03:00
parent eec5ef4fc0
commit 1f9bb886ee
28 changed files with 2281 additions and 128 deletions
@@ -1383,11 +1383,36 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves counterparty lifecycle intent for one-time counterparties wording", () => {
const result = resolveAddressIntent("Какие контрагенты работали с нами только один раз за все время?");
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves counterparty lifecycle intent for longest-running counterparties wording", () => {
const result = resolveAddressIntent("Какие контрагенты работают с нами дольше всех?");
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves supplier lifecycle segmentation wording into lifecycle intent", () => {
const result = resolveAddressIntent("Раздели поставщиков на регулярных и эпизодических по активности.");
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves stale suppliers wording into lifecycle intent", () => {
const result = resolveAddressIntent("Какие поставщики давно не использовались?");
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("resolves contract usage overview intent", () => {
const result = resolveAddressIntent("Сколько всего договоров заведено и сколько из них реально использовались?");
expect(result.intent).toBe("contract_usage_overview");
});
it("resolves stale contracts wording into contract usage overview intent", () => {
const result = resolveAddressIntent("Какие договоры давно не использовались?");
expect(result.intent).toBe("contract_usage_overview");
});
it("resolves customer revenue/payout ranking intent", () => {
const result = resolveAddressIntent("какие клиенты самые доходные, выдай топ-20");
expect(result.intent).toBe("customer_revenue_and_payments");
@@ -1418,6 +1443,11 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("contract_usage_and_value");
});
it("resolves multi-contract counterparties wording into contract usage and value intent", () => {
const result = resolveAddressIntent("Покажи контрагентов с несколькими договорами и какие из договоров активны.");
expect(result.intent).toBe("contract_usage_and_value");
});
it("resolves contracts-by-counterparty intent from list wording", () => {
const result = resolveAddressIntent("покажи договора все по жуковке 51");
expect(result.intent).toBe("list_contracts_by_counterparty");
@@ -2017,6 +2047,36 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes one-time counterparties wording into lifecycle aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие контрагенты работали с нами только один раз за все время?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle");
expect(result?.debug.selected_recipe).toBe("address_counterparty_activity_lifecycle_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes stale contracts wording into contract usage overview recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие договоры давно не использовались?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("contract_usage_overview");
expect(result?.debug.selected_recipe).toBe("address_contract_usage_overview_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes multi-contract counterparties wording into contract usage and value recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Покажи контрагентов с несколькими договорами и какие из договоров активны.");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("contract_usage_and_value");
expect(result?.debug.selected_recipe).toBe("address_contract_usage_and_value_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
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");
@@ -2510,6 +2570,22 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.limit).toBe(1000);
});
it("uses bank-doc profile with contract projection for open-items anchor matching", () => {
const selected = selectAddressRecipe("open_items_by_counterparty_or_contract", {
counterparty: "СВК",
as_of_date: "2020-12-31"
});
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
counterparty: "СВК",
as_of_date: "2020-12-31"
});
expect(plan.query).toContain("Документ.СписаниеСРасчетногоСчета");
expect(plan.query).toContain("Документ.ПоступлениеНаРасчетныйСчет");
expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор");
});
it("allows extended limit for open-contracts intent", () => {
const selected = selectAddressRecipe("list_open_contracts", {
as_of_date: "2020-12-31",
@@ -348,6 +348,100 @@ describe("assistant address llm pre-decompose candidate preference", () => {
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 follow-up intent injection when llm adds documents to same-date account prompt", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {