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

This commit is contained in:
dctouch 2026-05-24 17:52:57 +03:00
parent 98bdff31dc
commit f69393a887
14 changed files with 302 additions and 8 deletions

View File

@ -52,7 +52,7 @@
"inputs": ["steps/<step_id>/output.md", "steps/<step_id>/turn.json"], "inputs": ["steps/<step_id>/output.md", "steps/<step_id>/turn.json"],
"check": { "check": {
"forbidden_patterns": [ "forbidden_patterns": [
"(?i)(амортизац|объект\\s+ОС|основн(ые|ых)?\\s+средств|payment_document|settlement|банк|оплат[аы])" "(?i)(амортизац|объект\\s+ОС|основн(ые|ых)?\\s+средств|payment_document|settlement)"
] ]
} }
}, },
@ -115,7 +115,9 @@
"issue_codes": ["margin_domain_leak_accounting_route"], "issue_codes": ["margin_domain_leak_accounting_route"],
"inputs": ["steps/<step_id>/output.md"], "inputs": ["steps/<step_id>/output.md"],
"check": { "check": {
"forbidden_patterns": ["(?i)(payment_document|банковск|плат[её]ж|оплат[аы]).{0,80}(марж|себестоим|валов)"] "forbidden_patterns": [
"(?i)(payment_document|банковск|плат[её]ж|оплат[аы]).{0,80}(достаточ|посчитал|рассчитал|является\\s+источник|как\\s+источник|на\\s+основании.{0,40}(марж|себестоим|валов))"
]
} }
}, },
"margin_os_amortization_leak": { "margin_os_amortization_leak": {

View File

@ -75,9 +75,12 @@
"detectors": [ "detectors": [
"forbidden_margin_terms", "forbidden_margin_terms",
"missing_revenue_cogs_margin_fields", "missing_revenue_cogs_margin_fields",
"margin_payment_document_false_source",
"wrong_capability_family" "wrong_capability_family"
], ],
"allowed_patch_targets": [ "allowed_patch_targets": [
"llm_normalizer/backend/src/services/addressQueryClassifier.ts",
"llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts",
"llm_normalizer/backend/src/services/addressIntentResolver.ts", "llm_normalizer/backend/src/services/addressIntentResolver.ts",
"llm_normalizer/backend/src/services/addressCapabilityPolicy.ts", "llm_normalizer/backend/src/services/addressCapabilityPolicy.ts",
"llm_normalizer/backend/src/services/addressFilterExtractor.ts", "llm_normalizer/backend/src/services/addressFilterExtractor.ts",

View File

@ -1672,7 +1672,8 @@ function hasNomenclatureMarginRankingSignal(text) {
const hasNomenclatureCue = /(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized); const hasNomenclatureCue = /(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/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); 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) { function hasVatPeriodInspectionBridgeSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase(); const normalized = String(text ?? "").trim().toLowerCase();

View File

@ -32,7 +32,8 @@ function hasInventoryMarginRankingSignal(text) {
const hasNomenclatureCue = /(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized); const hasNomenclatureCue = /(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/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); 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) { function hasInventoryOnHandSignal(text) {
const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(text); const hasColloquialStockSnapshotCue = /(?:что|ч[еёо])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом|ах)(?=$|[\s,.;:!?])/iu.test(text);

View File

@ -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 hasFollowupSignal = hasAddressFollowupSignal(text);
const hasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignal(text); const hasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignal(text);
const hasAccountCode = hasAccountCodeAnchor(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) { if (hasAddressAction && (hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
return { return {
mode: "address_query", mode: "address_query",

View File

@ -92,6 +92,12 @@ function asksForInventoryMarginBasis(userMessage) {
const text = String(userMessage ?? "").toLowerCase(); 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)); 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) { function inventoryRowItemLabel(row, deps) {
return deps.summarizeInventoryTraceRows([row]).item; return deps.summarizeInventoryTraceRows([row]).item;
} }
@ -471,6 +477,26 @@ function composeInventoryReply(intent, rows, options, deps) {
const totalSpread = totalRevenue - totalCostProxy; const totalSpread = totalRevenue - totalCostProxy;
const topMarginEntry = highMargin[0] ?? null; const topMarginEntry = highMargin[0] ?? null;
const marginBasisRequested = asksForInventoryMarginBasis(options.userMessage); 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) { if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage); const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines = [ const lines = [

View File

@ -2167,7 +2167,11 @@ function hasNomenclatureMarginRankingSignal(text: string): boolean {
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test( /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(
normalized 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 { function hasVatPeriodInspectionBridgeSignal(text: string): boolean {

View File

@ -53,7 +53,11 @@ function hasInventoryMarginRankingSignal(text: string): boolean {
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test( /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(
normalized 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 { function hasInventoryOnHandSignal(text: string): boolean {

View File

@ -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 hasFollowupSignal = hasAddressFollowupSignal(text);
const hasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignal(text); const hasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignal(text);
const hasAccountCode = hasAccountCodeAnchor(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) { if (hasAddressAction && (hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
return { return {

View File

@ -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 { interface InventoryMarginRankingEntry {
item: string; item: string;
revenue: number; revenue: number;
@ -649,6 +658,26 @@ export function composeInventoryReply(
const totalSpread = totalRevenue - totalCostProxy; const totalSpread = totalRevenue - totalCostProxy;
const topMarginEntry = highMargin[0] ?? null; const topMarginEntry = highMargin[0] ?? null;
const marginBasisRequested = asksForInventoryMarginBasis(options.userMessage); 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) { if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage); const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines: string[] = [ const lines: string[] = [

View File

@ -27,6 +27,23 @@ describe("addressInventoryIntentSignals", () => {
expect(result?.reasons).toContain("inventory_margin_ranking_signal_detected"); 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", () => { it("classifies selected-object purchase provenance wording through the extracted inventory owner", () => {
const result = resolveInventoryAddressIntent("selected object supplier provenance"); const result = resolveInventoryAddressIntent("selected object supplier provenance");

View File

@ -131,6 +131,15 @@ describe("address query shape classifier", () => {
expect(result.mode).toBe("address_query"); 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", () => { it("extracts item anchor for inventory provenance questions", () => {
const filters = extractAddressFilters( const filters = extractAddressFilters(
"От какого поставщика куплен товар Шкаф картотечный?", "От какого поставщика куплен товар Шкаф картотечный?",

View File

@ -579,4 +579,66 @@ describe("address reply builders regressions", () => {
expect(result?.text).not.toContain("входящих денежных поступлений"); expect(result?.text).not.toContain("входящих денежных поступлений");
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);
});
}); });

View File

@ -256,6 +256,9 @@ GUARDED_INSUFFICIENCY_PRIMARY_MARKERS = (
"\u0442\u043e\u0447\u043d\u044b\u0435", "\u0442\u043e\u0447\u043d\u044b\u0435",
"\u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d", "\u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d",
"\u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0451\u043d", "\u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0451\u043d",
"\u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430",
"\u043d\u0435\u043b\u044c\u0437\u044f \u0447\u0435\u0441\u0442\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434",
"\u043d\u0435\u043b\u044c\u0437\u044f \u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e \u043e\u043f\u0440\u0435\u0434\u0435\u043b",
) )
GUARDED_INSUFFICIENCY_LIMITATION_MARKERS = ( GUARDED_INSUFFICIENCY_LIMITATION_MARKERS = (
"\u043f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u044c\u043d", "\u043f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u044c\u043d",
@ -265,12 +268,16 @@ GUARDED_INSUFFICIENCY_LIMITATION_MARKERS = (
"\u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0451\u043d\u043d\u043e\u0435 \u0441\u0430\u043b\u044c\u0434\u043e", "\u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0451\u043d\u043d\u043e\u0435 \u0441\u0430\u043b\u044c\u0434\u043e",
"\u043d\u0435 \u0434\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442 \u043e\u0441\u0442\u0430\u0442\u043e\u043a", "\u043d\u0435 \u0434\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442 \u043e\u0441\u0442\u0430\u0442\u043e\u043a",
"\u043d\u0435 \u0444\u0438\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0440\u0435\u0435\u0441\u0442\u0440", "\u043d\u0435 \u0444\u0438\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0440\u0435\u0435\u0441\u0442\u0440",
"\u0433\u0440\u0430\u043d\u0438\u0446\u0430 \u043e\u0442\u0432\u0435\u0442\u0430",
"\u0440\u0430\u0441\u0447\u0435\u0442\u043d\u0443\u044e \u0431\u0430\u0437\u0443",
"\u0440\u0430\u0441\u0447\u0451\u0442\u043d\u0443\u044e \u0431\u0430\u0437\u0443",
) )
GUARDED_INSUFFICIENCY_RESULT_MODES = {"heuristic_candidates"} GUARDED_INSUFFICIENCY_RESULT_MODES = {"heuristic_candidates"}
GUARDED_INSUFFICIENCY_TRUTH_MODES = {"limited"} GUARDED_INSUFFICIENCY_TRUTH_MODES = {"limited"}
GUARDED_INSUFFICIENCY_ANSWER_SHAPES = {"limited_with_reason"} GUARDED_INSUFFICIENCY_ANSWER_SHAPES = {"limited_with_reason"}
BUSINESS_EXPECTED_RESULT_MODES = { BUSINESS_EXPECTED_RESULT_MODES = {
"clarification_required", "clarification_required",
"honest_boundary_with_next_action",
"limited_accounting_answer", "limited_accounting_answer",
"evidence_or_honest_boundary", "evidence_or_honest_boundary",
"ranking_or_limited_accounting_answer", "ranking_or_limited_accounting_answer",
@ -966,6 +973,23 @@ def is_margin_profitability_step(step_output: dict[str, Any]) -> bool:
question = str(step_output.get("question_resolved") or step_output.get("question_template") or "") question = str(step_output.get("question_resolved") or step_output.get("question_template") or "")
if is_nomenclature_margin_context(step_output, question): if is_nomenclature_margin_context(step_output, question):
return True return True
margin_context_values = [
str(step_output.get("scenario_id") or ""),
str(step_output.get("target_id") or ""),
str(step_output.get("fix_goal") or ""),
str(step_output.get("business_mismatch") or ""),
str(step_output.get("minimal_patch_direction") or ""),
*normalize_string_list(step_output.get("signals")),
]
margin_context = " ".join(margin_context_values).casefold()
if (
"inventory_margin_ranking_for_nomenclature" in margin_context
or "inventory_inventory_margin_ranking_for_nomenclature" in margin_context
or "margin_false_source" in margin_context
or "payment_false_source" in margin_context
or ("margin" in margin_context and ("оплат" in margin_context or "банк" in margin_context))
):
return True
tokens = [ tokens = [
str(step_output.get("expected_business_answer_contract") or ""), str(step_output.get("expected_business_answer_contract") or ""),
str(step_output.get("required_answer_contract") or ""), str(step_output.get("required_answer_contract") or ""),
@ -978,6 +1002,8 @@ def derive_repair_issue_code(step_output: dict[str, Any], problem_type: str) ->
violated = normalize_string_list(step_output.get("violated_invariants")) violated = normalize_string_list(step_output.get("violated_invariants"))
if "domain_leak_accounting_route" in violated and is_margin_profitability_step(step_output): if "domain_leak_accounting_route" in violated and is_margin_profitability_step(step_output):
return "margin_domain_leak_accounting_route" return "margin_domain_leak_accounting_route"
if is_margin_profitability_step(step_output) and problem_type in {"route_gap", "capability_gap", "evidence_gap"}:
return "margin_domain_leak_accounting_route"
for issue_code in ( for issue_code in (
"technical_garbage_in_answer", "technical_garbage_in_answer",
"business_direct_answer_missing", "business_direct_answer_missing",
@ -2141,6 +2167,36 @@ def is_nomenclature_margin_context(step_state: dict[str, Any], question: str) ->
return has_subject and has_margin_signal and has_rank_signal return has_subject and has_margin_signal and has_rank_signal
def is_margin_false_source_boundary_answer(step_state: dict[str, Any], question: str, assistant_text: str) -> bool:
tags = set(normalize_string_list(step_state.get("semantic_tags")))
question_text = _review_text(question)
answer_text = _review_text(assistant_text)
has_false_source_question = (
"payment_false_source" in tags
or (
("марж" in question_text or "прибыл" in question_text)
and ("товар" in question_text or "номенклатур" in question_text)
and ("оплат" in question_text or "банк" in question_text)
)
)
if not has_false_source_question:
return False
rejects_source = any(
marker in answer_text
for marker in (
"нельзя",
"не подтвержд",
"не подтвержда",
"не использовать",
"не является",
"не расчет",
"не расчёт",
)
)
names_correct_basis = "выруч" in answer_text and "себестоим" in answer_text
return rejects_source and names_correct_basis
def build_business_first_review(step_state: dict[str, Any]) -> dict[str, Any]: def build_business_first_review(step_state: dict[str, Any]) -> dict[str, Any]:
question = str(step_state.get("question_resolved") or step_state.get("question_template") or "").strip() question = str(step_state.get("question_resolved") or step_state.get("question_template") or "").strip()
assistant_text = str(step_state.get("assistant_text") or "") assistant_text = str(step_state.get("assistant_text") or "")
@ -2171,11 +2227,18 @@ def build_business_first_review(step_state: dict[str, Any]) -> dict[str, Any]:
limited_answer = _has_any_marker(assistant_text, BUSINESS_LIMITED_ANSWER_MARKERS) limited_answer = _has_any_marker(assistant_text, BUSINESS_LIMITED_ANSWER_MARKERS)
has_next_action = _has_any_marker(assistant_text, BUSINESS_NEXT_ACTION_MARKERS) has_next_action = _has_any_marker(assistant_text, BUSINESS_NEXT_ACTION_MARKERS)
nomenclature_margin_context = is_nomenclature_margin_context(step_state, question) nomenclature_margin_context = is_nomenclature_margin_context(step_state, question)
wrong_margin_domain_hits = ( raw_wrong_margin_domain_hits = (
_marker_hits(assistant_text, NOMENCLATURE_MARGIN_WRONG_DOMAIN_ANSWER_MARKERS) _marker_hits(assistant_text, NOMENCLATURE_MARGIN_WRONG_DOMAIN_ANSWER_MARKERS)
if nomenclature_margin_context if nomenclature_margin_context
else [] else []
) )
if raw_wrong_margin_domain_hits and is_margin_false_source_boundary_answer(step_state, question, assistant_text):
allowed_false_source_boundary_hits = {"банковск", "списание с расчетного", "списание с расчётного"}
wrong_margin_domain_hits = [
hit for hit in raw_wrong_margin_domain_hits if hit not in allowed_false_source_boundary_hits
]
else:
wrong_margin_domain_hits = raw_wrong_margin_domain_hits
margin_contract_hits = ( margin_contract_hits = (
_marker_hits(assistant_text, NOMENCLATURE_MARGIN_EXPECTED_ANSWER_MARKERS) _marker_hits(assistant_text, NOMENCLATURE_MARGIN_EXPECTED_ANSWER_MARKERS)
if nomenclature_margin_context if nomenclature_margin_context
@ -2440,6 +2503,21 @@ def business_expected_result_mode_matches(expected_result_mode: str, step_state:
and reply_type in {"partial_coverage", "factual", "factual_with_explanation"} and reply_type in {"partial_coverage", "factual", "factual_with_explanation"}
) )
if expected_result_mode == "honest_boundary_with_next_action":
business_review = step_state.get("business_first_review") if isinstance(step_state.get("business_first_review"), dict) else {}
return (
clean_business_review
and bool(assistant_text)
and bool(business_review.get("next_action_present"))
and (
truth_mode in GUARDED_INSUFFICIENCY_TRUTH_MODES
or answer_shape in GUARDED_INSUFFICIENCY_ANSWER_SHAPES
or step_state.get("balance_confirmed") is False
or is_margin_false_source_boundary_answer(step_state, str(step_state.get("question_resolved") or ""), assistant_text)
)
and reply_type in {"partial_coverage", "factual", "factual_with_explanation"}
)
if expected_result_mode == "ranking_or_limited_accounting_answer": if expected_result_mode == "ranking_or_limited_accounting_answer":
return ( return (
clean_business_review clean_business_review
@ -4357,6 +4435,16 @@ def normalize_analyst_priority_repair_target(raw_target: dict[str, Any], index:
if not root_cause_layers: if not root_cause_layers:
root_cause_layers = [problem_type] root_cause_layers = [problem_type]
issue_code = str(raw_target.get("issue_code") or problem_type or "other").strip() issue_code = str(raw_target.get("issue_code") or problem_type or "other").strip()
issue_probe = {
**raw_target,
"scenario_id": scenario_id,
"target_id": f"{scenario_id}:{step_id}",
"problem_type": problem_type,
"root_cause_layers": root_cause_layers,
"fix_goal": fix_goal,
}
if issue_code in {"route_gap", "capability_gap", "evidence_gap"} and is_margin_profitability_step(issue_probe):
issue_code = "margin_domain_leak_accounting_route"
catalog_entry = issue_catalog_entry(issue_code) catalog_entry = issue_catalog_entry(issue_code)
return { return {
"issue_code": issue_code, "issue_code": issue_code,
@ -5212,6 +5300,18 @@ def build_issue_catalog_snapshot(repair_targets: dict[str, Any], catalog: dict[s
} }
def detector_evidence_paths_for_target(target: dict[str, Any]) -> list[str]:
explicit = normalize_string_list(target.get("evidence_paths"))
if explicit:
return explicit
refs = target.get("artifact_refs") if isinstance(target.get("artifact_refs"), dict) else {}
step_state_path = str(refs.get("step_state_json") or "").strip()
if not step_state_path:
return []
step_state = Path(step_state_path)
return [str(step_state.with_name("output.md")), str(step_state.with_name("turn.json"))]
def build_detector_candidates(repair_targets: dict[str, Any], catalog: dict[str, Any] | None = None) -> dict[str, Any]: def build_detector_candidates(repair_targets: dict[str, Any], catalog: dict[str, Any] | None = None) -> dict[str, Any]:
source = catalog if isinstance(catalog, dict) else load_issue_catalog() source = catalog if isinstance(catalog, dict) else load_issue_catalog()
issues = source.get("issues") if isinstance(source.get("issues"), dict) else {} issues = source.get("issues") if isinstance(source.get("issues"), dict) else {}
@ -5225,6 +5325,7 @@ def build_detector_candidates(repair_targets: dict[str, Any], catalog: dict[str,
detectors = normalize_string_list(entry.get("detectors")) detectors = normalize_string_list(entry.get("detectors"))
if not detectors and issue_code: if not detectors and issue_code:
detectors = [f"{issue_code}_detector"] detectors = [f"{issue_code}_detector"]
evidence_paths = detector_evidence_paths_for_target(target)
for detector in detectors: for detector in detectors:
key = (issue_code, detector) key = (issue_code, detector)
if key in seen: if key in seen:
@ -5236,7 +5337,7 @@ def build_detector_candidates(repair_targets: dict[str, Any], catalog: dict[str,
"detector": detector, "detector": detector,
"severity": target.get("severity"), "severity": target.get("severity"),
"sample_target_id": target.get("target_id"), "sample_target_id": target.get("target_id"),
"evidence_paths": target.get("evidence_paths") or [], "evidence_paths": evidence_paths,
} }
) )
return { return {