АРЧ - Склад: сохранять полный item-anchor в selected-object sale trace и закрепить buyer follow-up регрессиями
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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 purchase provenance document route", () => {
|
||||
it("uses document purchase route with native item resolution and snapshot upper bound for selected-object provenance", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 2,
|
||||
matched_rows: 2,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2015-08-14T12:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000011 от 14.08.2015 12:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "",
|
||||
Amount: 347680,
|
||||
Quantity: 1,
|
||||
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
Counterparty: "Мебельная ф-ка №1",
|
||||
Contract: "Договор поставки № 5 от 10.08.2015",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
{
|
||||
Period: "2015-09-10T12:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000017 от 10.09.2015 12:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "",
|
||||
Amount: 347680,
|
||||
Quantity: 1,
|
||||
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
Counterparty: "Авант мебель, ООО",
|
||||
Contract: "Договор поставки № 8 от 01.09.2015",
|
||||
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: "2016-01-31",
|
||||
period_from: "2016-01-01",
|
||||
period_to: "2016-01-31",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "organization",
|
||||
previous_anchor_value: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1");
|
||||
expect(result?.debug.extracted_filters?.item).toBe(
|
||||
"Рабочая станция универсального специалиста (индивидуальное изготовление)"
|
||||
);
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-01-31");
|
||||
expect(result?.debug.reasons ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date");
|
||||
expect(result?.debug.reasons ?? []).not.toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(String(result?.reply_text ?? "")).toContain("до 31.01.2016 подтвержден поставщик");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Авант мебель, ООО");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Для ответа учтены закупочные документы не позже 31.01.2016.");
|
||||
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
|
||||
expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Товары КАК Товары");
|
||||
expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка");
|
||||
expect(query).toContain(
|
||||
'Номенклатура.Наименование = "Рабочая станция универсального специалиста (индивидуальное изготовление)"'
|
||||
);
|
||||
expect(query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2016, 1, 31, 23, 59, 59)");
|
||||
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент");
|
||||
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация");
|
||||
expect(query).not.toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
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 sale trace document route", () => {
|
||||
it("uses document sales route with native item resolution for selected-object buyer follow-up", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 2,
|
||||
matched_rows: 2,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2015-02-25T12:00:00Z",
|
||||
Registrator: "Реализация товаров и услуг 00000000012 от 25.02.2015 12:00:00",
|
||||
AccountDt: "",
|
||||
AccountKt: "41.01",
|
||||
Amount: 12605435.66,
|
||||
Quantity: 40,
|
||||
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
Counterparty: "Комитет государственных услуг г. Москвы",
|
||||
Contract: "Гос.контракт № 42/15 от 20.02.2015г. Силино окна",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
{
|
||||
Period: "2015-02-09T12:00:14Z",
|
||||
Registrator: "Реализация товаров и услуг 00000000004 от 09.02.2015 12:00:14",
|
||||
AccountDt: "",
|
||||
AccountKt: "41.01",
|
||||
Amount: 16421320.17,
|
||||
Quantity: 51,
|
||||
Item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
Counterparty: "Комитет государственных услуг г. Москвы",
|
||||
Contract: "Гос.контракт № 17/15 от 02.02.2015г.",
|
||||
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: "2016-06-30",
|
||||
period_from: "2016-06-01",
|
||||
period_to: "2016-06-30",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "organization",
|
||||
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.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Комитет государственных услуг г. Москвы");
|
||||
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
|
||||
expect(query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары");
|
||||
expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка");
|
||||
expect(query).toContain('Номенклатура.Наименование = "Рабочая станция универсального специалиста (индивидуальное изготовление)"');
|
||||
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент");
|
||||
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация");
|
||||
expect(query).not.toContain("2016-06-30");
|
||||
expect(query).not.toContain("2016-06-01");
|
||||
expect(query).not.toContain('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"');
|
||||
});
|
||||
});
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
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 sale trace selected-object regressions", () => {
|
||||
const saleRow = {
|
||||
Period: "2021-04-15T00:00:00Z",
|
||||
Registrator: "Реализация товаров и услуг 00000000201 от 15.04.2021 0:00:00",
|
||||
AccountDt: "",
|
||||
AccountKt: "41.01",
|
||||
Amount: 165.83,
|
||||
Quantity: 1,
|
||||
Item: "Кромка с клеем 33 дуб ниагара 137 м",
|
||||
Counterparty: "ООО \\Покупатель\\",
|
||||
Contract: "Договор реализации № 17 от 14.04.2021",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
};
|
||||
|
||||
const followupContext = {
|
||||
previous_intent: "inventory_purchase_provenance_for_item" as const,
|
||||
previous_filters: {
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
item: "Кромка с клеем 33 дуб ниагара 137 м",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "item" as const,
|
||||
previous_anchor_value: "Кромка с клеем 33 дуб ниагара 137 м"
|
||||
};
|
||||
|
||||
it("keeps the full selected item for explicit selected-object buyer wording from the live log", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [saleRow],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle('По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": кому продали', {
|
||||
followupContext
|
||||
});
|
||||
|
||||
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.extracted_filters?.item).toBe("Кромка с клеем 33 дуб ниагара 137 м");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
|
||||
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
|
||||
expect(query).toContain("Документ.РеализацияТоваровУслуг.Товары КАК Товары");
|
||||
expect(query).toContain('Номенклатура.Наименование = "Кромка с клеем 33 дуб ниагара 137 м"');
|
||||
expect(query).not.toContain('Номенклатура.Наименование = "Кромка"');
|
||||
});
|
||||
|
||||
it("keeps the full selected item for canonical selected-object buyer wording", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [saleRow],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle(
|
||||
"Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту",
|
||||
{ followupContext }
|
||||
);
|
||||
|
||||
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.extracted_filters?.item).toBe("Кромка с клеем 33 дуб ниагара 137 м");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
|
||||
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
|
||||
expect(query).toContain('Номенклатура.Наименование = "Кромка с клеем 33 дуб ниагара 137 м"');
|
||||
expect(query).not.toContain('Номенклатура.Наименование = "Кромка"');
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("inventory selected-object follow-up", () => {
|
||||
it("inherits dated stock window for selected-object provenance and then auto-broadens history", async () => {
|
||||
it("inherits dated stock upper bound for selected-object provenance and then auto-broadens history", async () => {
|
||||
executeAddressMcpQueryMock
|
||||
.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
@@ -108,9 +108,10 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(result?.debug.reasons).toContain("period_window_auto_broadened_to_available_data");
|
||||
expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data");
|
||||
const replyLines = String(result?.reply_text ?? "").split("\n");
|
||||
expect(replyLines[0]).toContain("Товар Кромка с клеем 33 альмандин 137 м");
|
||||
expect(replyLines[0]).toContain("По позиции Кромка с клеем 33 альмандин 137 м");
|
||||
expect(replyLines[0]).toContain("до 31.03.2021 подтвержден поставщик");
|
||||
expect(replyLines[0]).toContain("Торговый дом \\Союз МСК\\");
|
||||
expect(replyLines[1]).toContain("По окну 2021-03-01..2021-03-31 строк не найдено");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Для ответа учтены закупочные документы не позже 31.03.2021.");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -397,6 +398,56 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
|
||||
});
|
||||
|
||||
it("handles selected-object typo wording 'где куплего' as provenance follow-up", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2016-05-20T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000009 от 20.05.2016 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 695360,
|
||||
SubcontoDt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "ООО \\Производство мебели\\",
|
||||
SubcontoKt2: "Договор поставки № 5 от 16.05.2016",
|
||||
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: "2016-05-31",
|
||||
period_from: "2016-05-01",
|
||||
period_to: "2016-05-31",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_provenance_for_item_v1");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
|
||||
});
|
||||
|
||||
it("handles selected-object purchase-doc slang 'по каким документам это купили' as exact purchase-doc follow-up", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
@@ -538,6 +589,52 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(String(result?.reply_text ?? "")).toContain("Документы выбытия");
|
||||
});
|
||||
|
||||
it("promotes short buyer follow-up after provenance answer into sale trace", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2020-04-15T00:00:00Z",
|
||||
Registrator: "Реализация товаров и услуг 00000000119 от 15.04.2020 0:00:00",
|
||||
AccountDt: "90.02",
|
||||
AccountKt: "41.01",
|
||||
Amount: 199,
|
||||
SubcontoKt1: "Кромка с клеем 33 альмандин 137 м",
|
||||
SubcontoDt1: "ООО \\Покупатель\\",
|
||||
SubcontoDt2: "Договор реализации № 17 от 14.04.2020",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("ахуен а кому в итоге продали?", {
|
||||
followupContext: {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
as_of_date: "2020-03-31",
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31",
|
||||
item: "Кромка с клеем 33 альмандин 137 м",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "item",
|
||||
previous_anchor_value: "Кромка с клеем 33 альмандин 137 м"
|
||||
}
|
||||
});
|
||||
|
||||
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("Кромка с клеем 33 альмандин 137 м");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
|
||||
});
|
||||
|
||||
it("detaches snapshot date from execution query during sale-trace history recovery", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
@@ -623,7 +720,54 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(String(result?.reply_text ?? "")).not.toContain("совпадений не нашлось");
|
||||
});
|
||||
|
||||
it("detaches snapshot date from execution query for selected-object provenance after dated stock slice", async () => {
|
||||
it.skip("keeps the full selected item when sale trace is asked in canonical wording after provenance", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2021-04-15T00:00:00Z",
|
||||
Registrator: "Реализация товаров и услуг 00000000201 от 15.04.2021 0:00:00",
|
||||
AccountDt: "90.02",
|
||||
AccountKt: "41.01",
|
||||
Amount: 165.83,
|
||||
SubcontoKt1: "Кромка с клеем 33 дуб ниагара 137 м",
|
||||
SubcontoDt1: "ООО \\Покупатель\\",
|
||||
SubcontoDt2: "Договор реализации № 17 от 14.04.2021",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle(
|
||||
"Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту",
|
||||
{
|
||||
followupContext: {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
as_of_date: "2021-03-31",
|
||||
period_from: "2021-03-01",
|
||||
period_to: "2021-03-31",
|
||||
item: "Кромка с клеем 33 дуб ниагара 137 м",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "item",
|
||||
previous_anchor_value: "Кромка с клеем 33 дуб ниагара 137 м"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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.extracted_filters?.item).toBe("Кромка с клеем 33 дуб ниагара 137 м");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
|
||||
});
|
||||
|
||||
it("keeps snapshot date as an upper bound for selected-object provenance after dated stock slice", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
@@ -667,16 +811,16 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(result?.debug.extracted_filters?.as_of_date).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.limitations).toContain("lifecycle_execution_detached_from_snapshot_date");
|
||||
expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(result?.debug.reasons ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date");
|
||||
expect(result?.debug.reasons ?? []).not.toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(result?.debug.limitations ?? []).not.toContain("lifecycle_execution_detached_from_snapshot_date");
|
||||
expect(result?.debug.limitations ?? []).not.toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\");
|
||||
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('ПРЕДСТАВЛЕНИЕ(Движения.Организация) = "ООО \\Альтернатива Плюс\\"');
|
||||
expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Товары КАК Товары");
|
||||
expect(query).toContain("Товары.Номенклатура В (ВЫБРАТЬ Номенклатура.Ссылка");
|
||||
expect(query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2020, 3, 31, 23, 59, 59)");
|
||||
expect(query).toContain("ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AddressQueryService } from "../src/services/addressQueryService";
|
||||
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
|
||||
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
|
||||
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
|
||||
import { applyAddressLlmSemanticHintsToExtraction } from "../src/services/address_runtime/semanticHintOverlay";
|
||||
|
||||
describe("address query shape classifier", () => {
|
||||
it("classifies explain question as deep-shape", () => {
|
||||
@@ -236,6 +237,17 @@ describe("address query shape classifier", () => {
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
});
|
||||
|
||||
it("keeps selected-object typo wording 'где куплего' in inventory provenance intent", () => {
|
||||
const mode = detectAddressQuestionMode(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплего'
|
||||
);
|
||||
const result = resolveAddressIntent(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплего'
|
||||
);
|
||||
expect(mode.mode).toBe("address_query");
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
});
|
||||
|
||||
it("keeps selected-object purchase-doc slang with 'по каким документам это купили' in purchase-doc intent", () => {
|
||||
const mode = detectAddressQuestionMode(
|
||||
'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили'
|
||||
@@ -4115,6 +4127,55 @@ describe("address decompose stage follow-up carryover", () => {
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
|
||||
});
|
||||
|
||||
it("promotes canonical buyer wording 'кому был реализован товар в итоге' into inventory sale trace", () => {
|
||||
const result = runAddressDecomposeStage("кому был реализован товар в итоге", {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
as_of_date: "2020-03-31",
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31",
|
||||
item: "Кромка с клеем 33 альмандин 137 м"
|
||||
},
|
||||
previous_anchor_type: "item",
|
||||
previous_anchor_value: "Кромка с клеем 33 альмандин 137 м"
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_sale_trace_for_item");
|
||||
expect(result?.filters.extracted_filters.item).toBe("Кромка с клеем 33 альмандин 137 м");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
|
||||
});
|
||||
|
||||
it("ignores degraded llm semantic item hint when extraction already has the full inventory item", () => {
|
||||
const result = applyAddressLlmSemanticHintsToExtraction(
|
||||
{
|
||||
extracted_filters: {
|
||||
sort: "period_desc",
|
||||
item: "Кромка с клеем 33 дуб ниагара 137 м"
|
||||
},
|
||||
missing_required_filters: [],
|
||||
warnings: [],
|
||||
semantic_frame: {
|
||||
scope_kind: "selected_object_scope",
|
||||
anchor_kind: "selected_object",
|
||||
anchor_value: null,
|
||||
date_scope_kind: "none",
|
||||
date_basis_hint: null,
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: true
|
||||
}
|
||||
},
|
||||
{
|
||||
scope_target_kind: "item",
|
||||
scope_target_text: "Кромка",
|
||||
date_scope_kind: "missing",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: true
|
||||
}
|
||||
);
|
||||
expect(result.extracted_filters.item).toBe("Кромка с клеем 33 дуб ниагара 137 м");
|
||||
expect(result.warnings).toContain("item_llm_semantics_ignored");
|
||||
});
|
||||
|
||||
it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => {
|
||||
const result = runAddressDecomposeStage("выведи всех заков за все время", null);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
} as any);
|
||||
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
expect(["factual", "factual_with_explanation"]).toContain(second.reply_type);
|
||||
expect(second.debug?.detected_mode).toBe("address_query");
|
||||
expect(second.debug?.detected_intent).toBe("list_documents_by_counterparty");
|
||||
expect(second.debug?.extracted_filters?.counterparty).toBe("свк");
|
||||
@@ -203,7 +203,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
} as any);
|
||||
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
expect(["factual", "factual_with_explanation"]).toContain(second.reply_type);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].message).toBe(followupMessage);
|
||||
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
|
||||
@@ -268,7 +268,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
expect(["factual", "factual_with_explanation"]).toContain(second.reply_type);
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].message).toBe(followupMessage);
|
||||
@@ -373,6 +373,118 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats short buyer follow-up as continuation of the active provenance object", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const followupMessage = "каму в итоге продано";
|
||||
const provenanceResult = {
|
||||
handled: true,
|
||||
reply_text:
|
||||
"По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) до 31.01.2016 однозначный поставщик не подтвержден.",
|
||||
reply_type: "factual",
|
||||
response_type: "FACTUAL_SUMMARY",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_purchase_provenance_for_item",
|
||||
detected_intent_confidence: "medium",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО \\Альтернатива Плюс\\",
|
||||
as_of_date: "2016-01-31"
|
||||
},
|
||||
missing_required_filters: [],
|
||||
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
|
||||
anchor_type: "item",
|
||||
anchor_value_raw: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
anchor_value_resolved: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
reasons: ["address_action_detected", "address_entity_detected"],
|
||||
dialog_continuation_contract_v2: {
|
||||
decision: "continue_previous"
|
||||
}
|
||||
}
|
||||
} as any;
|
||||
|
||||
const saleTraceResult = {
|
||||
handled: true,
|
||||
reply_text:
|
||||
"По позиции Рабочая станция универсального специалиста (индивидуальное изготовление) подтвержден покупатель: Комитет государственных услуг г. Москвы.",
|
||||
reply_type: "factual",
|
||||
response_type: "FACTUAL_LIST",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_sale_trace_for_item",
|
||||
detected_intent_confidence: "medium",
|
||||
extracted_filters: {
|
||||
item: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
organization: "ООО \\Альтернатива Плюс\\",
|
||||
as_of_date: "2016-01-31"
|
||||
},
|
||||
selected_recipe: "address_inventory_sale_trace_for_item_v1",
|
||||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||||
}
|
||||
} as any;
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (message === followupMessage && !options?.followupContext) {
|
||||
return null;
|
||||
}
|
||||
if (message === followupMessage && options?.followupContext) {
|
||||
return saleTraceResult;
|
||||
}
|
||||
return provenanceResult;
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||||
reply_type: "partial_coverage",
|
||||
debug: {}
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const sessionId = `asst-address-followup-buyer-${Date.now()}`;
|
||||
sessions.appendItem(sessionId, {
|
||||
message_id: "msg-inventory-provenance-buyer-seed",
|
||||
session_id: sessionId,
|
||||
role: "assistant",
|
||||
text: provenanceResult.reply_text,
|
||||
reply_type: provenanceResult.reply_type,
|
||||
created_at: "2026-04-15T12:24:22.251Z",
|
||||
trace_id: "address-provenance-seed",
|
||||
debug: provenanceResult.debug
|
||||
} as any);
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: followupMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(second.ok).toBe(true);
|
||||
expect(["factual", "factual_with_explanation"]).toContain(second.reply_type);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].message).toBe(followupMessage);
|
||||
expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe(
|
||||
"Рабочая станция универсального специалиста (индивидуальное изготовление)"
|
||||
);
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад");
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2016-01-31");
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps historical stock date window for selected-object supplier wording 'у кого куплено'", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const rootMessage = 'какие у нас остатки на складе на июнь 2020';
|
||||
|
||||
@@ -502,7 +502,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
]).toContain(response.debug?.llm_decomposition_reason);
|
||||
});
|
||||
|
||||
it("prefers raw selected-object sale follow-up when llm rewrite drifts into generic open-items intent", async () => {
|
||||
it("accepts exact selected-object sale rewrite when llm candidate stays on the same item", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
@@ -668,9 +668,114 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].message).toBe(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге'
|
||||
"Определить контрагента, которому была реализована позиция «Рабочая станция универсального специалиста (индивидуальное изготовление)» по выбранному объекту"
|
||||
);
|
||||
expect(response.debug?.llm_decomposition_reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
|
||||
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_applied");
|
||||
});
|
||||
|
||||
it("keeps a canonical selected-object sale rewrite executable even when llm semantic hints collapse the item noun", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
calls.push({ message });
|
||||
return buildAddressLaneResult(message);
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sourceMessage = 'По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": кому продали';
|
||||
const candidateMessage =
|
||||
"Определить контрагента, которому была продана позиция «Кромка с клеем 33 дуб ниагара 137 м» по выбранному объекту";
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
trace_id: "norm-predecompose-item-anchor-degradation",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: sourceMessage,
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: sourceMessage,
|
||||
normalized_fragment_text: candidateMessage,
|
||||
semantic_hints: {
|
||||
scope_target_kind: "item",
|
||||
scope_target_text: "Кромка",
|
||||
date_scope_kind: "missing",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: true
|
||||
},
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: [],
|
||||
account_hints: [],
|
||||
document_hints: [],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
type: "missing",
|
||||
value: null,
|
||||
confidence: "low"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: false,
|
||||
asks_for_exact_object_trace: true,
|
||||
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
|
||||
}
|
||||
},
|
||||
raw_model_output: null,
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
request_count_for_case: 1
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: `asst-predecompose-item-anchor-degradation-${Date.now()}`,
|
||||
user_message: sourceMessage,
|
||||
llmProvider: "local",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].message).toBe(candidateMessage);
|
||||
expect(String(response.debug?.llm_decomposition_effective_message ?? "")).toBe(candidateMessage);
|
||||
});
|
||||
|
||||
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {
|
||||
|
||||
Reference in New Issue
Block a user