ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить selected-object follow-up по складу и включить разговорные/UI-варианты в обязательный domain-loop

This commit is contained in:
2026-04-14 14:18:38 +03:00
parent 9048632d3e
commit d41819eabd
67 changed files with 3800 additions and 195 deletions
@@ -42,28 +42,73 @@ describe("address capability policy", () => {
expect(isCapabilityRouteBlocked(decision)).toBe(false);
});
it("marks inventory provenance intents as blocked exact-capability gaps", () => {
it("enables inventory provenance intent as compute exact capability", () => {
const decision = resolveAddressCapabilityRouteDecision("inventory_purchase_provenance_for_item");
expect(decision.capability_id).toBe("inventory_inventory_purchase_provenance_for_item");
expect(decision.capability_layer).toBe("compute");
expect(decision.capability_route_mode).toBe("exact");
expect(decision.capability_route_enabled).toBe(false);
expect(decision.capability_route_reason).toBe("inventory_provenance_route_not_implemented");
expect(isCapabilityRouteBlocked(decision)).toBe(true);
expect(decision.capability_route_enabled).toBe(true);
expect(decision.capability_route_reason).toBe("inventory_trace_route_enabled");
expect(isCapabilityRouteBlocked(decision)).toBe(false);
});
it("marks purchase-to-sale trace and aging intents as blocked exact-capability gaps", () => {
it("enables inventory supplier overlap intent as compute exact capability", () => {
const decision = resolveAddressCapabilityRouteDecision("inventory_supplier_stock_overlap_as_of_date");
expect(decision.capability_id).toBe("inventory_inventory_supplier_stock_overlap_as_of_date");
expect(decision.capability_layer).toBe("compute");
expect(decision.capability_route_mode).toBe("exact");
expect(decision.capability_route_enabled).toBe(true);
expect(decision.capability_route_reason).toBe("inventory_supplier_stock_overlap_route_enabled");
expect(isCapabilityRouteBlocked(decision)).toBe(false);
});
it("enables purchase-document and sale-trace intents", () => {
const purchaseDocsDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_documents_for_item");
expect(purchaseDocsDecision.capability_route_enabled).toBe(true);
expect(purchaseDocsDecision.capability_route_reason).toBe("inventory_trace_route_enabled");
const saleTraceDecision = resolveAddressCapabilityRouteDecision("inventory_sale_trace_for_item");
expect(saleTraceDecision.capability_route_enabled).toBe(true);
expect(saleTraceDecision.capability_route_reason).toBe("inventory_trace_route_enabled");
});
it("keeps inventory purchase documents and sale chain on exact compute routes", () => {
const purchaseDocsDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_documents_for_item");
expect(purchaseDocsDecision.capability_id).toBe("inventory_inventory_purchase_documents_for_item");
expect(purchaseDocsDecision.capability_layer).toBe("compute");
expect(purchaseDocsDecision.capability_route_mode).toBe("exact");
const chainDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_to_sale_chain");
expect(chainDecision.capability_id).toBe("inventory_inventory_purchase_to_sale_chain");
expect(chainDecision.capability_layer).toBe("compute");
expect(chainDecision.capability_route_mode).toBe("exact");
});
it("enables purchase-to-sale trace and aging by purchase date", () => {
const chainDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_to_sale_chain");
expect(chainDecision.capability_id).toBe("inventory_inventory_purchase_to_sale_chain");
expect(chainDecision.capability_route_mode).toBe("exact");
expect(chainDecision.capability_route_enabled).toBe(false);
expect(isCapabilityRouteBlocked(chainDecision)).toBe(true);
expect(chainDecision.capability_route_enabled).toBe(true);
expect(chainDecision.capability_route_reason).toBe("inventory_purchase_to_sale_chain_route_enabled");
expect(isCapabilityRouteBlocked(chainDecision)).toBe(false);
const agingDecision = resolveAddressCapabilityRouteDecision("inventory_aging_by_purchase_date");
expect(agingDecision.capability_id).toBe("inventory_inventory_aging_by_purchase_date");
expect(agingDecision.capability_route_mode).toBe("exact");
expect(agingDecision.capability_route_enabled).toBe(false);
expect(isCapabilityRouteBlocked(agingDecision)).toBe(true);
expect(agingDecision.capability_route_enabled).toBe(true);
expect(agingDecision.capability_route_reason).toBe("inventory_aging_route_enabled");
expect(isCapabilityRouteBlocked(agingDecision)).toBe(false);
});
it("enables aging by purchase date as an exact inventory capability", () => {
const decision = resolveAddressCapabilityRouteDecision("inventory_aging_by_purchase_date");
expect(decision.capability_id).toBe("inventory_inventory_aging_by_purchase_date");
expect(decision.capability_layer).toBe("compute");
expect(decision.capability_route_mode).toBe("exact");
expect(decision.capability_route_enabled).toBe(true);
expect(decision.capability_route_reason).toBe("inventory_aging_route_enabled");
expect(isCapabilityRouteBlocked(decision)).toBe(false);
});
it("maps document drilldown intent to navigation capability", () => {
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
describe("inventory supplier-item-buyer chain filter extraction", () => {
it("trims buyer tail from item filter in documentary chain questions", () => {
const filters = extractAddressFilters(
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы",
"inventory_purchase_to_sale_chain"
).extracted_filters;
expect(filters.item).toContain("1000*400*2100");
expect(filters.item).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 selected-object follow-up", () => {
it("auto-broadens dated stock follow-up window for inventory provenance", async () => {
executeAddressMcpQueryMock
.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2021-03-09T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000001 от 09.03.2021 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 442075,
SubcontoDt1: "Рабочая станция универсального специалиста с угловым элементом.",
SubcontoDt3: "Основной склад",
SubcontoKt1: "АСТРА",
SubcontoKt2: "А_03/2021 от 01.03.2021г.",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
})
.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2020-02-11T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2020 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 165.83,
SubcontoDt1: "Кромка с клеем 33 альмандин 137 м",
SubcontoDt3: "Основной склад",
SubcontoKt1: "Торговый дом \\Союз МСК\\",
SubcontoKt2: "Договор поставки № 12 от 01.02.2020",
Organization: "ООО \\Альтернатива Плюс\\"
},
{
Period: "2020-02-11T00:00:00Z",
Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2020 0:00:00",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 165.83,
SubcontoDt1: "Кромка с клеем 33 дуб ниагара 137 м",
SubcontoDt3: "Основной склад",
SubcontoKt1: "Торговый дом \\Союз МСК\\",
SubcontoKt2: "Договор поставки № 12 от 01.02.2020",
Organization: "ООО \\Альтернатива Плюс\\"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
'По выбранному объекту "Кромка с клеем 33 альмандин 137 м | склад: Основной склад | количество: 1,000 | стоимость: 165,83 ₽ | организация: ООО \\\\Альтернатива Плюс\\\\ | дата строки: 2021-03-31T23:59:59Z": От какого поставщика куплен товар'
);
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.extracted_filters?.item).toBe("Кромка с клеем 33 альмандин 137 м");
expect(result?.debug.reasons).toContain("period_window_auto_broadened_to_available_data");
expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data");
expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз МСК\\");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
describe("inventory warehouse anchor extraction", () => {
it("does not treat 'по состоянию ...' as warehouse name in stock snapshot questions", () => {
const filters = extractAddressFilters(
"Какие товары находятся на складе по состоянию на 15 марта 2020 года?",
"inventory_on_hand_as_of_date"
).extracted_filters;
expect(filters.as_of_date).toBe("2020-03-15");
expect(filters.warehouse).toBeUndefined();
});
it("does not treat month phrases as warehouse name in stock snapshot questions", () => {
const filters = extractAddressFilters(
"Какие товары лежат на складе на март 2019",
"inventory_on_hand_as_of_date"
).extracted_filters;
expect(filters.period_from).toBe("2019-03-01");
expect(filters.period_to).toBe("2019-03-31");
expect(filters.as_of_date).toBe("2019-03-31");
expect(filters.warehouse).toBeUndefined();
});
});
@@ -130,6 +130,222 @@ describe("address query shape classifier", () => {
expect(result.mode).toBe("address_query");
});
it("extracts item anchor for inventory provenance questions", () => {
const filters = extractAddressFilters(
"От какого поставщика куплен товар Шкаф картотечный?",
"inventory_purchase_provenance_for_item"
).extracted_filters;
expect(filters.item).toBe("Шкаф картотечный");
});
it("cuts inventory item anchor before current-stock tail", () => {
const filters = extractAddressFilters(
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
"inventory_purchase_provenance_for_item"
).extracted_filters;
expect(filters.item).toBe("Диван трехместный");
expect(filters.warehouse).toBe("Основной склад");
});
it("cuts inventory item anchor before chain suffix and ignores chain pseudo-warehouse", () => {
const filters = extractAddressFilters(
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа",
"inventory_purchase_to_sale_chain"
).extracted_filters;
expect(filters.item).toBe("Шкаф картотечный 1000*400*2100");
expect(filters.warehouse).toBeUndefined();
});
it("cuts inventory item anchor before purchase-doc residue tail", () => {
const filters = extractAddressFilters(
"По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад",
"inventory_purchase_documents_for_item"
).extracted_filters;
expect(filters.item).toBe("Диван трехместный");
expect(filters.warehouse).toBe("Основной склад");
});
it("extracts item anchor from selected-object inventory row for provenance follow-up", () => {
const filters = extractAddressFilters(
'По выбранному объекту "Кромка с клеем 33 альмандин 137 м | склад: Основной склад | количество: 1,000 | стоимость: 165,83 ₽ | организация: ООО \\\\Альтернатива Плюс\\\\ | дата строки: 2021-03-31T23:59:59Z": От какого поставщика куплен товар',
"inventory_purchase_provenance_for_item"
).extracted_filters;
expect(filters.item).toBe("Кромка с клеем 33 альмандин 137 м");
});
it("keeps full supplier anchor with comma suffix for stock-overlap questions", () => {
const filters = extractAddressFilters(
"Какие товары от поставщика Гамма-мебель, ООО сейчас еще лежат на складе Основной склад?",
"inventory_supplier_stock_overlap_as_of_date"
).extracted_filters;
expect(filters.counterparty).toBe("Гамма-мебель, ООО");
});
it("does not capture organization wording as supplier anchor in overlap questions", () => {
const filters = extractAddressFilters(
"У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад организации ООО \\Альтернатива Плюс\\",
"inventory_supplier_stock_overlap_as_of_date"
).extracted_filters;
expect(filters.counterparty).toBeUndefined();
expect(filters.warehouse).toBe("Основной склад");
});
it("extracts item anchor for inventory aging questions", () => {
const filters = extractAddressFilters(
"Относится ли товар Шкаф картотечный 1000*400*2100 в остатке на дату 2020-03-31 к старым закупкам",
"inventory_aging_by_purchase_date"
).extracted_filters;
expect(filters.item).toBe("Шкаф картотечный 1000*400*2100");
});
it("builds dedicated inventory purchase-documents query plan", () => {
const selected = selectAddressRecipe("inventory_purchase_documents_for_item", {
item: "Шкаф картотечный",
as_of_date: "2020-03-31"
});
expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_purchase_documents_for_item_v1");
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
item: "Шкаф картотечный",
as_of_date: "2020-03-31"
});
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
expect(plan.query).toContain("Движения.СчетДт");
expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1");
expect(plan.query).toContain("41.01");
});
it("builds overlap recipe for supplier-linked stock slice", () => {
const selected = selectAddressRecipe("inventory_supplier_stock_overlap_as_of_date", {
counterparty: "Гамма-мебель, ООО",
warehouse: "Основной склад",
as_of_date: "2020-03-31"
});
expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_supplier_stock_overlap_as_of_date_v1");
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
counterparty: "Гамма-мебель, ООО",
warehouse: "Основной склад",
as_of_date: "2020-03-31"
});
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
expect(plan.query).toContain("Движения.СчетДт");
expect(plan.query).toContain("41.01");
});
it("renders inventory purchase documents from purchase-side 41.01 movements", () => {
const reply = composeFactualReply(
"inventory_purchase_documents_for_item",
[
{
period: "2020-03-15T12:00:00Z",
registrator: "Поступление товаров и услуг 0001",
account_dt: "41.01",
account_kt: "60.01",
amount: 150000,
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Гамма-мебель, ООО"],
item: "Шкаф картотечный",
warehouse: "Основной склад",
organization: "ООО Ромашка"
}
],
{
asOfDate: "2020-03-31",
useRubCurrency: true
}
);
expect(reply.text).toContain("Шкаф картотечный");
expect(reply.text).toContain("Поступление товаров и услуг 0001");
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
});
it("renders inventory provenance summary from purchase-side 41.01 movements", () => {
const reply = composeFactualReply(
"inventory_purchase_provenance_for_item",
[
{
period: "2020-03-15T12:00:00Z",
registrator: "Поступление товаров и услуг 0001",
account_dt: "41.01",
account_kt: "60.01",
amount: 150000,
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Гамма-мебель, ООО"],
item: "Шкаф картотечный",
warehouse: "Основной склад",
organization: "ООО Ромашка"
}
],
{
asOfDate: "2020-03-31",
useRubCurrency: true
}
);
expect(reply.text).toContain("закупочный след");
expect(reply.text).toContain("Гамма-мебель, ООО");
expect(reply.semantics?.balance_confirmed).toBe(true);
});
it("renders inventory sale trace from credit-side 41.01 movements", () => {
const reply = composeFactualReply(
"inventory_sale_trace_for_item",
[
{
period: "2020-04-10T12:00:00Z",
registrator: "Реализация товаров и услуг 0007",
account_dt: "90.02",
account_kt: "41.01",
amount: 210000,
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Департамент капитального ремонта города Москвы"],
item: "Шкаф картотечный",
warehouse: "Основной склад",
organization: "ООО Ромашка"
}
],
{
asOfDate: "2020-04-30",
useRubCurrency: true
}
);
expect(reply.text).toContain("след выбытия");
expect(reply.text).toContain("Реализация товаров и услуг 0007");
expect(reply.text).toContain("Департамент капитального ремонта города Москвы");
});
it("renders purchase-to-sale chain from both sides of 41.01", () => {
const reply = composeFactualReply(
"inventory_purchase_to_sale_chain",
[
{
period: "2020-03-15T12:00:00Z",
registrator: "Поступление товаров и услуг 0001",
account_dt: "41.01",
account_kt: "60.01",
amount: 150000,
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Гамма-мебель, ООО"],
item: "Шкаф картотечный",
warehouse: "Основной склад",
organization: "ООО Ромашка"
},
{
period: "2020-04-10T12:00:00Z",
registrator: "Реализация товаров и услуг 0007",
account_dt: "90.02",
account_kt: "41.01",
amount: 210000,
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Департамент капитального ремонта города Москвы"],
item: "Шкаф картотечный",
warehouse: "Основной склад",
organization: "ООО Ромашка"
}
],
{
asOfDate: "2020-04-30",
useRubCurrency: true
}
);
expect(reply.text).toContain("документальная цепочка");
expect(reply.text).toContain("Поступление товаров и услуг 0001");
expect(reply.text).toContain("Реализация товаров и услуг 0007");
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
});
});
describe("address compose stage utf8 headers", () => {
@@ -4090,6 +4306,39 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("ДоговорКонтрагента");
});
it("keeps recipe-default limits for inventory exact intents", () => {
const onHand = extractAddressFilters("Какие товары сейчас лежат на складе?", "inventory_on_hand_as_of_date");
const provenance = extractAddressFilters(
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад?",
"inventory_purchase_provenance_for_item"
);
const purchaseDocs = extractAddressFilters(
"По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад?",
"inventory_purchase_documents_for_item"
);
const overlap = extractAddressFilters(
"Какие товары от поставщика Гамма-мебель, ООО сейчас еще лежат на складе Основной склад?",
"inventory_supplier_stock_overlap_as_of_date"
);
const saleTrace = extractAddressFilters("Кому был продан товар Шкаф картотечный 1000*400*2100?", "inventory_sale_trace_for_item");
const chain = extractAddressFilters(
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа?",
"inventory_purchase_to_sale_chain"
);
const aging = extractAddressFilters(
"Есть ли среди текущих остатков на складе Основной склад позиции, закупленные очень давно?",
"inventory_aging_by_purchase_date"
);
expect(onHand.extracted_filters.limit).toBeUndefined();
expect(provenance.extracted_filters.limit).toBeUndefined();
expect(purchaseDocs.extracted_filters.limit).toBeUndefined();
expect(overlap.extracted_filters.limit).toBeUndefined();
expect(saleTrace.extracted_filters.limit).toBeUndefined();
expect(chain.extracted_filters.limit).toBeUndefined();
expect(aging.extracted_filters.limit).toBeUndefined();
});
it("selects customer value recipe and keeps top-20 default", () => {
const selected = selectAddressRecipe("customer_revenue_and_payments", {});
expect(selected.selected_recipe).toBeTruthy();
@@ -4339,6 +4588,52 @@ describe("address recipe catalog counterparty filtering", () => {
expect(result.confidence).toBe("high");
});
it("detects colloquial warehouse snapshot wording as inventory-on-hand intent", () => {
const result = resolveAddressIntent("что у нас на складе на март 2017");
expect(result.intent).toBe("inventory_on_hand_as_of_date");
expect(result.confidence).toBe("high");
});
it("routes account 41 composition wording into inventory snapshot intent", () => {
const result = resolveAddressIntent("Из каких товаров состоит остаток по 41 счету");
expect(result.intent).toBe("inventory_on_hand_as_of_date");
});
it("routes account 41 date snapshot wording into inventory snapshot intent", () => {
const result = resolveAddressIntent("Какие товары числятся на 41 счете на дату 2020-03-31");
expect(result.intent).toBe("inventory_on_hand_as_of_date");
});
it("routes supplier stock overlap wording into overlap intent", () => {
const result = resolveAddressIntent("Какие товары от поставщика Гамма-мебель, ООО сейчас еще лежат на складе Основной склад");
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
});
it("routes supplier to buyer chain wording into purchase-to-sale chain intent", () => {
const result = resolveAddressIntent(
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы"
);
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
});
it("routes supplier-to-buyer inventory chains as exact trace intents", () => {
const result = resolveAddressIntent(
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы"
);
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
expect(result.confidence).toBe("medium");
});
it("routes old purchase residue questions to aging-by-purchase-date", () => {
const result = resolveAddressIntent(
"Относится ли товар Шкаф картотечный 1000*400*2100 в остатке на дату 2020-03-31 к старым закупкам"
);
expect(result.intent).toBe("inventory_aging_by_purchase_date");
expect(result.confidence).toBe("high");
});
it("derives as_of_date for inventory-on-hand from explicit month window", () => {
const filters = extractAddressFilters(
"Какие товары лежат на складе на март 2020",
@@ -4400,6 +4695,13 @@ describe("address recipe catalog counterparty filtering", () => {
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
});
it("keeps direct item supplier questions in provenance intent even with current-stock tail", () => {
const result = resolveAddressIntent(
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад?"
);
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
});
it("keeps inventory supplier overlap questions out of on-hand routing", () => {
const result = resolveAddressIntent("Какие товары от поставщика Альфа сейчас лежат на складе?");
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
@@ -4414,7 +4716,7 @@ describe("address recipe catalog counterparty filtering", () => {
const result = resolveAddressIntent(
"Через какие документы прошел путь товара Шкаф картоотечный: закупка -> склад -> продажа?"
);
expect(result.intent).toBe("inventory_sale_trace_for_item");
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
});
it("keeps inventory provenance wording out of inventory-on-hand routing", () => {
@@ -4427,6 +4729,16 @@ describe("address recipe catalog counterparty filtering", () => {
expect(result.intent).toBe("inventory_aging_by_purchase_date");
});
it("keeps very old purchase wording out of on-hand routing", () => {
const result = resolveAddressIntent("Относится ли товар к закупленным задолго до даты остатка на складе?");
expect(result.intent).toBe("inventory_aging_by_purchase_date");
});
it("routes old stock wording with residue anchor to aging intent", () => {
const result = resolveAddressIntent("Это остаток по очень давно купленному товару?");
expect(result.intent).toBe("inventory_aging_by_purchase_date");
});
it("routes purchase-document trace wording to dedicated inventory intent", () => {
const result = resolveAddressIntent("Какими документами был куплен товар Шкаф картоотечный?");
expect(result.intent).toBe("inventory_purchase_documents_for_item");
@@ -4436,9 +4748,33 @@ describe("address recipe catalog counterparty filtering", () => {
expect(result.intent).toBe("inventory_aging_by_purchase_date");
});
it("routes residue wording with explicit cut-off date into aging intent", () => {
const result = resolveAddressIntent(
"Есть ли среди текущих остатков на складе Основной склад позиции, закупленные задолго до 2020-03-31?"
);
expect(result.intent).toBe("inventory_aging_by_purchase_date");
});
it("keeps unresolved stock provenance wording out of open-items routing", () => {
const result = resolveAddressIntent("Какие товары сейчас висят в остатке без понятной привязки к поставщику?");
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
});
it("routes documentary supplier-to-buyer chain wording into inventory chain intent", () => {
const result = resolveAddressIntent(
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы?"
);
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
});
it("routes explicit supplier-item-buyer chain wording into inventory chain intent", () => {
const result = resolveAddressIntent("supplier -> item -> buyer");
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
});
it("routes documented supplier-item-buyer chain wording into inventory chain intent", () => {
const result = resolveAddressIntent("Документально подтвержденная цепочка: поставщик -> товар -> покупатель");
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
});
});
@@ -254,6 +254,102 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
});
it("keeps colloquial warehouse snapshot questions in exact inventory contour when llm rewrites them abstractly", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-inventory-colloquial",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "что у нас на складе на март 2017",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "что у нас на складе на март 2017",
normalized_fragment_text: "информация о наличии товаров на складе за март 2017 года",
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: [],
account_hints: [],
document_hints: [],
register_hints: [],
time_scope: {
type: "explicit",
value: "2017-03",
confidence: "high"
},
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: 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
}
},
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-inventory-colloquial-${Date.now()}`,
user_message: "что у нас на складе на март 2017",
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.toLowerCase()).toContain("складе");
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
expect(response.debug?.llm_predecompose_contract?.intent).toBe("inventory_on_hand_as_of_date");
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
});
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
@@ -264,6 +264,36 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("keeps inventory provenance and sale-trace queries in address lane", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
effectiveAddressUserMessage: "От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
followupContext: {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
as_of_date: "2020-03-31"
}
},
llmPreDecomposeMeta: {
applied: true,
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "address_query",
mode_confidence: "high",
intent: "inventory_purchase_provenance_for_item",
intent_confidence: "high"
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("keeps customer-value ranking question in address lane even when LLM semantic guard rejects canonical rewrite", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "кто больше всего принес денег в 2020",
@@ -558,6 +588,47 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("keeps documentary inventory chain verification in address lane for supported exact intent", () => {
const question =
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: question,
effectiveAddressUserMessage: question,
followupContext: null,
llmPreDecomposeMeta: {
applied: true,
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "deep_analysis",
mode_confidence: "high",
intent: "inventory_purchase_to_sale_chain",
intent_confidence: "medium"
},
semanticExtractionContract: {
valid: true,
apply_canonical_recommended: true,
reason_codes: ["deep_investigation_signal_detected"],
guard_hints: {
deep_investigation_signal_detected: true
},
extraction: {
query_shape: "VERIFY_FACTUAL",
aggregation_profile: "unknown"
}
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
expect(decision.orchestrationContract?.semantic_route_arbitration?.supported_address_intent_detected).toBe(true);
expect(decision.orchestrationContract?.semantic_route_arbitration?.strict_deep_investigation_bypass_allowed).toBe(true);
});
it("keeps 'a na tekushuyu datu' follow-up in address lane when previous VAT context exists", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "а на текущую дату",