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

2063 lines
83 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(["factual", "factual_with_explanation"]).toContain(second.reply_type);
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(["factual", "factual_with_explanation"]).toContain(second.reply_type);
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(["factual", "factual_with_explanation"]).toContain(second.reply_type);
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("treats short buyer follow-up as continuation of the active provenance object", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "каму в итоге продано";
const provenanceResult = {
handled: true,
reply_text:
"По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) до 31.01.2016 однозначный поставщик не подтвержден.",
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: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\",
as_of_date: "2016-01-31"
},
missing_required_filters: [],
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
anchor_type: "item",
anchor_value_raw: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
anchor_value_resolved: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
reasons: ["address_action_detected", "address_entity_detected"],
dialog_continuation_contract_v2: {
decision: "continue_previous"
}
}
} as any;
const saleTraceResult = {
handled: true,
reply_text:
"По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) подтвержден покупатель: Комитет государственных услуг г. Москвы.",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_sale_trace_for_item",
detected_intent_confidence: "medium",
extracted_filters: {
item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
organization: "ООО \\Альтернатива Плюс\\",
as_of_date: "2016-01-31"
},
selected_recipe: "address_inventory_sale_trace_for_item_v1",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
} 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 saleTraceResult;
}
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-buyer-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-inventory-provenance-buyer-seed",
session_id: sessionId,
role: "assistant",
text: provenanceResult.reply_text,
reply_type: provenanceResult.reply_type,
created_at: "2026-04-15T12:24:22.251Z",
trace_id: "address-provenance-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(["factual", "factual_with_explanation"]).toContain(second.reply_type);
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(
"Рабочая станция универсального специалиста (индивидуальное изготовление)"
);
expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад");
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2016-01-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");
});
it("continues the original inventory query after organization clarification with a bare company reply", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи остатки по складу";
const secondMessage = "Альтернатива";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === firstMessage) {
return buildAddressLimitedLaneResult("missing_anchor", {
reply_text: [
"Нужно уточнить организацию, чтобы не смешивать компании в одном ответе.",
"Сейчас в доступном контуре вижу такие организации:",
"- ООО Альтернатива Плюс",
"- ООО Лайсвуд"
].join("\n"),
debug: {
...buildAddressLimitedLaneResult("missing_anchor").debug,
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
as_of_date: "2026-04-15"
},
selected_recipe: null,
organization_candidates: ["ООО Альтернатива Плюс", "ООО Лайсвуд"],
reasons: ["organization_clarification_required", "multiple_known_organizations_detected"]
}
});
}
if (message === secondMessage && options?.followupContext && options?.activeOrganization === "ООО Альтернатива Плюс") {
return buildAddressLaneResult({
reply_text: "На 15.04.2026 по ООО Альтернатива Плюс подтвержден складской остаток.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
as_of_date: "2026-04-15",
organization: "ООО Альтернатива Плюс"
},
reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"]
}
});
}
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-org-clarification-${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("partial_coverage");
const second = await service.handleMessage({
session_id: sessionId,
user_message: secondMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(secondMessage);
expect(calls[1].options?.activeOrganization).toBe("ООО Альтернатива Плюс");
expect(calls[1].options?.knownOrganizations).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд"]);
expect(calls[1].options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[1].options?.followupContext?.previous_filters?.organization).toBe("ООО Альтернатива Плюс");
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
});