АРЧ - Ассистент: отделить reference-срез от execution-окна в ответах по lifecycle follow-up
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { executeAddressMcpQueryMock } = vi.hoisted(() => ({
|
||||
executeAddressMcpQueryMock: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../src/services/addressMcpClient", async () => {
|
||||
const actual = await vi.importActual<typeof import("../src/services/addressMcpClient")>(
|
||||
"../src/services/addressMcpClient"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
executeAddressMcpQuery: executeAddressMcpQueryMock
|
||||
};
|
||||
});
|
||||
|
||||
import { AddressQueryService } from "../src/services/addressQueryService";
|
||||
|
||||
afterEach(() => {
|
||||
executeAddressMcpQueryMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("inventory organization scope grounding", () => {
|
||||
it("asks for organization clarification when multiple known companies exist", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи остатки по складу", {
|
||||
knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд"]
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
|
||||
expect(result?.debug.limited_reason_category).toBe("missing_anchor");
|
||||
expect(result?.debug.organization_candidates).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд"]);
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО Альтернатива Плюс");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО Лайсвуд");
|
||||
expect(executeAddressMcpQueryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-selects the only known organization for inventory root queries", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2026-04-15T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 6490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Пуф арий",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО Альтернатива Плюс"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи остатки по складу", {
|
||||
knownOrganizations: ["ООО Альтернатива Плюс"]
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.extracted_filters?.organization).toBe("ООО Альтернатива Плюс");
|
||||
expect(result?.debug.reasons).toContain("organization_auto_selected_from_single_scope_candidate");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("grounds organization from observed rows when the result belongs to a single company", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2020-05-31T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 13490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Кресло орион",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи остатки по складу");
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.extracted_filters?.organization).toBe("ООО \\Альтернатива Плюс\\");
|
||||
expect(result?.debug.reasons).toContain("organization_grounded_from_observed_rows");
|
||||
});
|
||||
|
||||
it("asks for organization clarification when observed rows contain multiple companies", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 2,
|
||||
matched_rows: 2,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2020-05-31T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 6490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Пуф арий",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО Альтернатива Плюс"
|
||||
},
|
||||
{
|
||||
Period: "2020-05-31T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 34490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Диван трехместный",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО Лайсвуд"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи остатки по складу");
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
|
||||
expect(result?.debug.limited_reason_category).toBe("missing_anchor");
|
||||
expect(result?.debug.organization_candidates).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд"]);
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО Альтернатива Плюс");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО Лайсвуд");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -292,8 +292,8 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Конструкция трансформер рабочей станции 1300*900*2000");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-06-30");
|
||||
expect(result?.debug.extracted_filters?.period_from).toBe("2020-06-01");
|
||||
expect(result?.debug.extracted_filters?.period_to).toBe("2020-06-30");
|
||||
expect(result?.debug.extracted_filters?.period_from).toBeUndefined();
|
||||
expect(result?.debug.extracted_filters?.period_to).toBeUndefined();
|
||||
expect(result?.debug.capability_id).toBe("inventory_inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.capability_route_mode).toBe("exact");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\");
|
||||
@@ -491,6 +491,107 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(String(result?.reply_text ?? "")).toContain("Документы выбытия");
|
||||
});
|
||||
|
||||
it("routes selected-object wording 'куда мы продали эту позицию' into sale trace instead of replaying stock slice", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2020-06-18T00:00:00Z",
|
||||
Registrator: "Реализация товаров и услуг 00000000131 от 18.06.2020 0:00:00",
|
||||
AccountDt: "90.02",
|
||||
AccountKt: "41.01",
|
||||
Amount: 6490,
|
||||
SubcontoKt1: "Пуф арий",
|
||||
SubcontoKt3: "Основной склад",
|
||||
SubcontoDt1: "ООО \\Ромашка\\",
|
||||
SubcontoDt2: "Договор реализации № 14 от 17.06.2020",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle('По выбранному объекту "Пуф арий": куда мы продали эту позицию', {
|
||||
followupContext: {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2020-05-31",
|
||||
period_from: "2020-05-01",
|
||||
period_to: "2020-05-31"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
}
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item");
|
||||
expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Пуф арий");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-05-31");
|
||||
expect(result?.debug.reasons).toContain("inventory_selected_object_sale_trace_signal_detected");
|
||||
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ООО \\Ромашка\\");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Документы выбытия");
|
||||
});
|
||||
|
||||
it("detaches snapshot date from execution query during sale-trace history recovery", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2025-10-12T00:00:00Z",
|
||||
Registrator: "Реализация товаров и услуг 00000000421 от 12.10.2025 0:00:00",
|
||||
AccountDt: "90.02",
|
||||
AccountKt: "41.01",
|
||||
Amount: 165.83,
|
||||
SubcontoKt1: "Кромка с клеем 33 дуб ниагара 137 м",
|
||||
SubcontoKt3: "Основной склад",
|
||||
SubcontoDt1: "ООО \\Покупатель\\",
|
||||
SubcontoDt2: "Договор реализации № 55 от 01.10.2025",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle(
|
||||
'По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": куда в итоге продали эту позицию?',
|
||||
{
|
||||
followupContext: {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2019-03-31",
|
||||
period_from: "2019-03-01",
|
||||
period_to: "2019-03-31",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item");
|
||||
expect(result?.debug.reasons).toContain("lifecycle_execution_detached_from_snapshot_date");
|
||||
expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(result?.debug.limitations).toContain("lifecycle_execution_detached_from_snapshot_date");
|
||||
expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
|
||||
expect(query).not.toContain("2019-03-31");
|
||||
expect(query).not.toContain("2019-03-01");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
|
||||
});
|
||||
|
||||
it("matches sale-trace item anchors from subconto fields when the item is not materialized explicitly", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
@@ -522,16 +623,8 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(String(result?.reply_text ?? "")).not.toContain("совпадений не нашлось");
|
||||
});
|
||||
|
||||
it("clears carried as-of date during history recovery for selected-object provenance after dated stock slice", async () => {
|
||||
executeAddressMcpQueryMock
|
||||
.mockResolvedValueOnce({
|
||||
fetched_rows: 0,
|
||||
matched_rows: 0,
|
||||
raw_rows: [],
|
||||
rows: [],
|
||||
error: null
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
it("detaches snapshot date from execution query for selected-object provenance after dated stock slice", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
@@ -572,13 +665,18 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Кресло орион");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
|
||||
expect(result?.debug.extracted_filters?.period_from).toBe("2020-03-01");
|
||||
expect(result?.debug.extracted_filters?.period_to).toBe("2020-03-31");
|
||||
expect(result?.debug.extracted_filters?.period_from).toBeUndefined();
|
||||
expect(result?.debug.extracted_filters?.period_to).toBeUndefined();
|
||||
expect(result?.debug.reasons).toContain("lifecycle_execution_detached_from_snapshot_date");
|
||||
expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(result?.debug.reasons).toContain("period_window_auto_broadened_to_available_data");
|
||||
expect(result?.debug.limitations).toContain("lifecycle_execution_detached_from_snapshot_date");
|
||||
expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
|
||||
expect(query).not.toContain("2020-03-31");
|
||||
expect(query).not.toContain("2020-03-01");
|
||||
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК Организация");
|
||||
expect(query).toContain('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,4 +144,41 @@ describe("address navigation state", () => {
|
||||
expect(evolved.session_context.active_focus_object?.label).toBe("Диван трехместный");
|
||||
expect(evolved.session_context.active_focus_object?.provenance_result_set_id).toBe("rs-msg-a3");
|
||||
});
|
||||
it("derives single organization scope from inventory answer text when filters omit organization", () => {
|
||||
const base = createEmptyAddressNavigationState("asst-5", "2026-04-12T10:00:00.000Z");
|
||||
const assistantItem = {
|
||||
message_id: "msg-a4",
|
||||
session_id: "asst-5",
|
||||
role: "assistant",
|
||||
text: [
|
||||
"На 31.05.2020 на складе подтверждено 1 позиция.",
|
||||
"1. Пуф арий | склад: Основной склад | количество: 1,000 | стоимость: 6.490,00 ₽ | организация: ООО Альтернатива Плюс | дата строки: 2020-05-31T23:59:59Z"
|
||||
].join("\n"),
|
||||
reply_type: "factual",
|
||||
created_at: "2026-04-12T10:04:00.000Z",
|
||||
trace_id: "address-790",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
extracted_filters: {
|
||||
as_of_date: "2020-05-31"
|
||||
},
|
||||
anchor_type: "unknown",
|
||||
anchor_value_resolved: null,
|
||||
anchor_value_raw: null,
|
||||
dialog_continuation_contract_v2: {
|
||||
decision: "new_topic"
|
||||
}
|
||||
}
|
||||
} as any;
|
||||
|
||||
const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 4);
|
||||
expect(evolved.result_sets[0]?.type).toBe("inventory_snapshot");
|
||||
expect(evolved.result_sets[0]?.filters.organization).toBe("ООО Альтернатива Плюс");
|
||||
expect(evolved.result_sets[0]?.entity_refs[0]?.entity_type).toBe("item");
|
||||
expect(evolved.result_sets[0]?.entity_refs[0]?.value).toBe("Пуф арий");
|
||||
expect(evolved.session_context.organization_scope).toBe("ООО Альтернатива Плюс");
|
||||
expect(evolved.session_context.active_focus_object).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1857,5 +1857,94 @@ describe("assistant address follow-up carryover", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -237,4 +237,71 @@ describe("assistant address orchestration runtime adapter", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers raw selected-object sale-destination wording over generic canonical drift intent", async () => {
|
||||
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
|
||||
followupContext: {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2020-05-31",
|
||||
period_from: "2020-05-01",
|
||||
period_to: "2020-05-31"
|
||||
}
|
||||
}
|
||||
}));
|
||||
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
|
||||
runAddressLane: true,
|
||||
livingMode: "address_data",
|
||||
livingReason: "address_lane_triggered",
|
||||
toolGateDecision: "run_address_lane",
|
||||
toolGateReason: "address_mode_classifier_detected",
|
||||
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
|
||||
}));
|
||||
const buildAddressLlmPredecomposeContractV1 = vi.fn(({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({
|
||||
schema_version: "address_llm_predecompose_contract_v1",
|
||||
source_message: sourceMessage,
|
||||
canonical_message: canonicalMessage,
|
||||
mode: "address_query",
|
||||
intent: "unknown"
|
||||
}));
|
||||
|
||||
const rawMessage = 'По выбранному объекту "Пуф арий": куда мы продали эту позицию';
|
||||
|
||||
const output = await buildAssistantAddressOrchestrationRuntime(
|
||||
buildInput({
|
||||
userMessage: rawMessage,
|
||||
runAddressLlmPreDecompose: vi.fn(async () => ({
|
||||
attempted: true,
|
||||
applied: true,
|
||||
effectiveMessage: "Определить контрагента по реализации позиции «Пуф арий»",
|
||||
reason: "normalized_fragment_applied",
|
||||
predecomposeContract: {
|
||||
mode: "address_query",
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
semantics: {
|
||||
selected_object_scope_detected: true
|
||||
}
|
||||
}
|
||||
})),
|
||||
buildAddressLlmPredecomposeContractV1,
|
||||
resolveAddressFollowupCarryoverContext,
|
||||
resolveAssistantOrchestrationDecision
|
||||
})
|
||||
);
|
||||
|
||||
expect(output.addressInputMessage).toBe(rawMessage);
|
||||
expect(output.addressPreDecompose.applied).toBe(false);
|
||||
expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
|
||||
expect(buildAddressLlmPredecomposeContractV1).toHaveBeenCalledWith({
|
||||
sourceMessage: rawMessage,
|
||||
canonicalMessage: rawMessage
|
||||
});
|
||||
expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2);
|
||||
expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawUserMessage: rawMessage,
|
||||
effectiveAddressUserMessage: rawMessage
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -657,6 +657,88 @@ describe("assistant living chat mode", () => {
|
||||
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({
|
||||
|
||||
@@ -230,9 +230,59 @@ describe("assistant orchestration contract", () => {
|
||||
expect(decision.runAddressLane).toBe(false);
|
||||
expect(decision.toolGateDecision).toBe("skip_address_lane");
|
||||
expect(decision.toolGateReason).toBe("non_domain_query_indexed");
|
||||
expect(decision.livingMode).toBe("chat");
|
||||
expect(decision.livingReason).toBe("non_domain_query_indexed");
|
||||
expect(decision.orchestrationContract?.hard_meta_mode).toBe("non_domain");
|
||||
});
|
||||
|
||||
it("routes historical capability follow-up over grounded inventory answer to contextual chat", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "а исторические данные ты можешь же показать?",
|
||||
effectiveAddressUserMessage: "а исторические данные ты можешь же показать?",
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: {
|
||||
applied: false,
|
||||
reason: "normalized_fragment_rejected_semantic_guard",
|
||||
predecomposeContract: {
|
||||
mode: "unsupported",
|
||||
mode_confidence: "low",
|
||||
intent: "unknown",
|
||||
intent_confidence: "low"
|
||||
},
|
||||
semanticExtractionContract: {
|
||||
valid: false,
|
||||
apply_canonical_recommended: false
|
||||
}
|
||||
} as any,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
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: "альтернатива",
|
||||
address_root_frame_context: {
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
current_frame_kind: "inventory_root",
|
||||
organization: "альтернатива",
|
||||
as_of_date: "2026-04-15"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(decision.runAddressLane).toBe(false);
|
||||
expect(decision.toolGateDecision).toBe("skip_address_lane");
|
||||
expect(decision.toolGateReason).toBe("inventory_history_capability_followup_detected");
|
||||
expect(decision.livingMode).toBe("chat");
|
||||
expect(decision.livingReason).toBe("non_domain_query_indexed");
|
||||
expect(decision.orchestrationContract?.hard_meta_mode).toBe("non_domain");
|
||||
expect(decision.livingReason).toBe("inventory_history_capability_followup_detected");
|
||||
expect(decision.orchestrationContract?.followup_context_detected).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps VAT payable forecast query in address lane", () => {
|
||||
@@ -631,6 +681,55 @@ describe("assistant orchestration contract", () => {
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("routes meta follow-up over grounded inventory answer to chat instead of rerunning address lane", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "\u0447\u0435 \u0434\u0443\u043c\u0430\u0435\u0448\u044c \u043d\u0430 \u044d\u0442\u0443 \u0442\u0435\u043c\u0443",
|
||||
effectiveAddressUserMessage: "\u0447\u0435 \u0434\u0443\u043c\u0430\u0435\u0448\u044c \u043d\u0430 \u044d\u0442\u0443 \u0442\u0435\u043c\u0443",
|
||||
followupContext: {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2016-06-30",
|
||||
organization: "alt"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "ALT"
|
||||
},
|
||||
llmPreDecomposeMeta: {
|
||||
applied: false,
|
||||
reason: "normalized_fragment_rejected_semantic_guard",
|
||||
llmCanonicalCandidateDetected: true,
|
||||
predecomposeContract: {
|
||||
mode: "unsupported",
|
||||
mode_confidence: "low",
|
||||
intent: "unknown",
|
||||
intent_confidence: "low"
|
||||
},
|
||||
semanticExtractionContract: {
|
||||
valid: false,
|
||||
apply_canonical_recommended: false
|
||||
}
|
||||
} as any,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(decision.runAddressLane).toBe(false);
|
||||
expect(decision.toolGateDecision).toBe("skip_address_lane");
|
||||
expect(decision.toolGateReason).toBe("meta_followup_over_grounded_answer");
|
||||
expect(decision.livingMode).toBe("chat");
|
||||
expect(decision.livingReason).toBe("meta_followup_over_grounded_answer");
|
||||
});
|
||||
|
||||
it("keeps documentary inventory chain verification in address lane for supported exact intent", () => {
|
||||
const question =
|
||||
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";
|
||||
|
||||
@@ -51,6 +51,45 @@ describe("assistant organization scope runtime adapter", () => {
|
||||
expect(normalizeOrganizationScopeValue).toHaveBeenCalledWith("Org A");
|
||||
});
|
||||
|
||||
it("prefers organization scope from address navigation state when present", () => {
|
||||
const normalizeOrganizationScopeValue = vi.fn((value: unknown) =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : null
|
||||
);
|
||||
|
||||
const context = resolveSessionOrganizationScopeContextRuntime({
|
||||
userMessage: "просто продолжай",
|
||||
items: [] as any[],
|
||||
addressNavigationState: {
|
||||
schema_version: "address_navigation_state_v1",
|
||||
session_id: "asst-nav-org",
|
||||
updated_at: "2026-04-15T10:00:00.000Z",
|
||||
session_context: {
|
||||
active_result_set_id: "rs-1",
|
||||
active_focus_object: null,
|
||||
last_confirmed_route: "address_inventory_on_hand_as_of_date_v1",
|
||||
date_scope: {
|
||||
as_of_date: "2020-05-31",
|
||||
period_from: null,
|
||||
period_to: null
|
||||
},
|
||||
organization_scope: "Org B"
|
||||
},
|
||||
result_sets: [],
|
||||
navigation_history: []
|
||||
} as any,
|
||||
extractKnownOrganizationsFromHistory: () => ["Org A"],
|
||||
resolveOrganizationSelectionFromMessage: () => null,
|
||||
findLastAssistantActiveOrganization: () => "Org A",
|
||||
normalizeOrganizationScopeValue
|
||||
});
|
||||
|
||||
expect(context).toEqual({
|
||||
knownOrganizations: ["Org B", "Org A"],
|
||||
selectedOrganization: null,
|
||||
activeOrganization: "Org B"
|
||||
});
|
||||
});
|
||||
|
||||
it("merges organization into followup previous filters when organization is missing", () => {
|
||||
const merged = mergeFollowupContextWithOrganizationScopeRuntime({
|
||||
followupContext: {
|
||||
@@ -69,6 +108,9 @@ describe("assistant organization scope runtime adapter", () => {
|
||||
previous_filters: {
|
||||
period: "2020-07",
|
||||
organization: "Org A"
|
||||
},
|
||||
root_filters: {
|
||||
organization: "Org A"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user