1288 lines
53 KiB
TypeScript
1288 lines
53 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import { AssistantService, resolveSessionOrganizationScopeContextForTests } from "../src/services/assistantService";
|
||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||
|
||
describe("assistant living chat mode", () => {
|
||
it("resolves active organization from slang mention using previously discovered organization list", () => {
|
||
const items = [
|
||
{
|
||
role: "assistant",
|
||
text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.",
|
||
debug: {
|
||
trace_id: "chat-org-scope",
|
||
living_chat_data_scope_probe_status: "resolved",
|
||
living_chat_data_scope_probe_organizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"]
|
||
}
|
||
} as any
|
||
];
|
||
|
||
const context = resolveSessionOrganizationScopeContextForTests("га альтернативу тогда обсудим", items as any);
|
||
expect(context.knownOrganizations).toContain("ООО Альтернатива Плюс");
|
||
expect(context.selectedOrganization).toBe("ООО Альтернатива Плюс");
|
||
expect(context.activeOrganization).toBe("ООО Альтернатива Плюс");
|
||
});
|
||
|
||
it("repairs mojibake user text before persisting conversation", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-mojibake-user",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-mojibake-user" },
|
||
outputText: "Привет. Я на связи.",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-mojibake-user",
|
||
user_message: "че там",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(response.conversation?.[0]?.role).toBe("user");
|
||
expect(response.conversation?.[0]?.text).toBe("че там");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("does not treat organization fact lookup as scope-selection confirmation", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-org-fact-boundary",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const sessionId = "asst-living-chat-org-fact-boundary";
|
||
sessions.ensureSession(sessionId);
|
||
sessions.appendItem(sessionId, {
|
||
message_id: "msg-seed-org-scope",
|
||
session_id: sessionId,
|
||
role: "assistant",
|
||
text: "Сейчас доступны организации (3): ООО Альтернатива Плюс, ООО Лайсвуд, РАЙМ. Скажите, по какой организации смотреть данные.",
|
||
reply_type: "factual_with_explanation",
|
||
created_at: new Date().toISOString(),
|
||
trace_id: "chat-seed-org-scope",
|
||
debug: {
|
||
living_chat_data_scope_probe_status: "resolved",
|
||
living_chat_data_scope_probe_organizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"],
|
||
assistant_known_organizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"],
|
||
assistant_active_organization: "ООО Альтернатива Плюс"
|
||
}
|
||
} as any);
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-should-not-run-org-fact-boundary" },
|
||
outputText: "Для ООО Альтернатива Плюс дата регистрации 01.07.2015, возраст 8 лет.",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
const response = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "какой возраст у альтернативы?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_organization_fact_boundary");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("не буду называть");
|
||
expect(String(response.assistant_reply)).not.toContain("01.07.2015");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("keeps organization fact follow-up in deterministic boundary mode on short confirmation", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-org-fact-followup",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const sessionId = "asst-living-chat-org-fact-followup";
|
||
sessions.ensureSession(sessionId);
|
||
sessions.appendItem(sessionId, {
|
||
message_id: "msg-seed-org-fact-boundary",
|
||
session_id: sessionId,
|
||
role: "assistant",
|
||
text: "По организации ООО Альтернатива Плюс не буду называть дату/возраст без live-подтвержденного источника.",
|
||
reply_type: "factual_with_explanation",
|
||
created_at: new Date().toISOString(),
|
||
trace_id: "chat-seed-org-fact-boundary",
|
||
debug: {
|
||
living_chat_response_source: "deterministic_organization_fact_boundary",
|
||
assistant_known_organizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"],
|
||
assistant_active_organization: "ООО Альтернатива Плюс"
|
||
}
|
||
} as any);
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-should-not-run-org-fact-followup" },
|
||
outputText: "Дата регистрации: 2005-12-08",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
const response = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "давай",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_organization_fact_boundary_followup");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("не буду называть");
|
||
expect(String(response.assistant_reply)).not.toContain("2005-12-08");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("handles casual greeting in chat mode without deep-pipeline pass", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-chat-1",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-1" },
|
||
outputText: "Hello. We can chat freely.",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-1",
|
||
user_message: "hello, how are you?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("chat");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||
expect(normalizer.normalize).toHaveBeenCalledTimes(1);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("does not force address lane for unsupported low-confidence predecompose canonical", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
trace_id: "norm-chat-yo",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "yo",
|
||
normalized_fragment_text: "yoft",
|
||
confidence: "low",
|
||
domain_relevance: "in_scope",
|
||
business_scope: "generic_accounting",
|
||
time_scope: { type: "missing", value: null, confidence: "low" },
|
||
candidate_labels: ["ambiguous_human_query"],
|
||
execution_readiness: "clarification_needed",
|
||
route_status: "no_route",
|
||
no_route_reason: "insufficient_specificity"
|
||
}
|
||
]
|
||
},
|
||
validation: { passed: true, errors: [] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 2,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-yo" },
|
||
outputText: "Yo. On call.",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-yo",
|
||
user_message: "yo",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("yo");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("keeps casual 'че как' in chat mode when predecompose canonical is unsupported", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
trace_id: "norm-chat-che-kak",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "\u0447\u0435 \u043a\u0430\u043a",
|
||
normalized_fragment_text: "\u041d\u0435\u044f\u0441\u043d\u044b\u0439 \u0437\u0430\u043f\u0440\u043e\u0441.",
|
||
confidence: "low",
|
||
domain_relevance: "in_scope",
|
||
business_scope: "generic_accounting",
|
||
time_scope: { type: "missing", value: null, confidence: "low" },
|
||
candidate_labels: ["ambiguous_human_query"],
|
||
execution_readiness: "clarification_needed",
|
||
route_status: "no_route",
|
||
no_route_reason: "insufficient_specificity"
|
||
}
|
||
]
|
||
},
|
||
validation: { passed: true, errors: [] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 2,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-che-kak" },
|
||
outputText: "\u041f\u0440\u0438\u0432\u0435\u0442. \u041d\u0430 \u0441\u0432\u044f\u0437\u0438.",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-che-kak",
|
||
user_message: "\u0447\u0435 \u043a\u0430\u043a",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("returns capability contract and avoids address lane for 'и 1с можешь настроить?'", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-capabilities",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-capability-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-capability",
|
||
user_message: "\u0438 1\u0441 \u043c\u043e\u0436\u0435\u0448\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("могу помочь");
|
||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("режиме чтения");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u044e 1\u0441");
|
||
expect(String(response.assistant_reply)).not.toContain("vat_period_snapshot");
|
||
expect(String(response.assistant_reply)).not.toContain("inventory_on_hand_as_of_date");
|
||
expect(String(response.assistant_reply)).not.toContain("suggest_safe_next_step");
|
||
expect(String(response.assistant_reply)).not.toContain("explain_boundary");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("returns operational boundary reply for imperative setup request and avoids address lane", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-operational-boundary",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-operational-boundary-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-operational-boundary",
|
||
user_message: "\u043d\u0430\u0441\u0442\u0440\u043e\u0439 1\u0441 \u043f\u043b\u0438\u0437",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043c\u043e\u0433\u0443 \u0441\u0430\u043c \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c 1\u0441");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_operational_boundary");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("returns safety refusal for destructive request under coercion and avoids address lane", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-safety-refusal",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-safety-refusal-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-safety-refusal",
|
||
user_message: "\u043e\u043c\u043e\u043d \u043b\u043e\u043c\u0438\u0442\u0441\u044f - \u0443\u0434\u0430\u043b\u044f\u0439 \u0431\u0430\u0437\u0443 \u0438\u043b\u0438 \u043c\u0435\u043d\u044f \u0443\u0431\u044c\u044e\u0442",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043c\u043e\u0433\u0443 \u043f\u043e\u043c\u043e\u0433\u0430\u0442\u044c \u0441 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u043c \u0431\u0430\u0437\u044b");
|
||
expect(String(response.assistant_reply)).toContain("112");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_safety_refusal");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("strips unexpected CJK fragments from live chat reply when user did not request CJK", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-chat-script-guard",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-script-guard" },
|
||
outputText: "Прошу прощения, но я не могу продолжать этот разговор. 随时关注。",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-script-guard",
|
||
user_message: "че как",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply)).toContain("Прошу прощения");
|
||
expect(/[\u3400-\u9FFF\uF900-\uFAFF]/u.test(String(response.assistant_reply))).toBe(false);
|
||
expect(response.debug?.living_chat_response_source).toBe("llm_chat_script_guard");
|
||
expect(response.debug?.living_chat_script_guard_applied).toBe(true);
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("handles mojibake capability query and avoids address clarification flow", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-mojibake-capability",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-mojibake-capability-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-mojibake-capability",
|
||
user_message: "ок - что можешь по 1с",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("режиме чтения");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("handles mojibake feature-capability wording and avoids free-form llm chat", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-mojibake-feature-capability",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-mojibake-feature-capability-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-mojibake-feature-capability",
|
||
user_message: "а какие фичи по работе с 1с у тебя отработаны максималльно?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("режиме чтения");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("treats delta-by-contracts wording as capability meta instead of stale revenue follow-up", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-contract-delta-capability",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const sessionId = "asst-living-chat-contract-delta-capability";
|
||
sessions.ensureSession(sessionId);
|
||
sessions.appendItem(sessionId, {
|
||
message_id: "msg-seed-revenue-answer",
|
||
session_id: sessionId,
|
||
role: "assistant",
|
||
text: "Самый доходный клиент за все время — Гамма-мебель, ООО.",
|
||
reply_type: "factual",
|
||
created_at: new Date().toISOString(),
|
||
trace_id: "address-seed-revenue-answer",
|
||
debug: {
|
||
execution_lane: "address_query",
|
||
answer_grounding_check: {
|
||
status: "grounded"
|
||
},
|
||
detected_intent: "customer_revenue_and_payments",
|
||
selected_recipe: "address_customer_revenue_and_payments_v1",
|
||
extracted_filters: {
|
||
period_mode: "all_time"
|
||
}
|
||
}
|
||
} as any);
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-contract-delta-capability-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "ты умеешь считать дельту по договорам?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("дельт");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("договор");
|
||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("самый доходный клиент");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("keeps delta-by-contracts wording in capability meta mode after canonical rewrite", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
trace_id: "norm-contract-delta-capability-rewrite",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: {
|
||
query: "проверить возможность расчета дельты по договорам"
|
||
},
|
||
validation: { passed: true, errors: [] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const sessionId = "asst-living-chat-contract-delta-capability-rewrite";
|
||
sessions.ensureSession(sessionId);
|
||
sessions.appendItem(sessionId, {
|
||
message_id: "msg-seed-vat-answer",
|
||
session_id: sessionId,
|
||
role: "assistant",
|
||
text: "Собран подтвержденный расчет НДС к уплате за февраль 2017.",
|
||
reply_type: "factual",
|
||
created_at: new Date().toISOString(),
|
||
trace_id: "address-seed-vat-answer",
|
||
debug: {
|
||
execution_lane: "address_query",
|
||
answer_grounding_check: {
|
||
status: "grounded"
|
||
},
|
||
detected_intent: "vat_liability_confirmed_for_tax_period",
|
||
selected_recipe: "address_vat_liability_confirmed_tax_period_v1",
|
||
extracted_filters: {
|
||
organization: "ООО Альтернатива Плюс",
|
||
as_of_date: "2017-02-28",
|
||
period_from: "2017-02-01",
|
||
period_to: "2017-02-28"
|
||
}
|
||
}
|
||
} as any);
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-contract-delta-capability-rewrite-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "ты умеешь считать дельту по договорам?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("дельт");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("договор");
|
||
expect(String(response.assistant_reply)).not.toContain("февраль 2017");
|
||
expect(String(response.assistant_reply)).not.toContain("0,00");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("answers historical capability follow-up in current inventory context instead of generic capability contract", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-inventory-history-capability",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const sessionId = "asst-living-chat-inventory-history-capability";
|
||
sessions.ensureSession(sessionId);
|
||
sessions.appendItem(sessionId, {
|
||
message_id: "msg-seed-inventory-slice",
|
||
session_id: sessionId,
|
||
role: "assistant",
|
||
text: "На 15.04.2026 на складе подтверждено 11 позиций.",
|
||
reply_type: "factual",
|
||
created_at: new Date().toISOString(),
|
||
trace_id: "address-seed-inventory-history-capability",
|
||
debug: {
|
||
execution_lane: "address_query",
|
||
answer_grounding_check: {
|
||
status: "grounded"
|
||
},
|
||
detected_intent: "inventory_on_hand_as_of_date",
|
||
capability_id: "confirmed_inventory_on_hand_as_of_date",
|
||
assistant_active_organization: "альтернатива",
|
||
extracted_filters: {
|
||
organization: "альтернатива",
|
||
as_of_date: "2026-04-15"
|
||
},
|
||
address_root_frame_context: {
|
||
root_intent: "inventory_on_hand_as_of_date",
|
||
current_frame_kind: "inventory_root",
|
||
organization: "альтернатива",
|
||
as_of_date: "2026-04-15"
|
||
}
|
||
}
|
||
} as any);
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-inventory-history-capability-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "а исторические данные ты можешь же показать?",
|
||
llmProvider: "local",
|
||
model: "qwen3",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("историческ");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("альтернатив");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("март 2020");
|
||
expect(String(response.assistant_reply)).not.toContain("Что умею по группам");
|
||
expect(response.debug?.tool_gate_reason).toBe("inventory_history_capability_followup_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_inventory_history_capability_contract");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("handles data-scope meta question as deterministic chat contract", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-data-scope-meta",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-data-scope-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-data-scope",
|
||
user_message: "по какой компании мы можем работать?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("организаций");
|
||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("mcp");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
it("handles 'какая база подрублена?' as deterministic data-scope contract", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-data-scope-podrublena",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-data-scope-podrublena-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-data-scope-podrublena",
|
||
user_message: "какая база подрублена?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(String(response.assistant_reply).toLowerCase()).toContain("по какой смотреть данные");
|
||
expect(String(response.assistant_reply).toLowerCase()).not.toContain("read-only");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
it("handles typo data-scope query with misspelled company token as deterministic contract", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-data-scope-typo-company",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-data-scope-typo-company-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-data-scope-typo-company",
|
||
user_message: "подскажи плиз с какой компинией можем поработать?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("handles no-question-mark data-scope phrase with interrogative token as deterministic contract", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-data-scope-no-qmark",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-data-scope-no-qmark-should-not-run" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-data-scope-no-qmark",
|
||
user_message: "каза какой компании подключена к 1с",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("does not misroute contract ranking query to data-scope when canonical text contains 'компании'", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
trace_id: "norm-no-datascope-contract-ranking",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: {
|
||
schema_version: "normalized_query_v2_0_2",
|
||
user_message_raw: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
|
||
message_in_scope: true,
|
||
scope_confidence: "high",
|
||
contains_multiple_tasks: false,
|
||
fragments: [
|
||
{
|
||
fragment_id: "F1",
|
||
raw_fragment_text: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
|
||
normalized_fragment_text: "Какой самый крупный договор в истории компании?",
|
||
domain_relevance: "in_scope",
|
||
business_scope: "company_specific_accounting",
|
||
entity_hints: [],
|
||
account_hints: [],
|
||
document_hints: ["договор"],
|
||
register_hints: [],
|
||
time_scope: {
|
||
type: "explicit",
|
||
value: "all_time",
|
||
confidence: "medium"
|
||
},
|
||
flags: {
|
||
has_multi_entity_scope: false,
|
||
asks_for_chain_explanation: false,
|
||
asks_for_ranking_or_top: true,
|
||
asks_for_period_summary: false,
|
||
asks_for_rule_check: false,
|
||
asks_for_anomaly_scan: false,
|
||
asks_for_exact_object_trace: false,
|
||
asks_for_evidence: false,
|
||
mentions_period_close_context: false
|
||
},
|
||
candidate_labels: ["simple_factual"],
|
||
confidence: "medium",
|
||
execution_readiness: "executable",
|
||
clarification_reason: null,
|
||
soft_assumption_used: [],
|
||
route_status: "routed",
|
||
no_route_reason: null
|
||
}
|
||
],
|
||
discarded_fragments: [],
|
||
global_notes: {
|
||
needs_clarification: false,
|
||
clarification_reason: null
|
||
}
|
||
},
|
||
validation: { passed: true, errors: [] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||
latency_ms: 10,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({
|
||
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: "contract_usage_and_value",
|
||
detected_intent_confidence: "high",
|
||
extracted_filters: { sort: "period_desc", limit: 20 },
|
||
missing_required_filters: [],
|
||
selected_recipe: "address_contract_usage_and_value_v1",
|
||
mcp_call_status_legacy: "matched_non_empty",
|
||
account_scope_mode: "preferred",
|
||
account_scope_fallback_applied: false,
|
||
anchor_type: "unknown",
|
||
anchor_value_raw: null,
|
||
anchor_value_resolved: null,
|
||
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: 20,
|
||
rows_after_recipe_filter: 20,
|
||
rows_materialized: 20,
|
||
rows_matched: 20,
|
||
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: ["contract_usage_and_value_signal_detected"]
|
||
}
|
||
})
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-should-not-run-for-contract-ranking" },
|
||
outputText: "unused",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
|
||
const response = await service.handleMessage({
|
||
session_id: "asst-living-chat-no-datascope-contract-ranking",
|
||
user_message: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual");
|
||
expect(response.debug?.tool_gate_reason).not.toBe("assistant_data_scope_query_detected");
|
||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(1);
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||
});
|
||
it("does not treat short emotional reaction as organization-selection confirmation", async () => {
|
||
const normalizer = {
|
||
normalize: vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
trace_id: "norm-chat-affective-reaction",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
schema_version: "v2_0_2",
|
||
normalized: null,
|
||
validation: { passed: false, errors: ["mock"] },
|
||
route_hint_summary: null,
|
||
raw_model_output: {},
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: 1,
|
||
request_count_for_case: 1
|
||
})
|
||
} as any;
|
||
|
||
const sessions = new AssistantSessionStore();
|
||
const sessionId = "asst-living-chat-affective-reaction";
|
||
sessions.ensureSession(sessionId);
|
||
sessions.appendItem(sessionId, {
|
||
message_id: "msg-seed-selected-org",
|
||
session_id: sessionId,
|
||
role: "assistant",
|
||
text: "Отлично, фиксирую рабочую организацию: ООО Альтернатива Плюс.",
|
||
reply_type: "factual_with_explanation",
|
||
created_at: new Date().toISOString(),
|
||
trace_id: "chat-seed-selected-org",
|
||
debug: {
|
||
assistant_known_organizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд"],
|
||
assistant_selected_organization: "ООО Альтернатива Плюс",
|
||
assistant_active_organization: "ООО Альтернатива Плюс"
|
||
}
|
||
} as any);
|
||
|
||
const addressQueryService = {
|
||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||
} as any;
|
||
const chatClient = {
|
||
chat: vi.fn().mockResolvedValue({
|
||
raw: { id: "chat-affective-reaction" },
|
||
outputText: "Понимаю, это выглядит неприятно. Давай разберем следующий шаг.",
|
||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||
})
|
||
} as any;
|
||
|
||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||
const response = await service.handleMessage({
|
||
session_id: sessionId,
|
||
user_message: "ну ахуеть",
|
||
llmProvider: "local",
|
||
model: "qwen2.5",
|
||
useMock: false
|
||
} as any);
|
||
|
||
expect(response.ok).toBe(true);
|
||
expect(response.reply_type).toBe("factual_with_explanation");
|
||
expect(response.debug?.living_chat_response_source).not.toBe("deterministic_data_scope_selection_contract");
|
||
expect(String(response.assistant_reply)).not.toContain("фиксирую рабочую организацию");
|
||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|