NODEDC_1C/llm_normalizer/backend/tests/assistantAddressFollowupCon...

1142 lines
46 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(overrides?: Record<string, unknown>): any {
return {
handled: true,
reply_text: "Собран список документов по контрагенту.",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_mode_confidence: "high",
query_shape: "DOCUMENT_LIST",
query_shape_confidence: "medium",
detected_intent: "list_documents_by_counterparty",
detected_intent_confidence: "medium",
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "свк"
},
missing_required_filters: [],
selected_recipe: "address_documents_by_counterparty_v1",
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: "preferred",
account_scope_fallback_applied: false,
anchor_type: "counterparty",
anchor_value_raw: "свк",
anchor_value_resolved: "Группа СВК",
resolver_confidence: "medium",
ambiguity_count: 0,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: "matched_non_empty",
rows_fetched: 20,
raw_rows_received: 20,
rows_after_account_scope: 5,
rows_after_recipe_filter: 3,
rows_materialized: 5,
rows_matched: 3,
raw_row_keys_sample: [],
materialization_drop_reason: "none",
account_token_raw: null,
account_token_normalized: null,
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
account_scope_drop_reason: "not_applicable",
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: "FACTUAL_LIST",
limitations: [],
reasons: ["address_action_detected", "address_entity_detected"]
},
...(overrides ?? {})
};
}
function buildAddressLimitedLaneResult(
category: "missing_anchor" | "empty_match" = "missing_anchor",
overrides?: Record<string, unknown>
): any {
const base = buildAddressLaneResult();
return {
...base,
reply_text: "Нужны уточнения по якорю.",
reply_type: "partial_coverage",
response_type: "LIMITED_WITH_REASON",
debug: {
...base.debug,
response_type: "LIMITED_WITH_REASON",
limited_reason_category: category,
reasons: ["address_action_detected", "address_entity_detected"]
},
...(overrides ?? {})
};
}
describe("assistant address follow-up carryover", () => {
it("keeps short follow-up in address lane by reusing previous anchor context", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === "а за все время?" && !options?.followupContext) {
return null;
}
if (message === "а за все время?" && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "Собран список документов по контрагенту за все время.",
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult();
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: "покажи документы по свк за 2020",
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: "а за все время?",
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_mode).toBe("address_query");
expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied");
expect(calls).toHaveLength(2);
expect(calls[0].message.toLowerCase()).toContain("свк");
expect(calls[1].message).toBe("а за все время?");
expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_value).toBe("Группа СВК");
expect(calls[1].options?.followupContext?.previous_filters?.counterparty).toBe("свк");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short 'по <anchor> также' phrase as follow-up for address lane", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020";
const followupMessage = "\u043f\u043e \u0441\u0432\u043a \u0442\u0430\u043a\u0436\u0435 \u043f\u043b\u0438\u0437";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && !options?.followupContext) {
return null;
}
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult();
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-short-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(typeof calls[1].options?.followupContext?.previous_anchor_value).toBe("string");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short affirmative 'давай' as follow-up for previous address answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020";
const followupMessage = "\u0434\u0430\u0432\u0430\u0439";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && !options?.followupContext) {
return null;
}
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult();
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-davai-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по свк за 2020";
const followupMessage = "показывыай";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && !options?.followupContext) {
return null;
}
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult();
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-pokazyvai-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps previous counterparty context for referential follow-up 'кроме этого документа...'", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по жуковке 51";
const followupMessage = "кроме этого документа есть еще чтото?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && !options?.followupContext) {
return null;
}
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
anchor_value_raw: "кроме",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "жуковке 51"
},
anchor_value_raw: "жуковке 51",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-referential-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "")).toContain("Жуковка 51");
expect(String(calls[1].options?.followupContext?.previous_filters?.counterparty ?? "")).toContain("жуковке 51");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("retries with raw user message after rewrite degraded anchor and returns factual follow-up result", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по жуковке 51";
const followupMessage = "кроме этого документа есть еще чтото?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
const compact = String(message ?? "").trim().toLowerCase();
if (calls.length === 1) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "жуковке 51"
},
anchor_value_raw: "жуковке 51",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
}
});
}
if (compact === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "жуковке 51"
},
anchor_value_raw: "жуковке 51",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
if (compact.startsWith("документы по контрагенту") && options?.followupContext) {
return buildAddressLimitedLaneResult("missing_anchor", {
debug: {
...buildAddressLimitedLaneResult("missing_anchor").debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "кроме"
},
anchor_value_raw: "кроме",
anchor_value_resolved: "кроме"
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-safe-retry-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.address_retry_audit?.attempted).toBe(true);
expect(second.debug?.address_retry_audit?.initial_limited_category).toBe("missing_anchor");
expect(second.debug?.address_retry_audit?.retry_message).toBe(followupMessage);
expect(calls.some((entry) => String(entry.message).toLowerCase().startsWith("документы по контрагенту"))).toBe(true);
expect(calls.some((entry) => String(entry.message).toLowerCase() === followupMessage)).toBe(true);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("reuses last real address context after intermediate clarification fallback", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const lifecycleFollowupMessage = "А кто из них новые?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === "что там не так?") {
return null;
}
if (message === lifecycleFollowupMessage && !options?.followupContext) {
return null;
}
if (message === lifecycleFollowupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
detected_intent: "counterparty_activity_lifecycle",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
reasons: ["address_action_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult();
})
} as any;
const normalizerService = {
normalize: vi.fn(async (payload: any) => ({
assistant_reply: payload?.userQuestion === "что там не так?" ? "Нужно уточнение по фокусу." : "unexpected_normalizer_call",
reply_type: payload?.userQuestion === "что там не так?" ? "clarification_required" : "partial_coverage",
debug: {
prompt_version: "address_query_runtime_v1",
detected_mode: null,
detected_intent: null
}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-clar-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: "покажи документы по свк за 2020",
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: "что там не так?",
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("clarification_required");
const third = await service.handleMessage({
session_id: sessionId,
user_message: lifecycleFollowupMessage,
useMock: true
} as any);
expect(third.ok).toBe(true);
expect(third.reply_type).toBe("factual");
expect(third.debug?.detected_mode).toBe("address_query");
expect(third.debug?.detected_intent).toBe("counterparty_activity_lifecycle");
expect(calls.length).toBeGreaterThanOrEqual(2);
const contextualCall = calls.find((item) => item.message === lifecycleFollowupMessage && Boolean(item.options?.followupContext));
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(String(contextualCall?.options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).toHaveBeenCalledTimes(1);
});
it("resolves counterparty mention from previous displayed list and carries it into value follow-up", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "с кем мы работали в 2020 годы- покажи клиентов";
const followupMessage = "сколько денег за 2020 принес калинин?";
const lifecycleReply = [
"Активные заказчики в 2020 году: 3.",
"1. Группа | операций: 13 | последняя активность: 2020-12-30T12:00:00Z | лет в базе: 1",
"2. ИП Калинин Н.М. | операций: 2 | последняя активность: 2020-03-02T12:00:03Z | лет в базе: 1",
"3. Смарт | операций: 1 | последняя активность: 2020-02-07T12:00:03Z | лет в базе: 1"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.counterparty !== "ИП Калинин Н.М.") {
return null;
}
return buildAddressLaneResult({
reply_text: "ИП Калинин Н.М. | сумма: 216600 | операций: 2",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "customer_revenue_and_payments",
selected_recipe: "address_customer_revenue_and_payments_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31",
counterparty: "ИП Калинин Н.М."
},
anchor_type: "counterparty",
anchor_value_raw: "калинин",
anchor_value_resolved: "ИП Калинин Н.М.",
reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: lifecycleReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "counterparty_activity_lifecycle",
selected_recipe: "address_counterparty_activity_lifecycle_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
reasons: ["address_action_detected", "counterparty_activity_lifecycle_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-kalinin-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("customer_revenue_and_payments");
expect(second.debug?.extracted_filters?.counterparty).toBe("ИП Калинин Н.М.");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "ИП Калинин Н.М."
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("ИП Калинин Н.М.");
expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true);
expect(second.debug?.dialog_continuation_contract_v2?.target_intent).toBe("customer_revenue_and_payments");
expect(second.debug?.dialog_continuation_contract_v2?.decision).toBe("continue_previous");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("resolves short declined counterparty mention from displayed top list into contracts follow-up", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "топ топ клиентов по приходам за 2020";
const followupMessage = "покажи договор по гамме";
const topReply = [
"Топ-6 заказчиков по сумме поступлений:",
"1. Группа | сумма: 12093465 | операций: 13 | средний чек: 930266.54 | макс: 3320600",
"2. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | сумма: 1642764.88 | операций: 1 | средний чек: 1642764.88 | макс: 1642764.88",
"4. Гамма-мебель, ООО | сумма: 471000 | операций: 2 | средний чек: 235500.00 | макс: 250000",
"5. «Олимпстрой» | сумма: 276873.6 | операций: 1 | средний чек: 276873.60 | макс: 276873.6"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.counterparty !== "Гамма-мебель, ООО") {
return null;
}
return buildAddressLaneResult({
reply_text: "Собран список договоров по контрагенту Гамма-мебель, ООО.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31",
counterparty: "Гамма-мебель, ООО"
},
anchor_type: "counterparty",
anchor_value_raw: "гамме",
anchor_value_resolved: "Гамма-мебель, ООО",
reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: topReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "customer_revenue_and_payments",
selected_recipe: "address_customer_revenue_and_payments_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-gamma-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_contracts_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("Гамма-мебель, ООО");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "Гамма-мебель, ООО"
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Гамма-мебель, ООО");
expect(contextualCall?.options?.followupContext?.previous_filters?.period_from).toBe("2020-01-01");
expect(contextualCall?.options?.followupContext?.previous_filters?.period_to).toBe("2020-12-31");
expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true);
expect(second.debug?.dialog_continuation_contract_v2?.target_intent).toBe("list_contracts_by_counterparty");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("resolves numbered item from displayed top list into counterparty drill-down", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "топ топ клиентов по приходам за 2020";
const followupMessage = "покажи договор по пункту 4";
const topReply = [
"Топ-6 заказчиков по сумме поступлений:",
"1. Группа | сумма: 12093465 | операций: 13 | средний чек: 930266.54 | макс: 3320600",
"2. ЗАО Ремонтно-строительная фирма «Ремстройсервис» | сумма: 1642764.88 | операций: 1 | средний чек: 1642764.88 | макс: 1642764.88",
"4. Гамма-мебель, ООО | сумма: 471000 | операций: 2 | средний чек: 235500.00 | макс: 250000",
"5. «Олимпстрой» | сумма: 276873.6 | операций: 1 | средний чек: 276873.60 | макс: 276873.6"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.counterparty !== "Гамма-мебель, ООО") {
return null;
}
return buildAddressLaneResult({
reply_text: "Собран список договоров по контрагенту Гамма-мебель, ООО.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31",
counterparty: "Гамма-мебель, ООО"
},
anchor_type: "counterparty",
anchor_value_raw: "пункт 4",
anchor_value_resolved: "Гамма-мебель, ООО",
reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: topReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "customer_revenue_and_payments",
selected_recipe: "address_customer_revenue_and_payments_v1",
extracted_filters: {
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-index-counterparty-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_contracts_by_counterparty");
expect(second.debug?.extracted_filters?.counterparty).toBe("Гамма-мебель, ООО");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "Гамма-мебель, ООО"
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Гамма-мебель, ООО");
expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("resolves numbered contract item from previous contract list into documents-by-contract follow-up", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи договоры по гамме";
const followupMessage = "покажи документы по пункту 2";
const contractsReply = [
"Собран список договоров по контрагенту Гамма-мебель, ООО.",
"1. Договор № 1-ГМ/2020 | операций: 4 | последняя активность: 2020-12-14T12:00:00Z",
"2. Договор № 2-ГМ/2020 | операций: 3 | последняя активность: 2020-12-30T12:00:00Z",
"3. Договор № 3-ГМ/2020 | операций: 1 | последняя активность: 2020-08-11T13:15:30Z"
].join("\n");
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage) {
if (options?.followupContext?.previous_filters?.contract !== "Договор № 2-ГМ/2020") {
return null;
}
return buildAddressLaneResult({
reply_text: "Собран список документов по договору № 2-ГМ/2020.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_documents_by_contract",
selected_recipe: "address_documents_by_contract_v1",
extracted_filters: {
contract: "Договор № 2-ГМ/2020"
},
anchor_type: "contract",
anchor_value_raw: "пункт 2",
anchor_value_resolved: "Договор № 2-ГМ/2020",
reasons: ["address_action_detected", "documents_by_contract_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
reply_text: contractsReply,
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
counterparty: "Гамма-мебель, ООО",
period_from: "2020-01-01",
period_to: "2020-12-31"
},
anchor_type: "counterparty",
anchor_value_raw: "гамма",
anchor_value_resolved: "Гамма-мебель, ООО",
reasons: ["address_action_detected", "contracts_by_counterparty_signal_detected"]
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-index-contract-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.detected_intent).toBe("list_documents_by_contract");
expect(second.debug?.extracted_filters?.contract).toBe("Договор № 2-ГМ/2020");
const contextualCall = calls.find(
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.contract === "Договор № 2-ГМ/2020"
);
expect(contextualCall).toBeTruthy();
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("contract");
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("Договор № 2-ГМ/2020");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("does not carry address follow-up context into capability question", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по свк за 2020";
const capabilityMessage = "и 1с можешь настроить?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (String(message).toLowerCase().includes("свк")) {
return buildAddressLaneResult();
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService,
chatClient
);
const sessionId = `asst-address-followup-capability-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: capabilityMessage,
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual_with_explanation");
expect(String(second.assistant_reply).toLowerCase()).toContain("не настраиваю 1с");
expect(calls).toHaveLength(1);
expect(String(calls[0].message).toLowerCase()).toContain("свк");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
});
it("passes active organization scope into address lane follow-up context", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
return buildAddressLaneResult();
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-org-scope-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-seed-org",
session_id: sessionId,
role: "assistant",
text: "Data scope organizations are available.",
reply_type: "factual_with_explanation",
created_at: new Date().toISOString(),
trace_id: "chat-org-seed",
debug: {
trace_id: "chat-org-seed",
living_chat_data_scope_probe_status: "resolved",
living_chat_data_scope_probe_organizations: ["Alternative Plus LLC", "Lacewood LLC", "RIME"],
assistant_active_organization: "Alternative Plus LLC"
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: "show docs by svk for 2020",
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls.length).toBeGreaterThan(0);
const scopedCall = calls.find((entry) => Boolean(entry.options?.followupContext?.previous_filters?.organization));
expect(scopedCall).toBeTruthy();
expect(scopedCall?.options?.followupContext?.previous_filters?.organization).toBe("Alternative Plus LLC");
});
});