Доказать полезность margin-agent через честный boundary replay

This commit is contained in:
2026-05-24 17:52:57 +03:00
parent 98bdff31dc
commit f69393a887
14 changed files with 302 additions and 8 deletions
@@ -1672,7 +1672,8 @@ function hasNomenclatureMarginRankingSignal(text) {
const hasNomenclatureCue = /(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(normalized);
const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && hasRankingCue;
const hasCalculationCue = /(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue);
}
function hasVatPeriodInspectionBridgeSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase();
@@ -32,7 +32,8 @@ function hasInventoryMarginRankingSignal(text) {
const hasNomenclatureCue = /(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(normalized);
const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && hasRankingCue;
const hasCalculationCue = /(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(normalized);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue);
}
function hasInventoryOnHandSignal(text) {
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(text);
@@ -17,6 +17,11 @@ const ADDRESS_ACTION_TOKENS = [
"показ",
"проверь",
"провер",
"посчитай",
"посчитать",
"рассчитай",
"рассчитать",
"понять",
"чекни",
"чекн",
"глянь",
@@ -102,6 +107,10 @@ const ADDRESS_ENTITY_TOKENS = [
"чек",
"доход",
"выруч",
"прибыл",
"марж",
"рентаб",
"себестоим",
"сделк",
"бюджет",
"топ",
@@ -402,6 +411,14 @@ function detectAddressQuestionMode(userMessage) {
const hasFollowupSignal = hasAddressFollowupSignal(text);
const hasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignal(text);
const hasAccountCode = hasAccountCodeAnchor(text);
const hasInventoryProfitabilitySignal = (0, inventoryLifecycleCueHelpers_1.hasInventoryProfitabilityCue)(text);
if (hasInventoryProfitabilitySignal && hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "high",
reasons: ["inventory_profitability_signal_detected", "address_entity_detected"]
};
}
if (hasAddressAction && (hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
return {
mode: "address_query",
@@ -92,6 +92,12 @@ function asksForInventoryMarginBasis(userMessage) {
const text = String(userMessage ?? "").toLowerCase();
return (/(?:из\s+чего|как\s+(?:ты\s+)?(?:это\s+)?посчитал|какие\s+поля|чего\s+не\s+хватает|не\s+хватает|точн(?:ой|ая|ую)?\s+марж|basis|source|fields|calculated|missing)/iu.test(text) && /(?:марж|прибыл|себестоимост|выручк|margin|profit|cogs|revenue)/iu.test(text));
}
function asksInventoryMarginFromPaymentOrBank(userMessage) {
const text = String(userMessage ?? "").toLowerCase();
return (/(?:марж|прибыл|рентаб|profit|margin)/iu.test(text) &&
/(?:товар|номенклатур|inventory|item|sku)/iu.test(text) &&
/(?:банк|банковск|выписк|плат[её]ж|оплат|payment|bank|statement)/iu.test(text));
}
function inventoryRowItemLabel(row, deps) {
return deps.summarizeInventoryTraceRows([row]).item;
}
@@ -471,6 +477,26 @@ function composeInventoryReply(intent, rows, options, deps) {
const totalSpread = totalRevenue - totalCostProxy;
const topMarginEntry = highMargin[0] ?? null;
const marginBasisRequested = asksForInventoryMarginBasis(options.userMessage);
const paymentOrBankFalseSourceRequested = asksInventoryMarginFromPaymentOrBank(options.userMessage);
if (paymentOrBankFalseSourceRequested) {
const lines = [
"По оплатам и банку такой показатель нельзя честно подтвердить: платежи показывают денежный поток и факт оплаты, а не связь реализации с себестоимостью по номенклатуре."
];
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Корректная база для маржинальности:", [
"выручка реализации по номенклатуре;",
"себестоимостная база реализации по той же номенклатуре;",
"валовая разница и процент валовой маржи."
]);
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что можно сделать дальше:", [
`посчитать управленческий рейтинг по выручке и себестоимостной базе за период ${periodLabel};`,
"отдельно сверить оплаты и банк как денежный поток, но не использовать их как расчетную базу."
]);
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Граница ответа:", [
"оплаты могут помочь сверить поступление денег, но сами по себе не подтверждают валовую прибыль по товарам;",
"строгий бухгалтерский расчет требует проводок реализации и себестоимости, а не только банковских движений."
]);
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", false));
}
if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines = [
@@ -2167,7 +2167,11 @@ function hasNomenclatureMarginRankingSignal(text: string): boolean {
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(
normalized
);
return hasNomenclatureCue && hasMarginCue && hasRankingCue;
const hasCalculationCue =
/(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(
normalized
);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue);
}
function hasVatPeriodInspectionBridgeSignal(text: string): boolean {
@@ -53,7 +53,11 @@ function hasInventoryMarginRankingSignal(text: string): boolean {
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(
normalized
);
return hasNomenclatureCue && hasMarginCue && hasRankingCue;
const hasCalculationCue =
/(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(
normalized
);
return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue);
}
function hasInventoryOnHandSignal(text: string): boolean {
@@ -22,6 +22,11 @@ const ADDRESS_ACTION_TOKENS = [
"показ",
"проверь",
"провер",
"посчитай",
"посчитать",
"рассчитай",
"рассчитать",
"понять",
"чекни",
"чекн",
"глянь",
@@ -108,6 +113,10 @@ const ADDRESS_ENTITY_TOKENS = [
"чек",
"доход",
"выруч",
"прибыл",
"марж",
"рентаб",
"себестоим",
"сделк",
"бюджет",
"топ",
@@ -427,6 +436,15 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
const hasFollowupSignal = hasAddressFollowupSignal(text);
const hasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignal(text);
const hasAccountCode = hasAccountCodeAnchor(text);
const hasInventoryProfitabilitySignal = hasInventoryProfitabilityCue(text);
if (hasInventoryProfitabilitySignal && hasAddressEntity && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "high",
reasons: ["inventory_profitability_signal_detected", "address_entity_detected"]
};
}
if (hasAddressAction && (hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
return {
@@ -179,6 +179,15 @@ function asksForInventoryMarginBasis(userMessage: string | null | undefined): bo
);
}
function asksInventoryMarginFromPaymentOrBank(userMessage: string | null | undefined): boolean {
const text = String(userMessage ?? "").toLowerCase();
return (
/(?:марж|прибыл|рентаб|profit|margin)/iu.test(text) &&
/(?:товар|номенклатур|inventory|item|sku)/iu.test(text) &&
/(?:банк|банковск|выписк|плат[её]ж|оплат|payment|bank|statement)/iu.test(text)
);
}
interface InventoryMarginRankingEntry {
item: string;
revenue: number;
@@ -649,6 +658,26 @@ export function composeInventoryReply(
const totalSpread = totalRevenue - totalCostProxy;
const topMarginEntry = highMargin[0] ?? null;
const marginBasisRequested = asksForInventoryMarginBasis(options.userMessage);
const paymentOrBankFalseSourceRequested = asksInventoryMarginFromPaymentOrBank(options.userMessage);
if (paymentOrBankFalseSourceRequested) {
const lines = [
"По оплатам и банку такой показатель нельзя честно подтвердить: платежи показывают денежный поток и факт оплаты, а не связь реализации с себестоимостью по номенклатуре."
];
appendInventoryBulletSection(lines, "Корректная база для маржинальности:", [
"выручка реализации по номенклатуре;",
"себестоимостная база реализации по той же номенклатуре;",
"валовая разница и процент валовой маржи."
]);
appendInventoryBulletSection(lines, "Что можно сделать дальше:", [
`посчитать управленческий рейтинг по выручке и себестоимостной базе за период ${periodLabel};`,
"отдельно сверить оплаты и банк как денежный поток, но не использовать их как расчетную базу."
]);
appendInventoryBulletSection(lines, "Граница ответа:", [
"оплаты могут помочь сверить поступление денег, но сами по себе не подтверждают валовую прибыль по товарам;",
"строгий бухгалтерский расчет требует проводок реализации и себестоимости, а не только банковских движений."
]);
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false));
}
if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines: string[] = [
@@ -27,6 +27,23 @@ describe("addressInventoryIntentSignals", () => {
expect(result?.reasons).toContain("inventory_margin_ranking_signal_detected");
});
it("classifies calculate-margin nomenclature wording with false-source guards as margin ranking", () => {
const result = resolveInventoryAddressIntent(
"\u041f\u043e\u0441\u0447\u0438\u0442\u0430\u0439 \u043c\u0430\u0440\u0436\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u0442\u043e\u0432\u0430\u0440\u043d\u043e\u0439 \u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440\u044b \u0437\u0430 2020 \u0433\u043e\u0434, \u043d\u0435 \u041e\u0421 \u0438 \u043d\u0435 \u0430\u043c\u043e\u0440\u0442\u0438\u0437\u0430\u0446\u0438\u044e."
);
expect(result?.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.reasons).toContain("inventory_margin_ranking_signal_detected");
});
it("keeps payment-bank false-source wording in margin contour", () => {
const result = resolveAddressIntent(
"\u041c\u043e\u0436\u043d\u043e \u0431\u044b\u0441\u0442\u0440\u043e \u043f\u043e\u043d\u044f\u0442\u044c \u043c\u0430\u0440\u0436\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u0442\u043e\u0432\u0430\u0440\u043e\u0432 \u0437\u0430 2020 \u0433\u043e\u0434 \u043f\u043e \u043e\u043f\u043b\u0430\u0442\u0430\u043c \u0438 \u0431\u0430\u043d\u043a\u0443?"
);
expect(result.intent).toBe("inventory_margin_ranking_for_nomenclature");
});
it("classifies selected-object purchase provenance wording through the extracted inventory owner", () => {
const result = resolveInventoryAddressIntent("selected object supplier provenance");
@@ -131,6 +131,15 @@ describe("address query shape classifier", () => {
expect(result.mode).toBe("address_query");
});
it("keeps calculate margin wording in address lane before bank false-source cues can steal it", () => {
const result = detectAddressQuestionMode(
"\u041c\u043e\u0436\u043d\u043e \u0431\u044b\u0441\u0442\u0440\u043e \u043f\u043e\u043d\u044f\u0442\u044c \u043c\u0430\u0440\u0436\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u0442\u043e\u0432\u0430\u0440\u043e\u0432 \u0437\u0430 2020 \u0433\u043e\u0434 \u043f\u043e \u043e\u043f\u043b\u0430\u0442\u0430\u043c \u0438 \u0431\u0430\u043d\u043a\u0443?"
);
expect(result.mode).toBe("address_query");
expect(result.reasons).toContain("inventory_profitability_signal_detected");
});
it("extracts item anchor for inventory provenance questions", () => {
const filters = extractAddressFilters(
"От какого поставщика куплен товар Шкаф картотечный?",
@@ -579,4 +579,66 @@ describe("address reply builders regressions", () => {
expect(result?.text).not.toContain("входящих денежных поступлений");
expect(result?.text).not.toContain("амортизац");
});
it("answers payment-bank margin false-source questions as a boundary before any ranking", () => {
const result = composeInventoryReply(
"inventory_margin_ranking_for_nomenclature",
[
{
amount: 1000,
quantity: 1,
item: "Товар A",
period: "2020-05-20",
registrator: "Реализация товаров"
} as any,
{
amount: 400,
quantity: 1,
item: "Товар A",
period: "2020-01-10",
registrator: "Поступление товаров"
} as any
],
{
userMessage:
"Можно быстро понять маржинальность товаров за 2020 год по оплатам и банку?",
periodFrom: "2020-01-01",
periodTo: "2020-12-31"
},
{
resolvePayablesAsOfDate: () => "2020-12-31",
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: (row: any) => String(row.registrator ?? "").includes("Поступление"),
summarizeInventoryTraceRows: (rows: any[]) => ({
item: rows[0]?.item ?? null,
warehouses: [],
organizations: [],
counterparties: [],
documents: [],
firstPeriod: null,
lastPeriod: null,
totalAmount: 0
}),
formatInventoryTraceRows: () => [],
hasInventoryPurchaseDateActionFocus: () => false,
inventoryTraceDateLabel: () => "",
extractInventoryCounterpartyCandidates: () => [],
buildInventoryAgingByItemAggregate: () => [],
formatInventoryAgingRows: () => [],
isInventorySaleMovement: (row: any) => String(row.registrator ?? "").includes("Реализация")
}
);
expect(result?.text.split("\n")[0]).toContain("По оплатам и банку");
expect(result?.text.split("\n")[0]).toContain("нельзя честно подтвердить");
expect(result?.text).toContain("выручка реализации");
expect(result?.text).toContain("себестоимостная база");
expect(result?.text).toContain("Что можно сделать дальше");
expect(result?.text).not.toContain("Самая маржинальная позиция");
expect(result?.text).not.toMatch(/(?:оплат[аы]|банк|payment_document).{0,80}(?:источник|достаточ|посчитал|марж[ау])/iu);
});
});