676 lines
25 KiB
TypeScript
676 lines
25 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import { AssistantService } from "../src/services/assistantService";
|
||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||
|
||
function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
|
||
return {
|
||
handled: true,
|
||
reply_text: "Собран список документов по контрагенту.",
|
||
reply_type: "factual",
|
||
response_type: "FACTUAL_LIST",
|
||
debug: {
|
||
detected_mode: "address_query",
|
||
detected_mode_confidence: "high",
|
||
query_shape: "DOCUMENT_LIST",
|
||
query_shape_confidence: "medium",
|
||
detected_intent: "list_documents_by_counterparty",
|
||
detected_intent_confidence: "medium",
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
counterparty: "свк"
|
||
},
|
||
missing_required_filters: [],
|
||
selected_recipe: "address_documents_by_counterparty_v1",
|
||
mcp_call_status_legacy: "matched_non_empty",
|
||
account_scope_mode: "preferred",
|
||
account_scope_fallback_applied: false,
|
||
anchor_type: "counterparty",
|
||
anchor_value_raw: "свк",
|
||
anchor_value_resolved: "Группа СВК",
|
||
resolver_confidence: "medium",
|
||
ambiguity_count: 0,
|
||
match_failure_stage: "none",
|
||
match_failure_reason: null,
|
||
mcp_call_status: "matched_non_empty",
|
||
rows_fetched: 20,
|
||
raw_rows_received: 20,
|
||
rows_after_account_scope: 5,
|
||
rows_after_recipe_filter: 3,
|
||
rows_materialized: 5,
|
||
rows_matched: 3,
|
||
raw_row_keys_sample: [],
|
||
materialization_drop_reason: "none",
|
||
account_token_raw: null,
|
||
account_token_normalized: null,
|
||
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
|
||
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
|
||
account_scope_drop_reason: "not_applicable",
|
||
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||
limited_reason_category: null,
|
||
response_type: "FACTUAL_LIST",
|
||
limitations: [],
|
||
reasons: ["address_action_detected", "address_entity_detected"]
|
||
},
|
||
...(overrides ?? {})
|
||
};
|
||
}
|
||
|
||
function buildAddressLimitedLaneResult(
|
||
category: "missing_anchor" | "empty_match" = "missing_anchor",
|
||
overrides?: Record<string, unknown>
|
||
): any {
|
||
const base = buildAddressLaneResult();
|
||
return {
|
||
...base,
|
||
reply_text: "Нужны уточнения по якорю.",
|
||
reply_type: "partial_coverage",
|
||
response_type: "LIMITED_WITH_REASON",
|
||
debug: {
|
||
...base.debug,
|
||
response_type: "LIMITED_WITH_REASON",
|
||
limited_reason_category: category,
|
||
reasons: ["address_action_detected", "address_entity_detected"]
|
||
},
|
||
...(overrides ?? {})
|
||
};
|
||
}
|
||
|
||
describe("assistant address follow-up carryover", () => {
|
||
it("keeps short follow-up in address lane by reusing previous anchor context", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === "а за все время?" && !options?.followupContext) {
|
||
return null;
|
||
}
|
||
if (message === "а за все время?" && options?.followupContext) {
|
||
return buildAddressLaneResult({
|
||
reply_text: "Собран список документов по контрагенту за все время.",
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||
}
|
||
});
|
||
}
|
||
return buildAddressLaneResult();
|
||
})
|
||
} as any;
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||
reply_type: "partial_coverage",
|
||
debug: {}
|
||
}))
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const service = new AssistantService(
|
||
normalizerService,
|
||
sessions as any,
|
||
{} as any,
|
||
{ persistSession: vi.fn() } as any,
|
||
addressQueryService
|
||
);
|
||
|
||
const sessionId = `asst-address-followup-${Date.now()}`;
|
||
const first = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "покажи документы по свк за 2020",
|
||
useMock: true
|
||
} as any);
|
||
expect(first.ok).toBe(true);
|
||
expect(first.reply_type).toBe("factual");
|
||
|
||
const second = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "а за все время?",
|
||
useMock: true
|
||
} as any);
|
||
|
||
expect(second.ok).toBe(true);
|
||
expect(second.reply_type).toBe("factual");
|
||
expect(second.debug?.detected_mode).toBe("address_query");
|
||
expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty");
|
||
expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
|
||
expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied");
|
||
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls[0].message.toLowerCase()).toContain("свк");
|
||
expect(calls[1].message).toBe("а за все время?");
|
||
expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
|
||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||
expect(calls[1].options?.followupContext?.previous_anchor_value).toBe("Группа СВК");
|
||
expect(calls[1].options?.followupContext?.previous_filters?.counterparty).toBe("свк");
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("treats short 'по <anchor> также' phrase as follow-up for address lane", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020";
|
||
const followupMessage = "\u043f\u043e \u0441\u0432\u043a \u0442\u0430\u043a\u0436\u0435 \u043f\u043b\u0438\u0437";
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === followupMessage && !options?.followupContext) {
|
||
return null;
|
||
}
|
||
if (message === followupMessage && options?.followupContext) {
|
||
return buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||
}
|
||
});
|
||
}
|
||
return buildAddressLaneResult();
|
||
})
|
||
} as any;
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||
reply_type: "partial_coverage",
|
||
debug: {}
|
||
}))
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const service = new AssistantService(
|
||
normalizerService,
|
||
sessions as any,
|
||
{} as any,
|
||
{ persistSession: vi.fn() } as any,
|
||
addressQueryService
|
||
);
|
||
|
||
const sessionId = `asst-address-followup-short-${Date.now()}`;
|
||
const first = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: firstMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(first.ok).toBe(true);
|
||
expect(first.reply_type).toBe("factual");
|
||
|
||
const second = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: followupMessage,
|
||
useMock: true
|
||
} as any);
|
||
|
||
expect(second.ok).toBe(true);
|
||
expect(second.reply_type).toBe("factual");
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls[1].message).toBe(followupMessage);
|
||
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
|
||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||
expect(typeof calls[1].options?.followupContext?.previous_anchor_value).toBe("string");
|
||
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("treats short affirmative 'давай' as follow-up for previous address answer", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020";
|
||
const followupMessage = "\u0434\u0430\u0432\u0430\u0439";
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === followupMessage && !options?.followupContext) {
|
||
return null;
|
||
}
|
||
if (message === followupMessage && options?.followupContext) {
|
||
return buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||
}
|
||
});
|
||
}
|
||
return buildAddressLaneResult();
|
||
})
|
||
} as any;
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||
reply_type: "partial_coverage",
|
||
debug: {}
|
||
}))
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const service = new AssistantService(
|
||
normalizerService,
|
||
sessions as any,
|
||
{} as any,
|
||
{ persistSession: vi.fn() } as any,
|
||
addressQueryService
|
||
);
|
||
|
||
const sessionId = `asst-address-followup-davai-${Date.now()}`;
|
||
const first = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: firstMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(first.ok).toBe(true);
|
||
expect(first.reply_type).toBe("factual");
|
||
|
||
const second = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: followupMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(second.ok).toBe(true);
|
||
expect(second.reply_type).toBe("factual");
|
||
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls[1].message).toBe(followupMessage);
|
||
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
|
||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage = "покажи документы по свк за 2020";
|
||
const followupMessage = "показывыай";
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === followupMessage && !options?.followupContext) {
|
||
return null;
|
||
}
|
||
if (message === followupMessage && options?.followupContext) {
|
||
return buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||
}
|
||
});
|
||
}
|
||
return buildAddressLaneResult();
|
||
})
|
||
} as any;
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||
reply_type: "partial_coverage",
|
||
debug: {}
|
||
}))
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const service = new AssistantService(
|
||
normalizerService,
|
||
sessions as any,
|
||
{} as any,
|
||
{ persistSession: vi.fn() } as any,
|
||
addressQueryService
|
||
);
|
||
|
||
const sessionId = `asst-address-followup-pokazyvai-${Date.now()}`;
|
||
const first = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: firstMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(first.ok).toBe(true);
|
||
expect(first.reply_type).toBe("factual");
|
||
|
||
const second = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: followupMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(second.ok).toBe(true);
|
||
expect(second.reply_type).toBe("factual");
|
||
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls[1].message).toBe(followupMessage);
|
||
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
|
||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps previous counterparty context for referential follow-up 'кроме этого документа...'", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage = "покажи документы по жуковке 51";
|
||
const followupMessage = "кроме этого документа есть еще чтото?";
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === followupMessage && !options?.followupContext) {
|
||
return null;
|
||
}
|
||
if (message === followupMessage && options?.followupContext) {
|
||
return buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
anchor_value_raw: "кроме",
|
||
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
|
||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||
}
|
||
});
|
||
}
|
||
return buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
counterparty: "жуковке 51"
|
||
},
|
||
anchor_value_raw: "жуковке 51",
|
||
anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
|
||
}
|
||
});
|
||
})
|
||
} as any;
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||
reply_type: "partial_coverage",
|
||
debug: {}
|
||
}))
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const service = new AssistantService(
|
||
normalizerService,
|
||
sessions as any,
|
||
{} as any,
|
||
{ persistSession: vi.fn() } as any,
|
||
addressQueryService
|
||
);
|
||
|
||
const sessionId = `asst-address-followup-referential-${Date.now()}`;
|
||
const first = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: firstMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(first.ok).toBe(true);
|
||
expect(first.reply_type).toBe("factual");
|
||
|
||
const second = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: followupMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(second.ok).toBe(true);
|
||
expect(second.reply_type).toBe("factual");
|
||
|
||
expect(calls).toHaveLength(2);
|
||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "")).toContain("Жуковка 51");
|
||
expect(String(calls[1].options?.followupContext?.previous_filters?.counterparty ?? "")).toContain("жуковке 51");
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("retries with raw user message after rewrite degraded anchor and returns factual follow-up result", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const firstMessage = "покажи документы по жуковке 51";
|
||
const followupMessage = "кроме этого документа есть еще чтото?";
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
const compact = String(message ?? "").trim().toLowerCase();
|
||
if (calls.length === 1) {
|
||
return buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
counterparty: "жуковке 51"
|
||
},
|
||
anchor_value_raw: "жуковке 51",
|
||
anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
|
||
}
|
||
});
|
||
}
|
||
if (compact === followupMessage && options?.followupContext) {
|
||
return buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
counterparty: "жуковке 51"
|
||
},
|
||
anchor_value_raw: "жуковке 51",
|
||
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
|
||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||
}
|
||
});
|
||
}
|
||
if (compact.startsWith("документы по контрагенту") && options?.followupContext) {
|
||
return buildAddressLimitedLaneResult("missing_anchor", {
|
||
debug: {
|
||
...buildAddressLimitedLaneResult("missing_anchor").debug,
|
||
extracted_filters: {
|
||
sort: "period_desc",
|
||
limit: 20,
|
||
counterparty: "кроме"
|
||
},
|
||
anchor_value_raw: "кроме",
|
||
anchor_value_resolved: "кроме"
|
||
}
|
||
});
|
||
}
|
||
return null;
|
||
})
|
||
} as any;
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async () => ({
|
||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||
reply_type: "partial_coverage",
|
||
debug: {}
|
||
}))
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const service = new AssistantService(
|
||
normalizerService,
|
||
sessions as any,
|
||
{} as any,
|
||
{ persistSession: vi.fn() } as any,
|
||
addressQueryService
|
||
);
|
||
|
||
const sessionId = `asst-address-followup-safe-retry-${Date.now()}`;
|
||
const first = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: firstMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(first.ok).toBe(true);
|
||
expect(first.reply_type).toBe("factual");
|
||
|
||
const second = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: followupMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(second.ok).toBe(true);
|
||
expect(second.reply_type).toBe("factual");
|
||
expect(second.debug?.address_retry_audit?.attempted).toBe(true);
|
||
expect(second.debug?.address_retry_audit?.initial_limited_category).toBe("missing_anchor");
|
||
expect(second.debug?.address_retry_audit?.retry_message).toBe(followupMessage);
|
||
|
||
expect(calls.some((entry) => String(entry.message).toLowerCase().startsWith("документы по контрагенту"))).toBe(true);
|
||
expect(calls.some((entry) => String(entry.message).toLowerCase() === followupMessage)).toBe(true);
|
||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("reuses last real address context after intermediate clarification fallback", async () => {
|
||
const calls: Array<{ message: string; options?: any }> = [];
|
||
const lifecycleFollowupMessage = "А кто из них новые?";
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||
calls.push({ message, options });
|
||
if (message === "что там не так?") {
|
||
return null;
|
||
}
|
||
if (message === lifecycleFollowupMessage && !options?.followupContext) {
|
||
return null;
|
||
}
|
||
if (message === lifecycleFollowupMessage && options?.followupContext) {
|
||
return buildAddressLaneResult({
|
||
debug: {
|
||
...buildAddressLaneResult().debug,
|
||
detected_intent: "counterparty_activity_lifecycle",
|
||
extracted_filters: {
|
||
period_from: "2020-01-01",
|
||
period_to: "2020-12-31"
|
||
},
|
||
reasons: ["address_action_detected", "address_followup_context_applied"]
|
||
}
|
||
});
|
||
}
|
||
return buildAddressLaneResult();
|
||
})
|
||
} as any;
|
||
|
||
const normalizerService = {
|
||
normalize: vi.fn(async (payload: any) => ({
|
||
assistant_reply: payload?.userQuestion === "что там не так?" ? "Нужно уточнение по фокусу." : "unexpected_normalizer_call",
|
||
reply_type: payload?.userQuestion === "что там не так?" ? "clarification_required" : "partial_coverage",
|
||
debug: {
|
||
prompt_version: "address_query_runtime_v1",
|
||
detected_mode: null,
|
||
detected_intent: null
|
||
}
|
||
}))
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const service = new AssistantService(
|
||
normalizerService,
|
||
sessions as any,
|
||
{} as any,
|
||
{ persistSession: vi.fn() } as any,
|
||
addressQueryService
|
||
);
|
||
|
||
const sessionId = `asst-address-followup-clar-${Date.now()}`;
|
||
const first = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "покажи документы по свк за 2020",
|
||
useMock: true
|
||
} as any);
|
||
expect(first.ok).toBe(true);
|
||
expect(first.reply_type).toBe("factual");
|
||
|
||
const second = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "что там не так?",
|
||
useMock: true
|
||
} as any);
|
||
expect(second.ok).toBe(true);
|
||
expect(second.reply_type).toBe("clarification_required");
|
||
|
||
const third = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: lifecycleFollowupMessage,
|
||
useMock: true
|
||
} as any);
|
||
expect(third.ok).toBe(true);
|
||
expect(third.reply_type).toBe("factual");
|
||
expect(third.debug?.detected_mode).toBe("address_query");
|
||
expect(third.debug?.detected_intent).toBe("counterparty_activity_lifecycle");
|
||
|
||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||
const contextualCall = calls.find((item) => item.message === lifecycleFollowupMessage && Boolean(item.options?.followupContext));
|
||
expect(contextualCall).toBeTruthy();
|
||
expect(contextualCall?.options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
|
||
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||
expect(String(contextualCall?.options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||
expect(normalizerService.normalize).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("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);
|
||
});
|
||
});
|