Закрепить складской provenance и supplier-overlap ассистента

This commit is contained in:
2026-06-03 07:38:35 +03:00
parent 4d32b7fad5
commit 2cec57888a
28 changed files with 2274 additions and 162 deletions
@@ -36,6 +36,26 @@ describe("inventory root frame regressions", () => {
expect(result?.filters.warnings).toContain("period_to_from_followup_context");
});
it("does not treat an explicit warehouse as an item in root inventory snapshot", () => {
const result = runAddressDecomposeStage(
"Какие конкретно номенклатуры формируют остаток по складу Основной склад на дату 2019-03-31",
null,
{
scope_target_kind: "item",
scope_target_text: "Основной склад",
date_scope_kind: "explicit",
self_scope_detected: false,
selected_object_scope_detected: false
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
expect(result?.filters.extracted_filters.warehouse).toBe("Основной склад");
expect(result?.filters.extracted_filters.item).toBeUndefined();
expect(result?.filters.warnings).toContain("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot");
});
it("promotes selected-object provenance slang with 'где мы взяли это' into inventory provenance", () => {
const result = runAddressDecomposeStage(
'По выбранному объекту "Зеркало для инвалидов поворотное травмобезопасное": где мы взяли это говнище?',
@@ -110,6 +130,39 @@ describe("inventory root frame regressions", () => {
expect(result?.intent.intent).not.toBe("bank_operations_by_counterparty");
expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)");
});
it("routes inline selected-object purchase document wording without external followup context", () => {
const result = runAddressDecomposeStage(
'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили',
null
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_purchase_documents_for_item");
expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара");
});
it("routes normalized selected-item purchase document wording from LLM predecompose", () => {
const result = runAddressDecomposeStage(
"Уточнить, какие документы поступления связаны с выбранным товаром «Столешница 600*3050*26 дуб ниагара»",
{
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
item: "Столешница 600*3050*26 дуб ниагара",
as_of_date: "2019-03-31",
organization: 'ООО "Альтернатива Плюс"'
},
previous_anchor_type: "item",
previous_anchor_value: "Столешница 600*3050*26 дуб ниагара",
root_intent: "inventory_on_hand_as_of_date",
current_frame_kind: "inventory_drilldown"
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_purchase_documents_for_item");
expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара");
});
it("restores stock snapshot intent for selected-object restatement on the previously reviewed date", () => {
const result = runAddressDecomposeStage(
'По выбранному объекту "Столешница 600*3050*26 альмандин": покажи еще раз остатки на дату которую до этого рассматривали',
@@ -3,7 +3,7 @@ import { detectAddressQuestionMode } from "../src/services/addressQueryClassifie
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
import { AddressQueryService } from "../src/services/addressQueryService";
import { AddressQueryService, ensureInventorySupplierOverlapLeadLine } 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";
@@ -157,6 +157,15 @@ describe("address query shape classifier", () => {
expect(filters.warehouse).toBe("Основной склад");
});
it("does not extract generic goods wording as counterparty for supplier-overlap identity questions", () => {
const filters = extractAddressFilters(
"У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
"inventory_supplier_stock_overlap_as_of_date"
).extracted_filters;
expect(filters.counterparty).toBeUndefined();
expect(filters.warehouse).toBe("Основной склад");
});
it("cuts inventory item anchor before chain suffix and ignores chain pseudo-warehouse", () => {
const filters = extractAddressFilters(
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа",
@@ -499,6 +508,35 @@ describe("address query shape classifier", () => {
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
});
it("states directly when only purchase side of the inventory chain is confirmed", () => {
const reply = composeFactualReply(
"inventory_purchase_to_sale_chain",
[
{
period: "2019-12-10T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000111 от 10.12.2019 12:00:01",
account_dt: "41.01",
account_kt: "",
amount: 855000,
analytics: ["Шкаф картотечный 1000*400*2100", "Основной склад", "ООО Альтернатива Плюс", "ЭталонМебель"],
item: "Шкаф картотечный 1000*400*2100",
counterparty: "ЭталонМебель",
organization: "ООО Альтернатива Плюс"
}
],
{
asOfDate: "2020-03-31",
userMessage: "Есть ли цепочка закупка -> продажа по товару Шкаф картотечный 1000*400*2100?"
}
);
expect(reply.text.split("\n")[0]).toContain("закупочная часть цепочки подтверждена");
expect(reply.text.split("\n")[0]).toContain("продажа/выбытие со счета 41.01");
expect(reply.text.split("\n")[0]).not.toContain("частично или разнообразно");
expect(reply.text).toContain("Строк закупки на 41.01: 1");
expect(reply.text).toContain("Строк продажи со счета 41.01: 0");
});
it("states when requested supplier-to-buyer chain parties are not confirmed by item documents", () => {
const reply = composeFactualReply(
"inventory_purchase_to_sale_chain",
@@ -3648,6 +3686,24 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 9000
expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context");
});
it("detaches analysis-context snapshot date for inventory purchase-to-sale chain execution", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа",
{
analysisDateHint: "2020-03-31",
activeOrganization: 'ООО "Альтернатива Плюс"'
}
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("inventory_purchase_to_sale_chain");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context");
expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery");
expect(result?.reply_text).toContain("Строк продажи со счета 41.01: 1");
});
it("returns soft out-of-scope reply without technical jargon for unsupported supplier-control wording", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
@@ -4511,6 +4567,33 @@ it("auto-broadens out-of-window period after contracts pivot and keeps requested
});
describe("address decompose stage follow-up carryover", () => {
it("keeps old-purchase residue follow-up in aging route with inherited stock date", () => {
const result = runAddressDecomposeStage("Какие остатки по товарам на эту дату относятся к старым закупкам", {
previous_intent: "inventory_on_hand_as_of_date",
target_intent: "inventory_on_hand_as_of_date",
root_intent: "inventory_on_hand_as_of_date",
current_frame_kind: "inventory_root",
previous_filters: {
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"'
},
root_filters: {
as_of_date: "2021-09-30",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"'
}
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_aging_by_purchase_date");
expect(result?.filters.extracted_filters.as_of_date).toBe("2021-09-30");
expect(result?.filters.extracted_filters.warehouse).toBe("Основной склад");
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
});
it("promotes selected-object supplier slang follow-up into inventory provenance with inherited date context", () => {
const result = runAddressDecomposeStage('По выбранному объекту "Столешница 600*3050*26 дуб ниагара": кто это поставил нам', {
previous_intent: "inventory_on_hand_as_of_date",
@@ -4736,6 +4819,39 @@ describe("address decompose stage follow-up carryover", () => {
expect(result.warnings).toContain("item_llm_semantics_ignored");
});
it("clears generic goods nouns from llm semantic counterparty hints", () => {
const result = applyAddressLlmSemanticHintsToExtraction(
{
extracted_filters: {
sort: "period_desc",
warehouse: "Основной склад"
},
missing_required_filters: [],
warnings: [],
semantic_frame: {
scope_kind: "explicit_anchor",
anchor_kind: "warehouse",
anchor_value: "Основной склад",
date_scope_kind: "implicit_current",
date_basis_hint: "implicit_current_snapshot",
self_scope_detected: false,
selected_object_scope_detected: false
}
},
{
scope_target_kind: "counterparty",
scope_target_text: "товаров,",
date_scope_kind: "implicit_current",
self_scope_detected: false,
selected_object_scope_detected: false
}
);
expect(result.extracted_filters.counterparty).toBeUndefined();
expect(result.extracted_filters.warehouse).toBe("Основной склад");
expect(result.warnings).toContain("counterparty_cleared_low_quality_llm_semantics");
});
it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => {
const result = runAddressDecomposeStage("выведи всех заков за все время", null);
expect(result).not.toBeNull();
@@ -5889,7 +6005,7 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки(");
expect(plan.query).toContain("Документ.ПоступлениеТоваровУслуг.Товары");
expect(plan.query).toContain("Остатки.КоличествоРазвернутыйОстатокДт > 0");
expect(plan.query).toContain("Товары.Номенклатура В");
expect(plan.query).not.toContain("Товары.Номенклатура В");
expect(plan.query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2026, 4, 18, 23, 59, 59)");
});
@@ -6045,6 +6161,8 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text.split("\n")[0]).toContain("выявлен поставщик закупочного следа");
expect(reply.text.split("\n")[0]).toContain("Торговый дом");
expect(reply.text).toContain("Что проверили:");
expect(reply.text).toContain("Опорные документы:");
expect(reply.text).not.toContain("exact-контур");
@@ -6070,17 +6188,133 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
],
{
asOfDate: "2021-09-30",
userMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику",
userMessage: "Покажи закупочный след текущего складского остатка",
rawUserMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику",
useRubCurrency: true
}
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text.split("\n")[0]).toContain("без явно выделенного поставщика");
expect(reply.text).toContain("Позиции без явно выделенного поставщика:");
expect(reply.text.split("\n")[0]).toContain("без надежной привязки к поставщику");
expect(reply.text.split("\n")[0]).toContain("первые примеры ниже");
expect(reply.text).toContain("Позиции без надежной привязки к поставщику:");
expect(reply.text).toContain("Товар без поставщика");
expect(reply.text).toContain("Опорные документы:");
expect(reply.text).not.toContain("Уточните");
});
it("derives unresolved supplier-link month filters from raw user wording before organization clarification", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("По какому поставщику проходит текущий товарный остаток", {
analysisDateHint: "2021-09-30",
knownOrganizations: ['ООО "Альтернатива Плюс"', 'ООО "Актив"'],
rawUserMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику"
});
expect(result?.debug.detected_intent).toBe("inventory_supplier_stock_overlap_as_of_date");
expect(result?.debug.extracted_filters).toMatchObject({
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30"
});
});
it("keeps broad supplier-overlap answers direct-first when supplier is not separated", () => {
const reply = composeFactualReply(
"inventory_supplier_stock_overlap_as_of_date",
[
{
period: "2021-09-30T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000999 от 30.09.2021 0:00:00",
account_dt: "41.01",
account_kt: "60.01",
amount: 1200,
analytics: ["Товар без поставщика", "Основной склад"],
item: "Товар без поставщика",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"'
}
],
{
asOfDate: "2021-09-30",
useRubCurrency: true
}
);
const firstLine = reply.text.split("\n")[0] ?? "";
expect(firstLine).toContain("По складскому остатку Основной склад");
expect(firstLine).toContain("однозначный поставщик текущего остатка не подтвержден");
expect(firstLine).toContain("1");
expect(firstLine).not.toContain("Часть закупочных операций");
expect(reply.text).not.toContain("Часть закупочных операций");
expect(reply.text).toContain("закупочных операций без явно выделенного поставщика");
});
it("repairs supplier-overlap lead line when evidence rows display counterparties", () => {
const staleText = [
"По складскому остатку Основной склад на 30.09.2021 однозначная атрибуция части текущего остатка не подтверждена: 192 закупочных операций без явно выделенного поставщика.",
"Что проверили:",
"- Дата среза: 30.09.2021.",
"Опорные документы:",
"1. Поступление товаров и услуг 00000000001 | контрагент: А",
"2. Поступление товаров и услуг 00000000030 | контрагент: ВИЗАНТИЯ",
"3. Поступление товаров и услуг 00000000031 | контрагент: ИП Тучкова"
].join("\n");
const repaired = ensureInventorySupplierOverlapLeadLine("inventory_supplier_stock_overlap_as_of_date", staleText);
const lines = repaired.split("\n");
expect(lines[0]).toContain("найдено несколько поставщиков закупочного следа");
expect(lines[0]).toContain("А");
expect(lines[0]).toContain("ВИЗАНТИЯ");
expect(lines[1]).toContain("однозначная атрибуция части текущего остатка не подтверждена");
});
it("answers supplier-scoped stock questions with item list first", () => {
const reply = composeFactualReply(
"inventory_supplier_stock_overlap_as_of_date",
[
{
period: "2019-02-12T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
account_dt: "41.01",
account_kt: "60.01",
amount: 3075,
analytics: ["Столешница 600*3050*26 альмандин", "Основной склад", "Торговый дом \"Союз МСК\""],
item: "Столешница 600*3050*26 альмандин",
warehouse: "Основной склад",
counterparty: "Торговый дом \"Союз МСК\"",
organization: 'ООО "Альтернатива Плюс"'
},
{
period: "2019-02-12T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
account_dt: "41.01",
account_kt: "60.01",
amount: 3724.17,
analytics: ["Столешница 600*3050*26 дуб ниагара", "Основной склад", "Торговый дом \"Союз МСК\""],
item: "Столешница 600*3050*26 дуб ниагара",
warehouse: "Основной склад",
counterparty: "Торговый дом \"Союз МСК\"",
organization: 'ООО "Альтернатива Плюс"'
}
],
{
asOfDate: "2021-09-30",
counterpartyHint: "Торговый дом \\Союз",
userMessage: "Какие товары от поставщика Торговый дом \\Союз сейчас еще лежат на складе Основной склад",
useRubCurrency: true
}
);
const firstLine = reply.text.split("\n")[0] ?? "";
expect(firstLine).toContain("подтверждены позиции");
expect(firstLine).toContain("Столешница 600*3050*26 альмандин");
expect(firstLine).toContain("Столешница 600*3050*26 дуб ниагара");
expect(firstLine).not.toContain("выявлен поставщик закупочного следа");
expect(reply.text).toContain("Позиции:");
});
it("routes inventory provenance questions to a dedicated intent", () => {
const result = resolveAddressIntent("От какого поставщика куплен товар Шкаф картоотечный?");
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
@@ -6145,6 +6379,11 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
expect(result.intent).toBe("inventory_aging_by_purchase_date");
});
it("routes old-purchase residue follow-up wording to aging intent", () => {
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");
@@ -526,6 +526,73 @@ describe("address reply builders regressions", () => {
expect(result?.text).toContain("Следующий шаг: могу раскрыть полный список");
});
it("surfaces account and warehouse scope in on-hand snapshot top line", () => {
const result = composeInventoryReply(
"inventory_on_hand_as_of_date",
[
{
amount: 614098.33,
quantity: 13,
item: "Моноблок леново",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
period: "2019-03-31",
registrator: "Остатки"
} as any
],
{
userMessage: "Какие товары числятся на 41 счете на дату 2019-03-31",
accountHint: "41",
warehouseHint: "Основной склад",
asOfDate: "2019-03-31"
},
{
resolvePayablesAsOfDate: () => "2019-03-31",
buildInventoryOnHandAggregate: () => [
{
item: "Моноблок леново",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
quantity: 13,
amount: 614098.33,
operations: 1,
firstPeriod: "2019-03-31",
lastPeriod: "2019-03-31",
sourceRefs: []
}
],
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
formatDateRu: (value: string) => value,
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
formatMoneyRub: (value: number) => `${value} ₽`,
isInventoryPurchaseMovement: () => false,
summarizeInventoryTraceRows: () => ({
item: null,
warehouses: [],
organizations: [],
counterparties: [],
documents: [],
firstPeriod: null,
lastPeriod: null,
totalAmount: 0
}),
formatInventoryTraceRows: () => [],
hasInventoryPurchaseDateActionFocus: () => false,
inventoryTraceDateLabel: () => "",
extractInventoryCounterpartyCandidates: () => [],
buildInventoryAgingByItemAggregate: () => [],
formatInventoryAgingRows: () => [],
isInventorySaleMovement: () => false
}
);
const firstLine = result?.text.split("\n")[0] ?? "";
expect(firstLine).toContain("по счету 41");
expect(firstLine).toContain("по складу Основной склад");
expect(result?.text).toContain("Срез по счету 41.");
expect(result?.text).toContain("Срез по складу Основной склад.");
});
it("answers cost-base evidence follow-up directly when margin ranking has sales without confirmed cost", () => {
const result = composeInventoryReply(
"inventory_margin_ranking_for_nomenclature",
@@ -753,4 +820,87 @@ describe("address reply builders regressions", () => {
expect(result?.text).not.toContain("сумма:");
expect(result?.text).not.toContain("8.520,00 ₽");
});
it("keeps supplier-overlap stock answer direct-first and compact on broad warehouse traces", () => {
const rows = [
{
period: "2021-03-09",
registrator: "Поступление товаров и услуг 00000000001",
item: "Рабочая станция",
warehouse: "Основной склад",
counterparty: "А"
},
{
period: "2020-11-20",
registrator: "Поступление товаров и услуг 00000000030",
item: "Зеркало",
warehouse: "Основной склад",
counterparty: "ВИЗАНТИЯ"
},
{
period: "2020-07-13",
registrator: "Поступление товаров и услуг 00000000029",
item: "Стул",
warehouse: "Основной склад",
counterparty: "ИП Тучкова"
},
{
period: "2019-01-10",
registrator: "Поступление товаров и услуг 00000000077",
item: "Позиция без поставщика",
warehouse: "Основной склад"
}
] as any[];
const result = composeInventoryReply(
"inventory_supplier_stock_overlap_as_of_date",
rows,
{
userMessage: "По какому поставщику проходит текущий товарный остаток на складе Основной склад",
asOfDate: "2021-09-30"
},
{
resolvePayablesAsOfDate: () => "2021-09-30",
buildInventoryOnHandAggregate: () => [],
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
formatDateRu: (value: string) => value,
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
formatMoneyRub: (value: number) => `${value} ₽`,
isInventoryPurchaseMovement: () => true,
summarizeInventoryTraceRows: (inputRows: any[]) => ({
item: inputRows[0]?.item ?? null,
warehouses: ["Основной склад"],
organizations: [],
counterparties: [],
documents: inputRows.map((row) => String(row.registrator ?? "")),
firstPeriod: "2019-01-10",
lastPeriod: "2021-03-09",
totalAmount: 0
}),
formatInventoryTraceRows: (inputRows: any[], limit = 10) =>
inputRows.slice(0, limit).map((row, index) => {
const parts = [`${index + 1}. ${row.registrator}`];
if (row.counterparty) {
parts.push(`контрагент: ${row.counterparty}`);
}
return parts.join(" | ");
}),
hasInventoryPurchaseDateActionFocus: () => false,
inventoryTraceDateLabel: (value: string | null) => String(value ?? ""),
extractInventoryCounterpartyCandidates: () => [],
buildInventoryAgingByItemAggregate: () => [],
formatInventoryAgingRows: () => [],
isInventorySaleMovement: () => false
}
);
const text = result?.text ?? "";
const firstLine = text.split("\n")[0] ?? "";
expect(firstLine).toContain("найдено несколько поставщиков закупочного следа");
expect(firstLine).toContain("А");
expect(firstLine).toContain("ВИЗАНТИЯ");
expect(text.indexOf(firstLine)).toBeLessThan(text.indexOf("Что проверили:"));
expect(text).toContain("однозначная атрибуция части текущего остатка не подтверждена: 1 закупочных операций без явно выделенного поставщика.");
expect(text).not.toContain("Часть закупочных операций");
expect(text.match(/^\d+\./gmu)?.length ?? 0).toBeLessThanOrEqual(3);
});
});
@@ -100,6 +100,126 @@ describe("assistant address lane response runtime adapter", () => {
);
});
it("repairs inventory supplier-overlap lead line before finalizing the user reply", () => {
const finalizeAddressTurn = vi.fn(() => ({
response: {
ok: true
}
}));
const runtime = runAssistantAddressLaneResponseRuntime({
sessionId: "asst-supplier-overlap",
userMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
effectiveAddressUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
addressLane: {
handled: true,
reply_text: [
"По складскому остатку Основной склад на 30.09.2021 однозначная атрибуция части текущего остатка не подтверждена: 192 закупочных операций без явно выделенного поставщика.",
"Что проверили:",
"- Дата среза: 30.09.2021.",
"Опорные документы:",
"1. Поступление товаров и услуг 00000000001 | контрагент: А",
"2. Поступление товаров и услуг 00000000030 | контрагент: ВИЗАНТИЯ",
"3. Поступление товаров и услуг 00000000031 | контрагент: ИП Тучкова"
].join("\n"),
reply_type: "factual",
debug: {
extracted_filters: {
warehouse: "Основной склад",
as_of_date: "2021-09-30"
}
}
},
knownOrganizations: [],
activeOrganization: null,
sanitizeOutgoingAssistantText: (text) =>
String(text ?? "")
.split(/\r?\n/u)
.filter((line) => !line.includes("найдено несколько поставщиков закупочного следа"))
.join("\n")
.trim(),
buildAddressDebugPayload: (addressDebug) => ({
...(addressDebug as Record<string, unknown>),
detected_intent: "inventory_supplier_stock_overlap_as_of_date"
}),
buildAddressFollowupOffer: () => null,
mergeKnownOrganizations: (items) => Array.from(new Set(items)),
toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null),
appendItem: () => {},
getSession: () => ({ session_id: "asst-supplier-overlap", updated_at: "", items: [], investigation_state: null } as any),
persistSession: () => {},
cloneConversation: (items) => items,
logEvent: () => {},
messageIdFactory: () => "msg-supplier-overlap",
finalizeAddressTurn
});
const assistantReply = (finalizeAddressTurn.mock.calls[0]?.[0] as any)?.assistantReply ?? "";
const nonEmptyLines = assistantReply.split("\n").filter((line: string) => line.trim().length > 0);
expect(nonEmptyLines[0]).toContain("найдено несколько поставщиков закупочного следа");
expect(nonEmptyLines[0]).toContain("А");
expect(nonEmptyLines[1]).toContain("однозначная атрибуция части текущего остатка не подтверждена");
expect(runtime.debug.inventory_supplier_overlap_surface_repair_v1).toEqual(
expect.objectContaining({
applied: true,
lead_preserved_after_sanitize: true,
counterparties: ["А", "ВИЗАНТИЯ", "ИП Тучкова"]
})
);
});
it("persists inventory root frame filters with a single warehouse derived from the factual reply", () => {
const runtime = runAssistantAddressLaneResponseRuntime({
sessionId: "asst-inventory-root",
userMessage: "какие остатки на складе на сентябрь 2021",
effectiveAddressUserMessage: "какие остатки на складе на сентябрь 2021",
addressLane: {
handled: true,
reply_text:
'На 30.09.2021 по складу Основной склад подтверждено 11 позиций.\n1. Диван | склад: Основной склад | организация: ООО "Альтернатива Плюс"',
reply_type: "factual",
debug: {
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30"
}
}
},
knownOrganizations: ['ООО "Альтернатива Плюс"'],
activeOrganization: 'ООО "Альтернатива Плюс"',
sanitizeOutgoingAssistantText: (text) => String(text ?? "").trim(),
buildAddressDebugPayload: (addressDebug) => ({ ...(addressDebug as Record<string, unknown>) }),
buildAddressFollowupOffer: () => null,
mergeKnownOrganizations: (items) => Array.from(new Set(items)),
toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null),
appendItem: () => {},
getSession: () => ({ session_id: "asst-inventory-root", updated_at: "", items: [], investigation_state: null } as any),
persistSession: () => {},
cloneConversation: (items) => items,
logEvent: () => {},
messageIdFactory: () => "msg-inventory-root",
finalizeAddressTurn: () => ({ response: { ok: true } as any })
});
expect(runtime.debug.address_root_frame_context).toEqual(
expect.objectContaining({
root_intent: "inventory_on_hand_as_of_date",
current_frame_kind: "inventory_root",
warehouse: "Основной склад",
root_filters: expect.objectContaining({
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30"
})
})
);
});
it("attaches MCP discovery summary from predecompose runtime meta without changing the reply", () => {
const runtime = runAssistantAddressLaneResponseRuntime({
sessionId: "asst-mcp",
@@ -5,6 +5,7 @@ import {
applyReferencedEntityCarryover,
applySelectedItemCarryover,
applyTemporalCarryoverFilters,
buildInventoryRootFrameFromAddressDebug,
buildRootScopedCarryoverFilters,
hydrateInventoryRootFrameState,
readAddressDebugCounterparty,
@@ -479,6 +480,35 @@ describe("assistantContinuityPolicy organization authority", () => {
});
});
it("builds inventory root frame from compact root-frame context without leaking drilldown item scope", () => {
const frame = buildInventoryRootFrameFromAddressDebug({
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item: "Четки Пост (84*117)",
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2021-09-30"
},
address_root_frame_context: {
root_intent: "inventory_on_hand_as_of_date",
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30",
current_frame_kind: "inventory_drilldown"
}
});
expect(frame?.filters).toEqual({
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30"
});
expect(frame?.filters).not.toHaveProperty("item");
});
it("does not carry MCP discovery counterparty from a stale replay-forbidden turn", () => {
const filters = resolveAddressDebugCarryoverFilters({
detected_intent: "payables_confirmed_as_of_date",
@@ -439,6 +439,108 @@ describe("assistant MCP discovery response policy", () => {
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
});
it("keeps exact inventory purchase-to-sale replies over stale value-flow clarification candidates", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply:
"По товару Шкаф картотечный 1000*400*2100 закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.",
currentReplySource: "address_query_runtime_v1",
currentReplyType: "factual",
addressRuntimeMeta: {
detected_intent: "inventory_purchase_to_sale_chain",
selected_recipe: "address_inventory_purchase_to_sale_chain_v1",
mcp_call_status: "matched_non_empty",
truth_mode: "confirmed",
capability_binding_status: "bound",
capability_binding_violations: [],
answer_shape_contract: {
reply_type: "factual",
capability_contract_id: "inventory_inventory_purchase_to_sale_chain"
},
assistant_mcp_discovery_entry_point_v1: entryPoint({
bridge: {
bridge_status: "needs_clarification",
user_facing_response_allowed: true,
business_fact_answer_allowed: false,
requires_user_clarification: true,
answer_draft: {
answer_mode: "clarification_required",
headline:
"Коротко: Могу посчитать общий денежный поток в проверяемом окне, но для проверяемого поиска в 1С нужен проверяемый период.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: "Уточните период, и я продолжу поиск по денежному потоку в 1С."
}
},
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "counterparty_value",
asked_action_family: "payout",
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
},
data_need_graph: {
business_fact_family: "value_flow",
action_family: "payout",
clarification_gaps: ["period"]
}
}
})
}
});
expect(result.applied).toBe(false);
expect(result.decision).toBe("keep_current_reply");
expect(result.reply_text).toContain("закупочная часть цепочки подтверждена");
expect(result.reply_text).not.toContain("денежный поток");
expect(result.reason_codes).toContain(
"mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification"
);
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_candidate_applied");
});
it("keeps confirmed supplier-overlap address replies over entity-resolution discovery candidates", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply:
'По складскому остатку Основной склад на 30.09.2021 выявлен поставщик закупочного следа: Торговый дом "Союз МСК".',
currentReplySource: "address_query_runtime_v1",
currentReplyType: "factual",
addressRuntimeMeta: {
detected_intent: "inventory_supplier_stock_overlap_as_of_date",
selected_recipe: "address_inventory_supplier_stock_overlap_as_of_date_v1",
mcp_call_status: "matched_non_empty",
truth_mode: "confirmed",
capability_binding_status: "bound",
capability_binding_violations: [],
answer_shape_contract: {
reply_type: "factual",
capability_contract_id: "inventory_inventory_supplier_stock_overlap_as_of_date"
},
assistant_mcp_discovery_entry_point_v1: entryPoint({
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "entity_resolution",
asked_action_family: "search_business_entity",
unsupported_but_understood_family: "entity_resolution",
explicit_entity_candidates: ['Торговый дом "Союз"']
}
}
})
}
});
expect(result.applied).toBe(false);
expect(result.decision).toBe("keep_current_reply");
expect(result.reply_text).toContain("выявлен поставщик закупочного следа");
expect(result.reply_text).not.toContain("Confirmed fact");
expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply");
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
});
it("lets metadata discovery override a stale exact address answer outside protected inventory chains", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "ИП Калинин Н.М. | сумма: 216600",
@@ -336,6 +336,38 @@ describe("assistantTurnMeaningPolicy", () => {
expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal");
});
it.each([
{
rawUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
effectiveAddressUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад"
},
{
rawUserMessage: "По какому поставщику проходит текущий товарный остаток на складе Основной склад",
effectiveAddressUserMessage:
"Определить поставщика, с которого получен текущий товарный остаток на складе Основной склад"
},
{
rawUserMessage: "Какие товары от поставщика Торговый дом \\Союз сейчас еще лежат на складе Основной склад",
effectiveAddressUserMessage:
"Найти товары, которые находятся на складе Основной склад и были поставлены поставщиком Торговый дом \\Союз"
}
])("keeps supplier stock-overlap wording in the exact inventory lane", ({ rawUserMessage, effectiveAddressUserMessage }) => {
const policy = buildPolicy();
const meaning = policy.resolveAssistantTurnMeaning({
rawUserMessage,
effectiveAddressUserMessage
});
expect(meaning.explicit_intent_candidate).toBe("inventory_supplier_stock_overlap_as_of_date");
expect(meaning.asked_domain_family).toBe("inventory");
expect(meaning.asked_action_family).toBe("supplier_overlap");
expect(meaning.unsupported_but_understood_family).toBeNull();
expect(meaning.stale_replay_forbidden).toBe(false);
expect(meaning.reason_codes).toContain("address_intent_resolver_current_turn_signal");
expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal");
});
it("treats paired receivables/payables position as business overview instead of one debt side", () => {
const policy = buildPolicy();