NODEDC_1C/llm_normalizer/backend/tests/assistantAddressLlmPredecom...

606 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi } from "vitest";
import { AssistantService } from "../src/services/assistantService";
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
function buildAddressLaneResult(message: string): any {
return {
handled: true,
reply_text: `handled: ${message}`,
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: "svk",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
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: "svk",
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", "document_list_signal_detected"]
}
};
}
describe("assistant address llm pre-decompose candidate preference", () => {
it("prefers raw fragment when normalized fragment loses counterparty anchor", 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-1",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "бля svk doki za 20 god pokezh pls",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "svk doki za 20 god pokezh",
normalized_fragment_text: "Показать документы за 2020 год",
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-${Date.now()}`,
user_message: "бля svk doki za 20 god pokezh pls",
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("svk doki za 20 god pokezh");
expect(response.debug?.llm_decomposition_attempted).toBe(true);
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_decomposition_effective_message).toBe("svk doki za 20 god pokezh");
expect(response.debug?.fallback_rule_hit).toBeNull();
expect(response.debug?.sanitized_user_message).toBeTypeOf("string");
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
});
it("applies deterministic fallback rule when llm fragment 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-2",
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-fallback-${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_attempted).toBe(true);
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_counterparty_year_rewrite");
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");
});
});