Усилить reliability gate и принять margin semantic replay
This commit is contained in:
@@ -2159,19 +2159,15 @@ function hasNomenclatureMarginRankingSignal(text: string): boolean {
|
||||
}
|
||||
const hasNomenclatureCue =
|
||||
/(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
|
||||
const hasRealizationCue =
|
||||
/(?:реализован|реализац|продан|продаж|отгруж|41(?:[.,]0?1)?|90(?:[.,]\d{1,2})?|sales?|sold)/iu.test(
|
||||
normalized
|
||||
);
|
||||
const hasMarginCue =
|
||||
/(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(
|
||||
normalized
|
||||
);
|
||||
const hasRankingCue =
|
||||
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые)|больш|меньш|ранж|рейтинг|high|low|top|rank|best|worst)/iu.test(
|
||||
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(
|
||||
normalized
|
||||
);
|
||||
return hasNomenclatureCue && hasRealizationCue && hasMarginCue && hasRankingCue;
|
||||
return hasNomenclatureCue && hasMarginCue && hasRankingCue;
|
||||
}
|
||||
|
||||
function hasVatPeriodInspectionBridgeSignal(text: string): boolean {
|
||||
|
||||
@@ -35,6 +35,27 @@ function hasPlainRussianInventoryOnHandSignal(text: string): boolean {
|
||||
return hasRequestCue && (hasSnapshotCue || /остатк/iu.test(normalized));
|
||||
}
|
||||
|
||||
function hasInventoryMarginRankingSignal(text: string): boolean {
|
||||
const normalized = String(text ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function hasInventoryOnHandSignal(text: string): boolean {
|
||||
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(
|
||||
text
|
||||
@@ -54,6 +75,7 @@ function hasInventoryOnHandSignal(text: string): boolean {
|
||||
hasInventoryPurchaseDocumentsSignalV2(text) ||
|
||||
hasInventorySaleTraceSignalV2(text) ||
|
||||
hasInventoryAgingSignal(text) ||
|
||||
hasInventoryMarginRankingSignal(text) ||
|
||||
hasInventoryPurchaseToSaleChainSignal(text)
|
||||
) {
|
||||
return false;
|
||||
@@ -304,6 +326,14 @@ export function resolveInventoryAddressIntent(text: string): AddressIntentResolu
|
||||
};
|
||||
}
|
||||
|
||||
if (hasInventoryMarginRankingSignal(text)) {
|
||||
return {
|
||||
intent: "inventory_margin_ranking_for_nomenclature",
|
||||
confidence: "high",
|
||||
reasons: ["inventory_margin_ranking_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) {
|
||||
return {
|
||||
intent: "inventory_on_hand_as_of_date",
|
||||
|
||||
@@ -812,11 +812,18 @@ export function hasInventoryMarginRankingFollowupCue(text: string): boolean {
|
||||
/(?:покажи|показать|выведи|дай|раскрой|show|list|покажи|показать|выведи|дай|раскрой)/iu.test(normalized) &&
|
||||
/(?:найденн|строк|реализац|себестоимостн|баз|найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
|
||||
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себестоимостн|реализац|марж|прибыл|номенклат)/iu.test(normalized);
|
||||
const asksMarginBasis =
|
||||
/(?:из\s+чего|как\s+(?:ты\s+)?(?:это\s+)?посчитал|почему|какие\s+поля|чего\s+не\s+хватает|не\s+хватает|точн(?:ой|ая|ую)?\s+марж|basis|source|fields|calculated|missing)/iu.test(
|
||||
normalized
|
||||
) &&
|
||||
/(?:марж|прибыл|рентаб|себестоимост|выручк|номенклатур|рейтинг|top|margin|profit|cogs|revenue)/iu.test(
|
||||
normalized
|
||||
);
|
||||
const account41Not01 =
|
||||
/\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
|
||||
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
|
||||
/(?:\bне\b|вместо|а\s+не|not|instead|РЅРµ|вместо|Р°\s+РЅРµ)/iu.test(normalized);
|
||||
return wantsFoundRows || account41Not01;
|
||||
return wantsFoundRows || asksMarginBasis || account41Not01;
|
||||
}
|
||||
|
||||
export function hasAddressFollowupContextSignal(text: string): boolean {
|
||||
@@ -1674,9 +1681,9 @@ function deriveIntentWithFollowupContext(
|
||||
followupContext.root_anchor_type === "item" ||
|
||||
followupContext.current_frame_kind === "inventory_root" ||
|
||||
followupContext.current_frame_kind === "inventory_drilldown";
|
||||
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
|
||||
const inventorySelectedObjectFollowup =
|
||||
inventoryLineageActive &&
|
||||
(hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal));
|
||||
inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal));
|
||||
const previousCounterpartyLaneActive =
|
||||
hasPreviousCounterparty &&
|
||||
(followupContext.previous_anchor_type === "counterparty" ||
|
||||
@@ -1707,6 +1714,7 @@ function deriveIntentWithFollowupContext(
|
||||
detectedIntent.intent === "account_balance_snapshot" ||
|
||||
detectedIntent.intent === "documents_forming_balance" ||
|
||||
detectedIntent.intent === "inventory_margin_ranking_for_nomenclature" ||
|
||||
(detectedIntent.intent === "inventory_profitability_for_item" && !hasSelectedObjectReference) ||
|
||||
detectedIntent.intent === sourceIntent)
|
||||
) {
|
||||
return {
|
||||
|
||||
@@ -170,6 +170,15 @@ function asksForInventoryCostBaseRows(userMessage: string | null | undefined): b
|
||||
return /(?:себестоимостн|себестоимост|себестоим|закупочн|закупк|90\.02|\b41\b|баз)/iu.test(text);
|
||||
}
|
||||
|
||||
function asksForInventoryMarginBasis(userMessage: string | null | undefined): boolean {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
interface InventoryMarginRankingEntry {
|
||||
item: string;
|
||||
revenue: number;
|
||||
@@ -627,17 +636,19 @@ export function composeInventoryReply(
|
||||
const entries = buildInventoryMarginRankingEntries(rows, deps);
|
||||
const confirmedEntries = entries.filter((entry) => entry.revenue > 0 && entry.costProxy > 0);
|
||||
const highMargin = [...confirmedEntries]
|
||||
.sort((left, right) => right.spread - left.spread || (right.marginPct ?? -Infinity) - (left.marginPct ?? -Infinity))
|
||||
.slice(0, 5);
|
||||
.sort((left, right) => (right.marginPct ?? -Infinity) - (left.marginPct ?? -Infinity) || right.spread - left.spread)
|
||||
.slice(0, 3);
|
||||
const lowMargin = [...confirmedEntries]
|
||||
.sort((left, right) => left.spread - right.spread || (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity))
|
||||
.slice(0, 5);
|
||||
.sort((left, right) => (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity) || left.spread - right.spread)
|
||||
.slice(0, 3);
|
||||
const salesWithoutCost = entries.filter((entry) => entry.revenue > 0 && entry.costProxy <= 0);
|
||||
const purchasesWithoutSales = entries.filter((entry) => entry.costProxy > 0 && entry.revenue <= 0);
|
||||
const periodLabel = inventoryProfitabilityPeriodLabel(options, deps);
|
||||
const totalRevenue = entries.reduce((sum, entry) => sum + entry.revenue, 0);
|
||||
const totalCostProxy = entries.reduce((sum, entry) => sum + entry.costProxy, 0);
|
||||
const totalSpread = totalRevenue - totalCostProxy;
|
||||
const topMarginEntry = highMargin[0] ?? null;
|
||||
const marginBasisRequested = asksForInventoryMarginBasis(options.userMessage);
|
||||
if (confirmedEntries.length === 0) {
|
||||
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
|
||||
const lines: string[] = [
|
||||
@@ -700,19 +711,54 @@ export function composeInventoryReply(
|
||||
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics(entries.length > 0 ? "medium" : "weak", false));
|
||||
}
|
||||
const directAnswerLine =
|
||||
confirmedEntries.length > 0
|
||||
? `За период ${periodLabel} собран рейтинг реализованной номенклатуры по валовой маржинальности: выручка ${deps.formatMoneyRub(
|
||||
topMarginEntry && marginBasisRequested
|
||||
? `Считал маржу за период ${periodLabel} как выручку реализации минус доступную себестоимостную базу: выручка ${deps.formatMoneyRub(
|
||||
totalRevenue
|
||||
)}, себестоимостная база ${deps.formatMoneyRub(totalCostProxy)}, расчетная валовая разница ${deps.formatMoneyRub(
|
||||
)}, себестоимостная база ${deps.formatMoneyRub(totalCostProxy)}, валовая разница ${deps.formatMoneyRub(
|
||||
totalSpread
|
||||
)}.`
|
||||
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
|
||||
: topMarginEntry
|
||||
? `Самая маржинальная позиция за период ${periodLabel}: ${topMarginEntry.item} — маржа ${formatInventoryPercent(
|
||||
topMarginEntry.marginPct,
|
||||
deps.formatNumberWithDots
|
||||
)}, выручка ${deps.formatMoneyRub(topMarginEntry.revenue)}, себестоимостная база ${deps.formatMoneyRub(
|
||||
topMarginEntry.costProxy
|
||||
)}, валовая разница ${deps.formatMoneyRub(topMarginEntry.spread)}.`
|
||||
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
|
||||
const lines: string[] = [directAnswerLine];
|
||||
|
||||
if (marginBasisRequested) {
|
||||
appendInventoryBulletSection(lines, "База расчета:", [
|
||||
"выручка: подтвержденные строки реализации по номенклатуре;",
|
||||
"себестоимостная база: доступные строки закупочного/себестоимостного следа по той же номенклатуре;",
|
||||
"валовая маржа: (выручка - себестоимостная база) / выручка."
|
||||
]);
|
||||
const basisLimitations = [
|
||||
"это управленческий расчет валовой маржи, не показатель чистой прибыли;"
|
||||
];
|
||||
if (salesWithoutCost.length > 0) {
|
||||
basisLimitations.push(
|
||||
`по ${deps.formatNumberWithDots(salesWithoutCost.length)} позициям есть продажи без подтвержденной себестоимости реализации;`
|
||||
);
|
||||
}
|
||||
if (purchasesWithoutSales.length > 0) {
|
||||
basisLimitations.push(
|
||||
`по ${deps.formatNumberWithDots(purchasesWithoutSales.length)} позициям есть себестоимостная база без реализации в периоде;`
|
||||
);
|
||||
}
|
||||
basisLimitations.push("для строгого бухгалтерского расчета нужны проводки 90.01 / 90.02 и проверка закрытия себестоимости.");
|
||||
appendInventoryBulletSection(lines, "Чего не хватает для точной маржи:", basisLimitations);
|
||||
lines.push("", "Следующий шаг: могу раскрыть строки выручки и себестоимостной базы по любой позиции из рейтинга.");
|
||||
return buildFactualSummaryReply(
|
||||
lines,
|
||||
buildConfirmedBalanceSemantics(confirmedEntries.length > 0 ? "strong" : entries.length > 0 ? "medium" : "weak", confirmedEntries.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
if (highMargin.length > 0) {
|
||||
appendInventorySection(
|
||||
lines,
|
||||
"Высокая валовая маржинальность:",
|
||||
"Высокая валовая маржинальность (топ по проценту маржи):",
|
||||
highMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps))
|
||||
);
|
||||
}
|
||||
@@ -739,6 +785,7 @@ export function composeInventoryReply(
|
||||
);
|
||||
}
|
||||
appendInventoryBulletSection(lines, "Граница ответа:", boundaryLines);
|
||||
lines.push("", "Следующий шаг: могу раскрыть строки выручки и себестоимостной базы по выбранной позиции из рейтинга.");
|
||||
|
||||
return buildFactualSummaryReply(
|
||||
lines,
|
||||
|
||||
Reference in New Issue
Block a user