3566 lines
142 KiB
TypeScript
3566 lines
142 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import { AssistantService } from "../src/services/assistantService";
|
||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||
|
||
function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
|
||
return {
|
||
handled: true,
|
||
reply_text: "Собран список документов по контрагенту.",
|
||
reply_type: "factual",
|
||
response_type: "FACTUAL_LIST",
|
||
debug: {
|
||
detected_mode: "address_query",
|
||
detected_mode_confidence: "high",
|
||
query_shape: "DOCUMENT_LIST",
|
||
query_shape_confidence: "medium",
|
||
detected_intent: "list_documents_by_counterparty",
|
||
detected_intent_confidence: "medium",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
counterparty: "свк"
|
||
},
|
||
missing_required_filters: [],
|
||
selected_recipe: "address_documents_by_counterparty_v1",
|
||
mcp_call_status_legacy: "matched_non_empty",
|
||
account_scope_mode: "preferred",
|
||
account_scope_fallback_applied: false,
|
||
anchor_type: "counterparty",
|
||
anchor_value_raw: "свк",
|
||
anchor_value_resolved: "Группа СВК",
|
||
resolver_confidence: "medium",
|
||
ambiguity_count: 0,
|
||
match_failure_stage: "none",
|
||
match_failure_reason: null,
|
||
mcp_call_status: "matched_non_empty",
|
||
rows_fetched: 20,
|
||
raw_rows_received: 20,
|
||
rows_after_account_scope: 5,
|
||
rows_after_recipe_filter: 3,
|
||
rows_materialized: 5,
|
||
rows_matched: 3,
|
||
raw_row_keys_sample: [],
|
||
materialization_drop_reason: "none",
|
||
account_token_raw: null,
|
||
account_token_normalized: null,
|
||
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
|
||
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
|
||
account_scope_drop_reason: "not_applicable",
|
||
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||
limited_reason_category: null,
|
||
response_type: "FACTUAL_LIST",
|
||
limitations: [],
|
||
reasons: ["address_action_detected", "address_entity_detected"]
|
||
},
|
||
...(overrides ?? {})
|
||
};
|
||
}
|
||
|
||
function buildAddressLimitedLaneResult(
|
||
category: "missing_anchor" | "empty_match" = "missing_anchor",
|
||
overrides?: Record<string, unknown>
|
||
): any {
|
||
const base = buildAddressLaneResult();
|
||
return {
|
||
...base,
|
||
reply_text: "Нужны уточнения по якорю.",
|
||
reply_type: "partial_coverage",
|
||
response_type: "LIMITED_WITH_REASON",
|
||
debug: {
|
||
...base.debug,
|
||
response_type: "LIMITED_WITH_REASON",
|
||
limited_reason_category: category,
|
||
reasons: ["address_action_detected", "address_entity_detected"]
|
||
},
|
||
...(overrides ?? {})
|
||
};
|
||
}
|
||
|
||
describe("assistant address follow-up carryover", () => {
|
||
it("keeps short follow-up in address lane by reusing previous anchor context", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === "а за все время?" && !options?.followupContext) {
|
||
return null;
|
||
}
|
||
if (message === "а за все время?" && options?.followupContext) {
|
||
return buildAddressLaneResult({
|
||
reply_text: "Собран список документов по контрагенту за все время.",
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||
}
|
||
});
|
||
}
|
||
return buildAddressLaneResult();
|
||
})
|
||
} as any;
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||
reply_type: "partial_coverage",
|
||
debug: {}
|
||
}))
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const service = new AssistantService(
|
||
normalizerService,
|
||
sessions as any,
|
||
{} as any,
|
||
{ persistSession: vi.fn() } as any,
|
||
addressQueryService
|
||
);
|
||
|
||
const sessionId = `asst-address-followup-${Date.now()}`;
|
||
const first = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "покажи документы по свк за 2020",
|
||
useMock: true
|
||
} as any);
|
||
expect(first.ok).toBe(true);
|
||
expect(first.reply_type).toBe("factual");
|
||
|
||
const second = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "а за все время?",
|
||
useMock: true
|
||
} as any);
|
||
|
||
expect(second.ok).toBe(true);
|
||
expect(["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("continues the original inventory query after replacement-damaged organization clarification", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage = "кайф - что там на складе по остаткам?";
|
||
const secondMessage = "АЛЬТЕРНАТ\uFFFD?ВА";
|
||
const repairedSecondMessage = "\u0410\u041b\u042c\u0422\u0415\u0420\u041d\u0410\u0422\u0418\u0412\u0410";
|
||
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 || message === repairedSecondMessage) &&
|
||
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-damaged-${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(repairedSecondMessage);
|
||
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();
|
||
});
|
||
});
|
||
|