1893 lines
72 KiB
TypeScript
1893 lines
72 KiB
TypeScript
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");
|
||
expect(response.debug?.llm_predecompose_contract?.schema_version).toBe("address_llm_predecompose_contract_v1");
|
||
expect(["unknown", "list_documents_by_counterparty"]).toContain(response.debug?.llm_predecompose_contract?.intent);
|
||
});
|
||
|
||
it("keeps counterparty anchor for docy typo when llm fragment drops anchor", async () => {
|
||
const calls: Array<{ message: string }> = [];
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string) => {
|
||
calls.push({ message });
|
||
if (message === "получить остатки по складу для организации 'альтернатива'") {
|
||
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: "UNKNOWN",
|
||
query_shape_confidence: "low",
|
||
detected_intent: "inventory_on_hand_as_of_date",
|
||
detected_intent_confidence: "high",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
organization: "альтернатива",
|
||
counterparty: "альтернатива",
|
||
as_of_date: "2026-04-15"
|
||
},
|
||
missing_required_filters: [],
|
||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||
mcp_call_status_legacy: "matched_non_empty",
|
||
account_scope_mode: "strict",
|
||
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: 1,
|
||
raw_rows_received: 1,
|
||
rows_after_account_scope: 1,
|
||
rows_after_recipe_filter: 1,
|
||
rows_materialized: 1,
|
||
rows_matched: 1,
|
||
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: ["inventory_on_hand_signal_detected"]
|
||
}
|
||
};
|
||
}
|
||
return buildAddressLaneResult(message);
|
||
})
|
||
} as any;
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
trace_id: "norm-predecompose-docy",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: "svk poka docy za 2020",
|
||
message_in_scope: true,
|
||
scope_confidence: "medium",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "svk poka docy za 2020",
|
||
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-docy-${Date.now()}`,
|
||
user_message: "svk poka docy za 2020",
|
||
llmProvider: "local",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].message.toLowerCase()).toContain("svk");
|
||
expect(calls[0].message).not.toBe("Покажи документы за 2020 год");
|
||
expect(String(response.debug?.llm_decomposition_effective_message ?? "").toLowerCase()).toContain("svk");
|
||
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
||
});
|
||
|
||
it("keeps colloquial warehouse snapshot questions in exact inventory contour when llm rewrites them abstractly", 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-inventory-colloquial",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: "что у нас на складе на март 2017",
|
||
message_in_scope: true,
|
||
scope_confidence: "medium",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "что у нас на складе на март 2017",
|
||
normalized_fragment_text: "информация о наличии товаров на складе за март 2017 года",
|
||
domain_relevance: "in_scope",
|
||
business_scope: "company_specific_accounting",
|
||
entity_hints: [],
|
||
account_hints: [],
|
||
document_hints: [],
|
||
register_hints: [],
|
||
time_scope: {
|
||
type: "explicit",
|
||
value: "2017-03",
|
||
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-inventory-colloquial-${Date.now()}`,
|
||
user_message: "что у нас на складе на март 2017",
|
||
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.toLowerCase()).toContain("складе");
|
||
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
|
||
expect(response.debug?.llm_predecompose_contract?.intent).toBe("inventory_on_hand_as_of_date");
|
||
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
||
});
|
||
|
||
it("rejects selected-object rewrite that drops object context and injects generic documents intent", 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-selected-object-context-loss",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
|
||
message_in_scope: true,
|
||
scope_confidence: "medium",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
|
||
normalized_fragment_text: "Покупка рабочей станции универсального специалиста (индивидуальное изготовление). Кто поставщик и какие документы подтверждают?",
|
||
domain_relevance: "in_scope",
|
||
business_scope: "company_specific_accounting",
|
||
entity_hints: [],
|
||
account_hints: [],
|
||
document_hints: [],
|
||
register_hints: [],
|
||
time_scope: {
|
||
type: "missing",
|
||
value: null,
|
||
confidence: "low"
|
||
},
|
||
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: true,
|
||
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-selected-object-${Date.now()}`,
|
||
user_message: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
|
||
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(
|
||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это'
|
||
);
|
||
expect([
|
||
"normalized_fragment_rejected_intent_conflict",
|
||
"normalized_fragment_rejected_selected_object_context_loss"
|
||
]).toContain(response.debug?.llm_decomposition_reason);
|
||
});
|
||
|
||
it("accepts exact selected-object sale rewrite when llm candidate stays on the same item", 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 (payload: any) => {
|
||
if (payload?.userQuestion === "какие остатки по складу у альтернативы") {
|
||
return {
|
||
trace_id: "norm-predecompose-root-stock",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: "какие остатки по складу у альтернативы",
|
||
message_in_scope: true,
|
||
scope_confidence: "medium",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "какие остатки по складу у альтернативы",
|
||
normalized_fragment_text: "получить остатки по складу для организации 'альтернатива'",
|
||
domain_relevance: "in_scope",
|
||
business_scope: "company_specific_accounting",
|
||
entity_hints: [],
|
||
account_hints: [],
|
||
document_hints: [],
|
||
register_hints: [],
|
||
time_scope: {
|
||
type: "missing",
|
||
value: null,
|
||
confidence: "low"
|
||
},
|
||
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
|
||
};
|
||
}
|
||
return {
|
||
trace_id: "norm-predecompose-selected-object-sale-drift",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw:
|
||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге',
|
||
message_in_scope: true,
|
||
scope_confidence: "medium",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text:
|
||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге',
|
||
normalized_fragment_text:
|
||
"Определить контрагента, которому была реализована позиция «Рабочая станция универсального специалиста (индивидуальное изготовление)» по выбранному объекту",
|
||
domain_relevance: "in_scope",
|
||
business_scope: "company_specific_accounting",
|
||
entity_hints: [],
|
||
account_hints: [],
|
||
document_hints: [],
|
||
register_hints: [],
|
||
time_scope: {
|
||
type: "missing",
|
||
value: null,
|
||
confidence: "low"
|
||
},
|
||
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: true,
|
||
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 sessionId = `asst-predecompose-selected-object-sale-${Date.now()}`;
|
||
await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "какие остатки по складу у альтернативы",
|
||
llmProvider: "local",
|
||
useMock: false
|
||
} as any);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message:
|
||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге',
|
||
llmProvider: "local",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual");
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls[1].message).toBe(
|
||
"Определить контрагента, которому была реализована позиция «Рабочая станция универсального специалиста (индивидуальное изготовление)» по выбранному объекту"
|
||
);
|
||
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_applied");
|
||
});
|
||
|
||
it("keeps a canonical selected-object sale rewrite executable even when llm semantic hints collapse the item noun", async () => {
|
||
const calls: Array<{ message: string }> = [];
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string) => {
|
||
calls.push({ message });
|
||
return buildAddressLaneResult(message);
|
||
})
|
||
} as any;
|
||
|
||
const sourceMessage = 'По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": кому продали';
|
||
const candidateMessage =
|
||
"Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту";
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
trace_id: "norm-predecompose-item-anchor-degradation",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: sourceMessage,
|
||
message_in_scope: true,
|
||
scope_confidence: "medium",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: sourceMessage,
|
||
normalized_fragment_text: candidateMessage,
|
||
semantic_hints: {
|
||
scope_target_kind: "item",
|
||
scope_target_text: "Кромка",
|
||
date_scope_kind: "missing",
|
||
self_scope_detected: false,
|
||
selected_object_scope_detected: true
|
||
},
|
||
domain_relevance: "in_scope",
|
||
business_scope: "company_specific_accounting",
|
||
entity_hints: [],
|
||
account_hints: [],
|
||
document_hints: [],
|
||
register_hints: [],
|
||
time_scope: {
|
||
type: "missing",
|
||
value: null,
|
||
confidence: "low"
|
||
},
|
||
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: true,
|
||
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-item-anchor-degradation-${Date.now()}`,
|
||
user_message: sourceMessage,
|
||
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(candidateMessage);
|
||
expect(String(response.debug?.llm_decomposition_effective_message ?? "")).toBe(candidateMessage);
|
||
});
|
||
|
||
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", 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-skazhi-anchor",
|
||
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: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "свк списания/поступления за 2020",
|
||
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-skazhi-anchor-${Date.now()}`,
|
||
user_message: "свк списания/поступления за 2020",
|
||
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).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 diagnostic canonical rewrite like 'Неясно...' for debt-intent repair message", async () => {
|
||
const calls: Array<{ message: string }> = [];
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string) => {
|
||
calls.push({ message });
|
||
return buildAddressLaneResult(message);
|
||
})
|
||
} as any;
|
||
|
||
const sourceMessage = "нет вопрос кто нам в целом должен на денег на эту дату";
|
||
const candidateMessage = "Неясно, кто должен компании деньги на данную дату.";
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
trace_id: "norm-predecompose-diagnostic-rewrite",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: sourceMessage,
|
||
message_in_scope: true,
|
||
scope_confidence: "medium",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: sourceMessage,
|
||
normalized_fragment_text: candidateMessage,
|
||
domain_relevance: "in_scope",
|
||
business_scope: "company_specific_accounting",
|
||
entity_hints: [],
|
||
account_hints: [],
|
||
document_hints: [],
|
||
register_hints: [],
|
||
time_scope: {
|
||
type: "implicit",
|
||
value: null,
|
||
confidence: "low"
|
||
},
|
||
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-diagnostic-rewrite-${Date.now()}`,
|
||
user_message: sourceMessage,
|
||
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(sourceMessage);
|
||
expect(calls[0].message).not.toBe(candidateMessage);
|
||
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_diagnostic_rewrite");
|
||
expect(String(response.debug?.llm_decomposition_effective_message ?? "")).toBe(sourceMessage);
|
||
});
|
||
|
||
it("rejects follow-up intent injection when llm adds documents to same-date account prompt", 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-followup-injection",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: "а на ту же дату по 62",
|
||
message_in_scope: true,
|
||
scope_confidence: "medium",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "а на ту же дату по 62",
|
||
normalized_fragment_text: "документы или проводки по счету 62 на ту же дату",
|
||
domain_relevance: "in_scope",
|
||
business_scope: "company_specific_accounting",
|
||
entity_hints: [],
|
||
account_hints: ["62"],
|
||
document_hints: ["документы"],
|
||
register_hints: [],
|
||
time_scope: {
|
||
type: "explicit",
|
||
value: "same-date",
|
||
confidence: "medium"
|
||
},
|
||
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 sourceMessage = "а на ту же дату по 62";
|
||
const response = await service.handleMessage({
|
||
session_id: `asst-predecompose-followup-injection-${Date.now()}`,
|
||
user_message: sourceMessage,
|
||
llmProvider: "local",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].message).toBe(sourceMessage);
|
||
expect(response.debug?.llm_decomposition_applied).toBe(false);
|
||
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_followup_intent_injection");
|
||
expect(response.debug?.llm_decomposition_effective_message).toBe(sourceMessage);
|
||
});
|
||
|
||
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");
|
||
expect(response.debug?.llm_predecompose_contract?.period?.scope).toBe("year");
|
||
});
|
||
|
||
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: "Покажи документы по договору 19/15 за 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: "Покажи документы по договору 19/15 за 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("документы по договору 19/15 за 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: "Покажи банковские операции по договору 19/15",
|
||
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: "Покажи банковские операции по договору 19/15",
|
||
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("банковские операции по договору 19/15");
|
||
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("rejects account injection when LLM truncates a numeric counterparty suffix", 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-counterparty-suffix-account-injection",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: "Покажи документы по Жуковке 51.",
|
||
message_in_scope: true,
|
||
scope_confidence: "high",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "Покажи документы по Жуковке 51.",
|
||
normalized_fragment_text: "Показать документы, связанные с контрагентом Жуковка по счету 51",
|
||
domain_relevance: "in_scope",
|
||
business_scope: "company_specific_accounting",
|
||
entity_hints: ["Жуковка"],
|
||
account_hints: ["51"],
|
||
document_hints: ["документы"],
|
||
register_hints: [],
|
||
semantic_hints: {
|
||
scope_target_kind: "counterparty",
|
||
scope_target_text: "Жуковка",
|
||
date_scope_kind: "implicit_current",
|
||
self_scope_detected: false,
|
||
selected_object_scope_detected: false
|
||
},
|
||
time_scope: { type: "unspecified", value: null, confidence: "low" },
|
||
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: "high",
|
||
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-counterparty-suffix-${Date.now()}`,
|
||
user_message: "Покажи документы по Жуковке 51.",
|
||
llmProvider: "local",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].message).toBe("Покажи документы по Жуковке 51.");
|
||
expect(response.debug?.llm_decomposition_applied).toBe(false);
|
||
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_anchor_injection");
|
||
expect(response.debug?.llm_predecompose_contract?.entities?.account).toBeNull();
|
||
expect(response.debug?.llm_predecompose_contract?.entities?.counterparty).toBe("Жуковке 51");
|
||
});
|
||
|
||
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("rewrites shorthand bank/contract slang phrase to bank operations by contract", 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-contract-slang",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: "покажи банк опер по дог 19/15 пж",
|
||
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-contract-slang-${Date.now()}`,
|
||
user_message: "покажи банк опер по дог 19/15 пж",
|
||
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("банковские операции по договору 19/15");
|
||
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 loose all-time colloquial lookup in address lane without forcing rewrite", 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-loose-all-time",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: "по свк за весь период че есть",
|
||
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-loose-all-time-${Date.now()}`,
|
||
user_message: "по свк за весь период че есть",
|
||
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("по свк за весь период че есть");
|
||
expect(response.debug?.llm_decomposition_attempted).toBe(true);
|
||
expect(response.debug?.llm_decomposition_applied).toBe(false);
|
||
expect(response.debug?.llm_decomposition_reason).toBe("no_usable_fragment");
|
||
expect(response.debug?.fallback_rule_hit).toBeNull();
|
||
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
|
||
});
|
||
|
||
it("uses llm canonical candidate as gate signal when regex path has no address markers", 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-llm-gate-signal",
|
||
ok: true,
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: "svk gib list",
|
||
message_in_scope: true,
|
||
scope_confidence: "medium",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "svk gib list",
|
||
normalized_fragment_text: "заказчики компании svk",
|
||
domain_relevance: "in_scope",
|
||
confidence: "medium",
|
||
execution_readiness: "no_route",
|
||
route_status: "no_route"
|
||
}
|
||
]
|
||
},
|
||
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-llm-gate-signal-${Date.now()}`,
|
||
user_message: "svk gib list",
|
||
llmProvider: "local",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("clarification_required");
|
||
expect(calls).toHaveLength(0);
|
||
expect(response.debug?.address_tool_gate_decision).toBe("skip_address_lane");
|
||
expect(
|
||
[
|
||
"llm_predecompose_semantic_guard_rejected",
|
||
"llm_predecompose_unsupported_mode",
|
||
"address_signal_unsupported_intent_fallback_to_deep",
|
||
"non_domain_query_indexed"
|
||
]
|
||
).toContain(response.debug?.address_tool_gate_reason);
|
||
});
|
||
|
||
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");
|
||
});
|
||
|
||
});
|
||
|