Закрепить складской provenance и supplier-overlap ассистента
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user