1862 lines
74 KiB
TypeScript
1862 lines
74 KiB
TypeScript
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 bare 'когда' as a selected-item inventory follow-up for the active provenance object", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const followupMessage = "когда";
|
||
const provenanceResult = {
|
||
handled: true,
|
||
reply_text: "Товар Столешница 600*3050*26 дуб ниагара по доступным закупочным движениям связан с поставщиком: Торговый дом \\Союз\\.",
|
||
reply_type: "factual",
|
||
response_type: "FACTUAL_SUMMARY",
|
||
debug: {
|
||
detected_mode: "address_query",
|
||
detected_intent: "inventory_purchase_provenance_for_item",
|
||
detected_intent_confidence: "high",
|
||
extracted_filters: {
|
||
item: "Столешница 600*3050*26 дуб ниагара",
|
||
warehouse: "Основной склад",
|
||
as_of_date: "2019-03-31"
|
||
},
|
||
missing_required_filters: [],
|
||
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
|
||
anchor_type: "unknown",
|
||
anchor_value_raw: null,
|
||
anchor_value_resolved: null,
|
||
reasons: ["address_action_detected", "address_entity_detected"],
|
||
dialog_continuation_contract_v2: {
|
||
decision: "continue_previous"
|
||
}
|
||
}
|
||
} as any;
|
||
|
||
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 {
|
||
...provenanceResult,
|
||
reply_text: "Позиция Столешница 600*3050*26 дуб ниагара куплена 12.02.2019.\n\nПодтверждение:\n- Первый подтверждающий документ: Поступление товаров и услуг 00000000003 от 12.02.2019.",
|
||
debug: {
|
||
...provenanceResult.debug,
|
||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||
}
|
||
};
|
||
}
|
||
return provenanceResult;
|
||
})
|
||
} 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-when-${Date.now()}`;
|
||
sessions.appendItem(sessionId, {
|
||
message_id: "msg-inventory-provenance-seed",
|
||
session_id: sessionId,
|
||
role: "assistant",
|
||
text: provenanceResult.reply_text,
|
||
reply_type: provenanceResult.reply_type,
|
||
created_at: "2026-04-14T18:00:00.000Z",
|
||
trace_id: "address-seed",
|
||
debug: provenanceResult.debug
|
||
} as any);
|
||
|
||
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(1);
|
||
expect(calls[0].message).toBe(followupMessage);
|
||
expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item");
|
||
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
|
||
expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад");
|
||
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2019-03-31");
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps historical stock date window for selected-object supplier wording 'у кого куплено'", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const rootMessage = 'какие у нас остатки на складе на июнь 2020';
|
||
const followupMessage = 'По выбранному объекту "Конструкция трансформер рабочей станции 1300*900*2000": у кого куплено';
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === rootMessage) {
|
||
return {
|
||
handled: true,
|
||
reply_text:
|
||
"На 30.06.2020 на складе подтверждено 12 позиций с остатком на 755.392,33 ₽.\n1. Конструкция трансформер рабочей станции 1300*900*2000 | склад: Основной склад",
|
||
reply_type: "factual",
|
||
response_type: "FACTUAL_LIST",
|
||
debug: {
|
||
detected_mode: "address_query",
|
||
detected_intent: "inventory_on_hand_as_of_date",
|
||
detected_intent_confidence: "high",
|
||
extracted_filters: {
|
||
as_of_date: "2020-06-30",
|
||
period_from: "2020-06-01",
|
||
period_to: "2020-06-30",
|
||
warehouse: "Основной склад",
|
||
organization: "ООО \\Альтернатива Плюс\\"
|
||
},
|
||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||
capability_id: "confirmed_inventory_on_hand_as_of_date",
|
||
capability_route_mode: "exact",
|
||
anchor_type: "unknown",
|
||
anchor_value_raw: null,
|
||
anchor_value_resolved: null,
|
||
reasons: ["address_action_detected", "address_entity_detected"]
|
||
}
|
||
};
|
||
}
|
||
return {
|
||
handled: true,
|
||
reply_text:
|
||
"Поставщик по позиции Конструкция трансформер рабочей станции 1300*900*2000: ООО \\Гамма-мебель\\.",
|
||
reply_type: "factual",
|
||
response_type: "FACTUAL_SUMMARY",
|
||
debug: {
|
||
detected_mode: "address_query",
|
||
detected_intent: "inventory_purchase_provenance_for_item",
|
||
detected_intent_confidence: "medium",
|
||
extracted_filters: {
|
||
item: "Конструкция трансформер рабочей станции 1300*900*2000",
|
||
as_of_date: "2020-06-30",
|
||
period_from: "2020-06-01",
|
||
period_to: "2020-06-30"
|
||
},
|
||
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
|
||
capability_id: "inventory_inventory_purchase_provenance_for_item",
|
||
capability_route_mode: "exact",
|
||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||
},
|
||
...(options?.followupContext ? {} : {})
|
||
};
|
||
})
|
||
} 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-u-kogo-kupleno-${Date.now()}`;
|
||
const first = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: rootMessage,
|
||
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("inventory_on_hand_as_of_date");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-06-30");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.period_from).toBe("2020-06-01");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.period_to).toBe("2020-06-30");
|
||
expect(calls[1].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
|
||
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО \\Альтернатива Плюс\\");
|
||
expect(calls[1].options?.followupContext?.root_filters?.as_of_date).toBe("2020-06-30");
|
||
expect(calls[1].options?.followupContext?.current_frame_kind).toBe("inventory_root");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад");
|
||
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("keeps debt lifecycle follow-up context for 'а нам кто должен?.' after payables as-of answer", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage =
|
||
"\u043a\u043e\u043c\u0443 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017";
|
||
const followupMessage =
|
||
"\u0430 \u043d\u0430\u043c \u043a\u0442\u043e \u0434\u043e\u043b\u0436\u0435\u043d?.";
|
||
|
||
const payablesResult = buildAddressLaneResult({
|
||
reply_text:
|
||
"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u0441\u0442\u0432 \u043a \u043e\u043f\u043b\u0430\u0442\u0435 \u043d\u0430 30.09.2017",
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
query_shape: "UNKNOWN",
|
||
query_shape_confidence: "low",
|
||
detected_intent: "payables_confirmed_as_of_date",
|
||
detected_intent_confidence: "high",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
period_from: "2017-09-01",
|
||
period_to: "2017-09-30",
|
||
as_of_date: "2017-09-30"
|
||
},
|
||
selected_recipe: "address_payables_confirmed_as_of_date_v1",
|
||
response_type: "FACTUAL_LIST",
|
||
requested_result_mode: "confirmed_balance",
|
||
result_mode: "confirmed_balance",
|
||
balance_confirmed: true,
|
||
reasons: ["payables_debt_lifecycle_signal_detected", "confirmed_balance_exact_payables_intent"]
|
||
}
|
||
});
|
||
|
||
const receivablesResult = buildAddressLaneResult({
|
||
reply_text:
|
||
"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a\u043e\u0439 \u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u0438 \u043d\u0430 30.09.2017",
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
query_shape: "UNKNOWN",
|
||
query_shape_confidence: "low",
|
||
detected_intent: "receivables_confirmed_as_of_date",
|
||
detected_intent_confidence: "high",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
period_from: "2017-09-01",
|
||
period_to: "2017-09-30",
|
||
as_of_date: "2017-09-30"
|
||
},
|
||
selected_recipe: "address_receivables_confirmed_as_of_date_v1",
|
||
response_type: "FACTUAL_LIST",
|
||
requested_result_mode: "confirmed_balance",
|
||
result_mode: "confirmed_balance",
|
||
balance_confirmed: true,
|
||
reasons: ["receivables_debt_lifecycle_signal_detected", "confirmed_balance_exact_receivables_intent"]
|
||
}
|
||
});
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === firstMessage) {
|
||
return payablesResult;
|
||
}
|
||
if (message === followupMessage) {
|
||
if (!options?.followupContext) {
|
||
return null;
|
||
}
|
||
return receivablesResult;
|
||
}
|
||
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-debt-${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("receivables_confirmed_as_of_date");
|
||
expect(second.debug?.selected_recipe).toBe("address_receivables_confirmed_as_of_date_v1");
|
||
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls[1].message).toBe(followupMessage);
|
||
expect(calls[1].options?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30");
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("mirrors receivables->payables for short follow-up 'a мы кому' and keeps as-of date", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage = "кто нам должен на сентябрь 2017";
|
||
const followupMessage = "a мы кому";
|
||
|
||
const receivablesResult = buildAddressLaneResult({
|
||
reply_text: "Подтвержденный срез дебиторской задолженности на 30.09.2017",
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
query_shape: "UNKNOWN",
|
||
query_shape_confidence: "low",
|
||
detected_intent: "receivables_confirmed_as_of_date",
|
||
detected_intent_confidence: "high",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
period_from: "2017-09-01",
|
||
period_to: "2017-09-30",
|
||
as_of_date: "2017-09-30"
|
||
},
|
||
selected_recipe: "address_receivables_confirmed_as_of_date_v1",
|
||
response_type: "FACTUAL_LIST",
|
||
requested_result_mode: "confirmed_balance",
|
||
result_mode: "confirmed_balance",
|
||
balance_confirmed: true,
|
||
reasons: ["receivables_debt_lifecycle_signal_detected", "confirmed_balance_exact_receivables_intent"]
|
||
}
|
||
});
|
||
|
||
const payablesResult = buildAddressLaneResult({
|
||
reply_text: "Подтвержденный срез обязательств к оплате на 30.09.2017",
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
query_shape: "UNKNOWN",
|
||
query_shape_confidence: "low",
|
||
detected_intent: "payables_confirmed_as_of_date",
|
||
detected_intent_confidence: "high",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
period_from: "2017-09-01",
|
||
period_to: "2017-09-30",
|
||
as_of_date: "2017-09-30"
|
||
},
|
||
selected_recipe: "address_payables_confirmed_as_of_date_v1",
|
||
response_type: "FACTUAL_LIST",
|
||
requested_result_mode: "confirmed_balance",
|
||
result_mode: "confirmed_balance",
|
||
balance_confirmed: true,
|
||
reasons: ["payables_debt_lifecycle_signal_detected", "confirmed_balance_exact_payables_intent"]
|
||
}
|
||
});
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === firstMessage) {
|
||
return receivablesResult;
|
||
}
|
||
if (message === followupMessage) {
|
||
if (!options?.followupContext) {
|
||
return null;
|
||
}
|
||
if (options?.followupContext?.previous_intent !== "payables_confirmed_as_of_date") {
|
||
return null;
|
||
}
|
||
return payablesResult;
|
||
}
|
||
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-debt-mirror-${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("payables_confirmed_as_of_date");
|
||
expect(second.debug?.selected_recipe).toBe("address_payables_confirmed_as_of_date_v1");
|
||
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls[1].message).toBe(followupMessage);
|
||
expect(calls[1].options?.followupContext?.previous_intent).toBe("payables_confirmed_as_of_date");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30");
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps short VAT follow-up in address lane after debt as-of answer", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage =
|
||
"\u043a\u043e\u043c\u0443 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017";
|
||
const followupMessage = "\u0430 \u043d\u0434\u0441?";
|
||
|
||
const payablesResult = buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
detected_intent: "payables_confirmed_as_of_date",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
period_from: "2017-09-01",
|
||
period_to: "2017-09-30",
|
||
as_of_date: "2017-09-30"
|
||
},
|
||
selected_recipe: "address_payables_confirmed_as_of_date_v1",
|
||
response_type: "FACTUAL_LIST",
|
||
requested_result_mode: "confirmed_balance",
|
||
result_mode: "confirmed_balance",
|
||
balance_confirmed: true
|
||
}
|
||
});
|
||
|
||
const vatResult = buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
detected_intent: "vat_payable_confirmed_as_of_date",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
period_from: "2017-09-01",
|
||
period_to: "2017-09-30",
|
||
as_of_date: "2017-09-30"
|
||
},
|
||
selected_recipe: "address_vat_payable_confirmed_as_of_date_v1",
|
||
response_type: "FACTUAL_LIST",
|
||
requested_result_mode: "confirmed_balance",
|
||
result_mode: "confirmed_balance",
|
||
balance_confirmed: true
|
||
}
|
||
});
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === firstMessage) {
|
||
return payablesResult;
|
||
}
|
||
if (!options?.followupContext) {
|
||
return null;
|
||
}
|
||
return vatResult;
|
||
})
|
||
} 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-vat-${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("vat_payable_confirmed_as_of_date");
|
||
expect(second.debug?.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1");
|
||
|
||
expect(calls).toHaveLength(2);
|
||
expect(typeof calls[1].message).toBe("string");
|
||
expect(String(calls[1].message).length).toBeGreaterThan(0);
|
||
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30");
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps month-only VAT follow-up phrase in address lane", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage = "\u0441\u043a\u043e\u043a \u043d\u0434\u0441 \u043d\u0430\u0434\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c \u0432 \u043d\u0430\u043b\u043e\u0433\u043e\u0432\u0443\u044e \u043d\u0430 \u0444\u0435\u0432\u0440\u0430\u043b\u044c 2017";
|
||
const followupMessage = "\u0430 \u043d\u0430 \u043c\u0430\u0440\u0442";
|
||
|
||
const firstVatResult = buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
detected_intent: "vat_liability_confirmed_for_tax_period",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
period_from: "2017-01-01",
|
||
period_to: "2017-03-31"
|
||
},
|
||
selected_recipe: "address_vat_liability_confirmed_tax_period_v1",
|
||
response_type: "FACTUAL_SUMMARY",
|
||
requested_result_mode: "confirmed_balance",
|
||
result_mode: "confirmed_balance",
|
||
balance_confirmed: true
|
||
}
|
||
});
|
||
|
||
const followupVatResult = buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
detected_intent: "vat_liability_confirmed_for_tax_period",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
period_from: "2017-01-01",
|
||
period_to: "2017-03-31"
|
||
},
|
||
selected_recipe: "address_vat_liability_confirmed_tax_period_v1",
|
||
response_type: "FACTUAL_SUMMARY",
|
||
requested_result_mode: "confirmed_balance",
|
||
result_mode: "confirmed_balance",
|
||
balance_confirmed: true,
|
||
reasons: [
|
||
"address_action_detected",
|
||
"vat_liability_confirmed_tax_period_signal_detected",
|
||
"address_followup_context_applied"
|
||
]
|
||
}
|
||
});
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === firstMessage) {
|
||
return firstVatResult;
|
||
}
|
||
if (!options?.followupContext) {
|
||
return null;
|
||
}
|
||
return followupVatResult;
|
||
})
|
||
} 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-vat-march-${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("vat_liability_confirmed_for_tax_period");
|
||
expect(second.debug?.selected_recipe).toBe("address_vat_liability_confirmed_tax_period_v1");
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls[1].message).toBe(followupMessage);
|
||
expect(calls[1].options?.followupContext?.previous_intent).toBe("vat_liability_confirmed_for_tax_period");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.period_from).toBe("2017-01-01");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.period_to).toBe("2017-03-31");
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps 'a na tekushuyu datu' VAT follow-up in address lane", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage = "\u0441\u043a\u043e\u043a \u043d\u0430\u0434\u043e \u043d\u0434\u0441 \u043f\u043b\u0430\u0442\u0438\u0442\u044c \u0441 \u0430\u043f\u0440\u0435\u043b\u0435 2017";
|
||
const followupMessage = "\u0430 \u043d\u0430 \u0442\u0435\u043a\u0443\u0449\u0443\u044e \u0434\u0430\u0442\u0443";
|
||
|
||
const vatAsOfResult = buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
detected_intent: "vat_payable_confirmed_as_of_date",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
period_from: "2017-04-01",
|
||
period_to: "2017-04-30",
|
||
as_of_date: "2017-04-30"
|
||
},
|
||
selected_recipe: "address_vat_payable_confirmed_as_of_date_v1",
|
||
response_type: "FACTUAL_SUMMARY",
|
||
requested_result_mode: "confirmed_balance",
|
||
result_mode: "confirmed_balance",
|
||
balance_confirmed: true
|
||
}
|
||
});
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === firstMessage) {
|
||
return vatAsOfResult;
|
||
}
|
||
if (!options?.followupContext) {
|
||
return null;
|
||
}
|
||
return vatAsOfResult;
|
||
})
|
||
} 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-vat-current-date-${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("vat_payable_confirmed_as_of_date");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-04-30");
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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");
|
||
});
|
||
});
|
||
|