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

3471 lines
137 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);
if (second.reply_type !== "factual") {
throw new Error(JSON.stringify({ calls, secondReplyType: second.reply_type, secondDebug: second.debug }, null, 2));
}
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);
if (second.reply_type !== "factual") {
throw new Error(JSON.stringify({ calls, secondReplyType: second.reply_type, secondDebug: second.debug }, null, 2));
}
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_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("prefers the raw referential document follow-up over a degraded rewrite 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);
if (second.reply_type !== "factual") {
throw new Error(JSON.stringify({ calls, secondReplyType: second.reply_type, secondDebug: second.debug }, null, 2));
}
expect(second.reply_type).toBe("factual");
expect(second.debug?.address_retry_audit?.attempted).toBe(false);
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("switches from document drilldown to bank operations on a pronoun payment 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 (options?.followupContext?.previous_intent === "bank_operations_by_counterparty") {
return buildAddressLaneResult({
reply_text: "Собран список банковских операций по контрагенту.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "bank_operations_by_counterparty",
selected_recipe: "address_bank_operations_by_counterparty_v1",
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "жуковке 51"
},
anchor_type: "counterparty",
anchor_value_raw: "жуковке 51",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["address_action_detected", "bank_ops_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_documents_by_counterparty",
selected_recipe: "address_documents_by_counterparty_v1",
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "жуковке 51"
},
anchor_type: "counterparty",
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-docs-to-bank-pronoun-${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).toMatch(/банковские операции|платежи/i);
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
expect(calls[1].options?.followupContext?.target_intent).toBe("bank_operations_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
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 counterparty scope when pivoting from contracts list to payments by pronoun", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "Покажи документы по Жуковке 51.";
const secondMessage = "А по нему договоры?";
const thirdMessage = "А по нему платежи?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
const normalizedMessage = String(message ?? "").toLowerCase();
if (normalizedMessage.includes("документ") && normalizedMessage.includes("жуков")) {
return buildAddressLaneResult({
reply_text: "Собран список документов по контрагенту ТСЖ \\Жуковка 51\\.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_documents_by_counterparty",
selected_recipe: "address_documents_by_counterparty_v1",
extracted_filters: {
counterparty: "ТСЖ \\Жуковка 51\\"
},
anchor_type: "counterparty",
anchor_value_raw: "Жуковке 51",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["documents_by_counterparty_signal_detected"]
}
});
}
if (normalizedMessage.includes("договор")) {
return buildAddressLaneResult({
reply_text: [
"Коротко: найдено 1 договоров по контрагенту.",
"Контрагент: ТСЖ \\Жуковка 51\\.",
"1. Счет № 5 от 05.04.2017"
].join("\n"),
debug: {
...buildAddressLaneResult().debug,
detected_intent: "list_contracts_by_counterparty",
selected_recipe: "address_contracts_by_counterparty_v1",
extracted_filters: {
counterparty: "ТСЖ \\Жуковка 51\\"
},
anchor_type: "counterparty",
anchor_value_raw: "нему",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["contracts_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
if (normalizedMessage.includes("плат") || normalizedMessage.includes("банк")) {
return buildAddressLaneResult({
reply_text: "Собран список банковских операций по контрагенту ТСЖ \\Жуковка 51\\.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "bank_operations_by_counterparty",
selected_recipe: "address_bank_operations_by_counterparty_v1",
extracted_filters: {
counterparty: "ТСЖ \\Жуковка 51\\"
},
anchor_type: "counterparty",
anchor_value_raw: "нему",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["bank_operations_by_counterparty_signal_detected", "address_followup_context_applied"]
}
});
}
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-contracts-payments-${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: secondMessage,
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");
const third = await service.handleMessage({
session_id: sessionId,
user_message: thirdMessage,
useMock: true
} as any);
expect(third.ok).toBe(true);
expect(third.reply_type).toBe("factual");
expect(third.debug?.detected_intent).toBe("bank_operations_by_counterparty");
expect(third.debug?.dialog_continuation_contract_v2?.target_intent).toBe("bank_operations_by_counterparty");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
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();
});
it("keeps historical inventory date follow-up alive after company clarification and a capability answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи остатки по складу";
const secondMessage = "Альтернатива";
const historicalCapabilityMessage = "а исторические остатки на другие даты умеешь?";
const dateFollowupMessage = "давай на июль 2017";
const organization = "ООО Альтернатива Плюс";
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-19"
},
organization_candidates: [organization, "ООО Лайсвуд"],
reasons: ["organization_clarification_required", "multiple_known_organizations_detected"]
}
});
}
if (message === secondMessage && options?.followupContext && options?.activeOrganization === organization) {
return buildAddressLaneResult({
reply_text: "На 19.04.2026 по ООО Альтернатива Плюс подтвержден складской остаток.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
as_of_date: "2026-04-19",
organization
},
reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"]
}
});
}
if (message === dateFollowupMessage && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "На 31.07.2017 по ООО Альтернатива Плюс подтвержден складской остаток.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization,
as_of_date: "2017-07-31",
period_from: "2017-07-01",
period_to: "2017-07-31"
},
reasons: ["address_followup_context_applied", "inventory_root_temporal_followup_detected"]
}
});
}
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-historical-${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");
const third = await service.handleMessage({
session_id: sessionId,
user_message: historicalCapabilityMessage,
useMock: true
} as any);
expect(third.ok).toBe(true);
expect(["factual", "factual_with_explanation"]).toContain(third.reply_type);
const fourth = await service.handleMessage({
session_id: sessionId,
user_message: dateFollowupMessage,
useMock: true
} as any);
expect(fourth.ok).toBe(true);
expect(fourth.reply_type).toBe("factual");
const dateCall = calls.find((entry) => entry.message === dateFollowupMessage);
expect(dateCall).toBeTruthy();
expect(dateCall?.options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date");
expect(dateCall?.options?.followupContext?.previous_filters?.organization).toBe(organization);
expect(dateCall?.options?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("sanitizes selected-item carryover when inventory drilldown pivots into VAT follow-up", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "\u0430 \u043d\u0434\u0441?";
const itemLabel =
"\u041a\u0440\u043e\u043c\u043a\u0430 \u0441 \u043a\u043b\u0435\u0435\u043c 33 \u0434\u0443\u0431 \u043d\u0438\u0430\u0433\u0430\u0440\u0430 137 \u043c";
const organization = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const warehouse = "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0441\u043a\u043b\u0430\u0434";
const vatResult = buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
detected_intent: "vat_payable_confirmed_as_of_date",
extracted_filters: {
sort: "period_desc",
period_from: "2021-03-01",
period_to: "2021-03-31",
as_of_date: "2021-03-31",
organization
},
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,
reasons: [
"address_action_detected",
"address_entity_detected",
"address_followup_context_applied"
]
}
});
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
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-inventory-vat-pivot-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-inventory-root-seed",
session_id: sessionId,
role: "assistant",
text: "inventory root seed",
reply_type: "factual",
created_at: "2026-04-15T14:01:00.000Z",
trace_id: "address-root-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
as_of_date: "2021-03-31",
period_from: "2021-03-01",
period_to: "2021-03-31",
organization,
warehouse
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1"
}
} as any);
sessions.appendItem(sessionId, {
message_id: "msg-inventory-sale-seed",
session_id: sessionId,
role: "assistant",
text: "inventory sale trace seed",
reply_type: "factual",
created_at: "2026-04-15T14:02:00.000Z",
trace_id: "address-sale-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_sale_trace_for_item",
extracted_filters: {
item: itemLabel,
organization,
as_of_date: "2021-03-31"
},
selected_recipe: "address_inventory_sale_trace_for_item_v1",
anchor_type: "item",
anchor_value_raw: itemLabel,
anchor_value_resolved: itemLabel
}
} 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(second.debug?.detected_intent).toBe("vat_payable_confirmed_as_of_date");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.root_context_only).toBe(true);
expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_anchor_type).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_anchor_value).toBeNull();
expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe(warehouse);
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.previous_filters?.period_from).toBe("2021-03-01");
expect(calls[0].options?.followupContext?.previous_filters?.period_to).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.root_filters?.period_from).toBe("2021-03-01");
expect(calls[0].options?.followupContext?.root_filters?.period_to).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.current_frame_kind).toBe("inventory_root");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short supplier follow-up after sale trace as continuation of the active selected object", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "а купили у кого";
const saleTraceResult = {
handled: true,
reply_text: "По позиции Столешница 600*3050*26 дуб ниагара подтвержден покупатель: ООО \\Ромашка\\.",
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: "Столешница 600*3050*26 дуб ниагара",
organization: "ООО Альтернатива Плюс",
as_of_date: "2020-05-31"
},
selected_recipe: "address_inventory_sale_trace_for_item_v1",
anchor_type: "item",
anchor_value_raw: "Столешница 600*3050*26 дуб ниагара",
anchor_value_resolved: "Столешница 600*3050*26 дуб ниагара",
reasons: ["address_action_detected", "address_entity_detected"]
}
} as any;
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: "medium",
extracted_filters: {
item: "Столешница 600*3050*26 дуб ниагара",
organization: "ООО Альтернатива Плюс",
as_of_date: "2020-05-31"
},
selected_recipe: "address_inventory_purchase_provenance_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 provenanceResult;
}
return saleTraceResult;
})
} 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-sale-to-supplier-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-sale-trace-seed",
session_id: sessionId,
role: "assistant",
text: saleTraceResult.reply_text,
reply_type: saleTraceResult.reply_type,
created_at: "2026-04-15T18:00:00.000Z",
trace_id: "address-sale-seed",
debug: saleTraceResult.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_sale_trace_for_item");
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe("ООО Альтернатива Плюс");
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("does not carry VAT previous_intent into a fresh inventory root query", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "прогноз ндс на март 2020";
const secondMessage = "остаток на складе за май 2020";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === firstMessage) {
return {
handled: true,
reply_text: "Прогноз НДС на март 2020 собран.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "vat_payable_forecast",
detected_intent_confidence: "high",
extracted_filters: {
period_from: "2020-03-01",
period_to: "2020-03-31"
},
selected_recipe: "address_vat_payable_forecast_v1"
}
};
}
return {
handled: true,
reply_text: "Нужно уточнить организацию.",
reply_type: "partial_coverage",
response_type: "LIMITED_WITH_REASON",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
period_from: "2020-05-01",
period_to: "2020-05-31",
as_of_date: "2020-05-31"
},
selected_recipe: null,
limited_reason_category: "missing_anchor",
reasons: ["organization_clarification_required", "multiple_known_organizations_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-vat-inventory-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
const second = await service.handleMessage({
session_id: sessionId,
user_message: secondMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(calls.length).toBeGreaterThanOrEqual(2);
const inventoryCalls = calls.slice(1);
expect(inventoryCalls.every((call) => call.message === secondMessage)).toBe(true);
expect(inventoryCalls.every((call) => call.options?.followupContext === undefined)).toBe(true);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short inventory month follow-up after drilldown as continuation of the recent inventory root frame", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "остатки на июль 2019";
const itemLabel = "Четки Пост (84*117)";
const organization = 'ООО "Альтернатива Плюс"';
const inventoryResult = {
handled: true,
reply_text: "Подтвержденный складской срез на 31.07.2019 собран.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
as_of_date: "2019-07-31",
period_from: "2019-07-01",
period_to: "2019-07-31",
organization
},
selected_recipe: "address_inventory_on_hand_as_of_date_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 inventoryResult;
}
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-inventory-root-month-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-inventory-root-seed-july",
session_id: sessionId,
role: "assistant",
text: "inventory root seed",
reply_type: "factual",
created_at: "2026-04-15T19:00:00.000Z",
trace_id: "address-root-seed-july",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31",
organization
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1"
}
} as any);
sessions.appendItem(sessionId, {
message_id: "msg-inventory-drilldown-seed-july",
session_id: sessionId,
role: "assistant",
text: "inventory provenance seed",
reply_type: "factual",
created_at: "2026-04-15T19:01:00.000Z",
trace_id: "address-drilldown-seed-july",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item: itemLabel,
organization
},
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
anchor_type: "item",
anchor_value_raw: itemLabel,
anchor_value_resolved: itemLabel
}
} 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).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined();
expect(calls[0].options?.followupContext?.root_context_only).toBe(true);
expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2020-03-31");
expect(calls[0].options?.followupContext?.current_frame_kind).toBe("inventory_root");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("restores inventory root filters from address_root_frame_context instead of drilldown extracted filters", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "остатки на эту же дату";
const itemLabel = "Рабочая станция универсального специалиста";
const organization = 'ООО "Альтернатива Плюс"';
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "Собран складской срез на дату из root frame.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization,
as_of_date: "2021-03-31",
period_from: "2021-03-01",
period_to: "2021-03-31"
},
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
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-root-frame-authority-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-root-frame-authority-seed",
session_id: sessionId,
role: "assistant",
text: "inventory purchase documents seed",
reply_type: "factual",
created_at: "2026-04-19T08:55:00.000Z",
trace_id: "address-root-frame-authority-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_purchase_documents_for_item",
extracted_filters: {
item: itemLabel,
organization,
as_of_date: "2021-04-15"
},
selected_recipe: "address_inventory_purchase_documents_for_item_v1",
anchor_type: "item",
anchor_value_raw: itemLabel,
anchor_value_resolved: itemLabel,
address_root_frame_context: {
root_intent: "inventory_on_hand_as_of_date",
root_filters: {
organization,
warehouse: "Основной склад",
as_of_date: "2021-03-31",
period_from: "2021-03-01",
period_to: "2021-03-31"
},
root_anchor_type: "organization",
root_anchor_value: organization,
current_frame_kind: "inventory_drilldown"
}
}
} 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].options?.followupContext?.root_context_only).toBe(true);
expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.root_filters?.warehouse).toBe("Основной склад");
expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.root_filters?.period_from).toBe("2021-03-01");
expect(calls[0].options?.followupContext?.root_filters?.period_to).toBe("2021-03-31");
expect(calls[0].options?.followupContext?.root_filters?.as_of_date).not.toBe("2021-04-15");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats colloquial supplier follow-up from an inventory root slice as continuation of the focused item", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "у кого мы модуль прямоугольный купили кстати";
const itemLabel = "Модуль прямоугольый 1400*110*750";
const organization = 'ООО "Альтернатива Плюс"';
const provenanceResult = {
handled: true,
reply_text: `По позиции ${itemLabel} подтвержден поставщик: Торговый дом "Союз".`,
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: itemLabel,
organization,
as_of_date: "2020-05-31"
},
selected_recipe: "address_inventory_purchase_provenance_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 provenanceResult;
}
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-root-item-focus-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-inventory-root-seed",
session_id: sessionId,
role: "assistant",
text: `${itemLabel} был в остатках на 31.05.2020.`,
reply_type: "factual",
created_at: "2026-04-15T19:00:00.000Z",
trace_id: "address-root-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization,
as_of_date: "2020-05-31",
period_from: "2020-05-01",
period_to: "2020-05-31"
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1"
}
} as any);
sessions.setAddressNavigationState(sessionId, {
session_id: sessionId,
session_context: {
active_focus_object: {
object_type: "item",
label: itemLabel
},
organization_scope: organization,
date_scope: {
as_of_date: "2020-05-31",
period_from: "2020-05-01",
period_to: "2020-05-31"
}
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("item");
expect(calls[0].options?.followupContext?.previous_anchor_value).toBe(itemLabel);
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe(itemLabel);
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short 'а нам?' as a receivables mirror follow-up after payables answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "а нам?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return {
handled: true,
reply_text: "На ту же дату нам должны 125 000 руб.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "receivables_confirmed_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
as_of_date: "2026-04-16"
},
selected_recipe: "address_receivables_confirmed_as_of_date_v1",
reasons: ["address_action_detected", "address_followup_context_applied"]
}
};
}
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-a-nam-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-payables-seed",
session_id: sessionId,
role: "assistant",
text: "На сегодня мы должны поставщикам 87 000 руб.",
reply_type: "factual",
created_at: "2026-04-16T10:00:00.000Z",
trace_id: "address-payables-seed",
debug: {
detected_mode: "address_query",
detected_intent: "payables_confirmed_as_of_date",
extracted_filters: {
as_of_date: "2026-04-16"
},
selected_recipe: "address_payables_confirmed_as_of_date_v1"
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(response.debug?.detected_intent).toBe("receivables_confirmed_as_of_date");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date");
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2026-04-16");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps document intent for short counterparty retarget like 'а по свк'", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "а по свк";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "Собран список документов по контрагенту СВК.",
debug: {
...buildAddressLaneResult().debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "свк"
},
detected_intent: "list_documents_by_counterparty",
selected_recipe: "address_documents_by_counterparty_v1",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
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-po-svk-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-docs-seed",
session_id: sessionId,
role: "assistant",
text: "Собран список документов по контрагенту Чапурнов.",
reply_type: "factual",
created_at: "2026-04-16T10:01:00.000Z",
trace_id: "address-docs-seed",
debug: {
detected_mode: "address_query",
detected_intent: "list_documents_by_counterparty",
extracted_filters: {
counterparty: "чапурнов",
sort: "period_desc",
limit: 20
},
selected_recipe: "address_documents_by_counterparty_v1",
anchor_type: "counterparty",
anchor_value_raw: "чапурнов",
anchor_value_resolved: "Чапурнов"
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(response.debug?.detected_intent).toBe("list_documents_by_counterparty");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(calls[0].options?.followupContext?.previous_anchor_value).toBe("Чапурнов");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("does not backfill stale counterparty anchors into inventory root temporal follow-ups", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "остатки на июль 2019";
const organization = 'ООО "Альтернатива Плюс"';
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (options?.followupContext) {
return buildAddressLaneResult({
reply_text: "На 31.07.2019 на складе подтверждено 4 позиции.",
reply_type: "factual",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "inventory_on_hand_as_of_date",
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
extracted_filters: {
organization,
period_from: "2019-07-01",
period_to: "2019-07-31",
as_of_date: "2019-07-31"
},
reasons: ["inventory_on_hand_signal_detected", "address_followup_context_applied"]
}
});
}
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-root-month-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-docs-seed",
session_id: sessionId,
role: "assistant",
text: "Собран список документов по контрагенту СВК.",
reply_type: "factual",
created_at: "2026-04-16T09:55:00.000Z",
trace_id: "address-docs-seed",
debug: {
detected_mode: "address_query",
detected_intent: "list_documents_by_counterparty",
extracted_filters: {
counterparty: "СЃРІРє",
sort: "period_desc",
limit: 20
},
selected_recipe: "address_documents_by_counterparty_v1",
anchor_type: "counterparty",
anchor_value_raw: "СЃРІРє",
anchor_value_resolved: "РЎР’Рљ"
}
} as any);
sessions.appendItem(sessionId, {
message_id: "msg-inventory-root-seed",
session_id: sessionId,
role: "assistant",
text: "На 16.04.2026 на складе подтверждено 11 позиций.",
reply_type: "factual",
created_at: "2026-04-16T10:00:00.000Z",
trace_id: "address-inventory-root-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization,
as_of_date: "2026-04-16"
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
anchor_type: "organization",
anchor_value_raw: organization,
anchor_value_resolved: organization
}
} as any);
sessions.setAddressNavigationState(sessionId, {
session_id: sessionId,
session_context: {
active_result_set_id: "rs-inventory-root",
active_focus_object: null,
last_confirmed_route: "address_inventory_on_hand_as_of_date_v1",
date_scope: {
as_of_date: "2026-04-16",
period_from: null,
period_to: null
},
organization_scope: organization
},
result_sets: [
{
result_set_id: "rs-inventory-root",
type: "inventory_snapshot",
route_id: "address_inventory_on_hand_as_of_date_v1",
filters: {
organization,
as_of_date: "2026-04-16"
},
entity_refs: [],
source_refs: [],
created_from_turn: 2,
created_at: "2026-04-16T10:00:00.000Z"
}
],
navigation_history: [
{
event_id: "nav-inventory-root",
action: "open",
source_result_set_id: null,
target_object_id: null,
derived_result_set_id: "rs-inventory-root",
turn_index: 2,
created_at: "2026-04-16T10:00:00.000Z"
}
]
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].options?.followupContext?.root_context_only).toBe(true);
expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined();
expect(calls[0].options?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.previous_filters?.counterparty).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization);
expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.root_filters?.counterparty).toBeUndefined();
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it.skip("passes grounded MCP discovery payout context into a short year-switch follow-up", async () => {
const followupMessage = "а теперь за 2021?";
const calls: Array<{ message: string; options?: any }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "Подтверждены исходящие платежи по Группа СВК за 2021 год.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "supplier_payouts_profile",
selected_recipe: "address_supplier_payouts_profile_v1",
extracted_filters: {
counterparty: "Группа СВК",
organization: "ООО Альтернатива Плюс",
period_from: "2021-01-01",
period_to: "2021-12-31"
},
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
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-discovery-followup-year-switch-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-discovery-payout-seed",
session_id: sessionId,
role: "assistant",
text: "Подтверждены исходящие платежи по Группа СВК за 2020 год.",
reply_type: "partial_coverage",
created_at: "2026-04-20T10:00:00.000Z",
trace_id: "living-discovery-seed",
debug: {
execution_lane: "living_chat",
mcp_discovery_response_applied: true,
assistant_active_organization: "ООО Альтернатива Плюс",
assistant_mcp_discovery_entry_point_v1: {
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
entry_status: "bridge_executed",
turn_input: {
turn_meaning_ref: {
asked_action_family: "payout",
explicit_entity_candidates: ["Группа СВК"],
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
}
},
bridge: {
bridge_status: "answer_draft_ready",
business_fact_answer_allowed: true,
pilot: {
pilot_scope: "counterparty_supplier_payout_query_movements_v1"
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference"
}
}
}
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("supplier_payouts_profile");
expect(calls[0].options?.followupContext?.previous_discovery_pilot_scope).toBe(
"counterparty_supplier_payout_query_movements_v1"
);
expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(calls[0].options?.followupContext?.previous_anchor_value).toBe("Группа СВК");
expect(calls[0].options?.followupContext?.previous_filters).toMatchObject({
counterparty: "Группа СВК",
organization: "ООО Альтернатива Плюс",
period_from: "2020-01-01",
period_to: "2020-12-31"
});
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
});