Расширить proof matrix маржинальности follow-up replay

This commit is contained in:
dctouch 2026-05-24 18:23:04 +03:00
parent f69393a887
commit 7cc65e808e
6 changed files with 436 additions and 8 deletions

View File

@ -0,0 +1,226 @@
{
"schema_version": "domain_scenario_pack_v1",
"pack_id": "agent_inventory_margin_ranking_reliability_20260524",
"domain": "inventory_margin_ranking",
"title": "AGENT | inventory margin ranking follow-up proof",
"description": "Extended proof pack for the agentic semantic loop: missing-period clarification, period carryover, cost-basis follow-up, period expansion, and account-family guard for inventory margin ranking.",
"source_contract_id": "margin_profitability_v1",
"issue_codes_under_test": [
"margin_domain_leak_accounting_route",
"business_next_step_missing",
"technical_garbage_in_answer"
],
"detectors_under_test": [
"margin_domain_leak_accounting_route",
"margin_required_fields_missing",
"margin_next_action_missing",
"margin_payment_document_false_source",
"margin_os_amortization_leak",
"runtime_tokens_in_user_answer",
"capability_ids_in_user_answer"
],
"bindings": {
"first_period": "май 2020",
"expanded_period": "2017 год"
},
"analysis_context": {
"expected_business_answer_contract": "margin_profitability_v1",
"semantic_focus": [
"direct_answer_first",
"period_carryover",
"cost_basis_honesty",
"account_family_guard",
"margin_domain_purity"
]
},
"acceptance": {
"min_score": 90,
"max_unresolved_p0": 0,
"require_all_critical_steps_pass": true,
"must_have": [
"missing period asks for period instead of guessing",
"follow-up period keeps margin intent",
"cost-basis follow-up does not leak technical garbage",
"period expansion keeps inventory margin context",
"account 41 guard does not become fixed-assets analysis"
],
"must_not_have": [
"fixed assets leak",
"amortization leak",
"bank/payment source for margin",
"route or capability ids in user answer"
]
},
"scenarios": [
{
"scenario_id": "inventory_margin_followup_carryover_proof",
"title": "Inventory margin ranking must survive period and evidence follow-ups",
"acceptance_canon": {
"root_step_id": "step_01_margin_root_needs_period",
"primary_user_path": [
"step_01_margin_root_needs_period",
"step_02_period_followup",
"step_03_show_cost_base_lines",
"step_04_expand_period",
"step_05_account_41_not_01"
],
"required_carryover_invariants": [
"intent_scope",
"period_scope",
"answer_shape"
]
},
"steps": [
{
"step_id": "step_01_margin_root_needs_period",
"title": "Root margin ranking asks period instead of guessing",
"question": "Какая номенклатура товара реализована с высокой прибылью, а какая с низкой?",
"node_role": "root",
"paraphrase_family": "colloquial",
"semantic_tags": [
"inventory_margin_ranking",
"needs_period",
"domain_purity"
],
"expected_capability": "inventory_inventory_margin_ranking_for_nomenclature",
"expected_recipe": "address_inventory_margin_ranking_for_nomenclature_v1",
"expected_result_mode": "clarification_required",
"expected_business_answer_contract": "margin_profitability_v1",
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_any": [
"период|месяц|квартал|год"
],
"forbidden_answer_patterns": [
"амортизац",
"объект ОС",
"основн.{0,20}средств",
"банк.{0,80}(источник|достаточ|марж)",
"оплат.{0,80}(источник|достаточ|марж)",
"route_id|capability_id|runtime_|debug"
]
},
{
"step_id": "step_02_period_followup",
"title": "Period follow-up keeps margin intent",
"question": "{{bindings.first_period}}",
"depends_on": [
"step_01_margin_root_needs_period"
],
"node_role": "critical_child",
"paraphrase_family": "short_followup",
"semantic_tags": [
"inventory_margin_ranking",
"period_followup",
"carryover"
],
"expected_capability": "inventory_inventory_margin_ranking_for_nomenclature",
"expected_recipe": "address_inventory_margin_ranking_for_nomenclature_v1",
"expected_result_mode": "ranking_or_limited_accounting_answer",
"expected_business_answer_contract": "margin_profitability_v1",
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_any": [
"май|01\\.05\\.2020|31\\.05\\.2020|2020",
"марж|прибыл|выруч|себестоим|валов"
],
"forbidden_answer_patterns": [
"амортизац",
"объект ОС",
"основн.{0,20}средств",
"банк.{0,80}(источник|достаточ|марж)",
"оплат.{0,80}(источник|достаточ|марж)",
"route_id|capability_id|runtime_|debug"
]
},
{
"step_id": "step_03_show_cost_base_lines",
"title": "Evidence follow-up remains business-readable",
"question": "Покажи найденные строки себестоимостной базы.",
"depends_on": [
"step_02_period_followup"
],
"node_role": "critical_child",
"paraphrase_family": "canonical",
"semantic_tags": [
"inventory_margin_ranking",
"evidence_followup",
"carryover"
],
"expected_result_mode": "evidence_or_honest_boundary",
"expected_business_answer_contract": "margin_profitability_v1",
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_any": [
"себестоим|закупоч|90\\.02|41|не найден|не подтвержд|нет"
],
"forbidden_answer_patterns": [
"амортизац",
"объект ОС",
"основн.{0,20}средств",
"route_id|capability_id|runtime_|debug|query_movements|planner_"
]
},
{
"step_id": "step_04_expand_period",
"title": "Expanded period keeps inventory margin context",
"question": "Расширь до {{bindings.expanded_period}}.",
"depends_on": [
"step_02_period_followup"
],
"node_role": "critical_child",
"paraphrase_family": "colloquial",
"semantic_tags": [
"inventory_margin_ranking",
"period_expansion",
"carryover"
],
"expected_capability": "inventory_inventory_margin_ranking_for_nomenclature",
"expected_recipe": "address_inventory_margin_ranking_for_nomenclature_v1",
"expected_result_mode": "ranking_or_limited_accounting_answer",
"expected_business_answer_contract": "margin_profitability_v1",
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_any": [
"2017",
"марж|прибыл|выруч|себестоим|валов"
],
"forbidden_answer_patterns": [
"амортизац",
"объект ОС",
"основн.{0,20}средств",
"банк.{0,80}(источник|достаточ|марж)",
"route_id|capability_id|runtime_|debug"
]
},
{
"step_id": "step_05_account_41_not_01",
"title": "Account family guard stays on goods not fixed assets",
"question": "Анализ по 41 счету, а не по 01.",
"depends_on": [
"step_04_expand_period"
],
"node_role": "critical_child",
"paraphrase_family": "colloquial",
"semantic_tags": [
"inventory_margin_ranking",
"account_family_guard",
"no_fixed_assets"
],
"expected_capability": "inventory_inventory_margin_ranking_for_nomenclature",
"expected_recipe": "address_inventory_margin_ranking_for_nomenclature_v1",
"expected_result_mode": "same_inventory_margin_context_or_clarification",
"expected_business_answer_contract": "margin_profitability_v1",
"required_answer_shape": "direct_answer_first",
"required_answer_patterns_any": [
"41",
"товар|номенклатур|закупоч|себестоим|марж|прибыл"
],
"forbidden_answer_patterns": [
"амортизац",
"объект ОС",
"основн.{0,20}средств",
"банк.{0,80}(источник|достаточ|марж)",
"route_id|capability_id|runtime_|debug"
]
}
]
}
]
}

View File

@ -98,6 +98,10 @@ function asksInventoryMarginFromPaymentOrBank(userMessage) {
/(?:товар|номенклатур|inventory|item|sku)/iu.test(text) &&
/(?:банк|банковск|выписк|плат[её]ж|оплат|payment|bank|statement)/iu.test(text));
}
function asksInventoryMarginAccount41Not01(userMessage) {
const text = String(userMessage ?? "").toLowerCase();
return /(?:\b41\b|41\s*сч|сч[её]т[ау]?\s*41)/iu.test(text) && /(?:\b01\b|не\s+ос|основн)/iu.test(text);
}
function inventoryRowItemLabel(row, deps) {
return deps.summarizeInventoryTraceRows([row]).item;
}
@ -478,6 +482,8 @@ function composeInventoryReply(intent, rows, options, deps) {
const topMarginEntry = highMargin[0] ?? null;
const marginBasisRequested = asksForInventoryMarginBasis(options.userMessage);
const paymentOrBankFalseSourceRequested = asksInventoryMarginFromPaymentOrBank(options.userMessage);
const account41Not01Requested = asksInventoryMarginAccount41Not01(options.userMessage);
const withAccountScopePrefix = (line) => account41Not01Requested ? `По счету 41, не по 01/ОС: ${line}` : line;
if (paymentOrBankFalseSourceRequested) {
const lines = [
"По оплатам и банку такой показатель нельзя честно подтвердить: платежи показывают денежный поток и факт оплаты, а не связь реализации с себестоимостью по номенклатуре."
@ -500,9 +506,9 @@ function composeInventoryReply(intent, rows, options, deps) {
if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines = [
costBaseRowsRequested && purchasesWithoutSales.length === 0
withAccountScopePrefix(costBaseRowsRequested && purchasesWithoutSales.length === 0
? `За период ${periodLabel} подтвержденных строк себестоимостной базы по реализованной номенклатуре не найдено.`
: `За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`
: `За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`)
];
const findings = [];
if (salesWithoutCost.length > 0) {
@ -549,7 +555,7 @@ function composeInventoryReply(intent, rows, options, deps) {
: topMarginEntry
? `Самая маржинальная позиция за период ${periodLabel}: ${topMarginEntry.item} — маржа ${formatInventoryPercent(topMarginEntry.marginPct, deps.formatNumberWithDots)}, выручка ${deps.formatMoneyRub(topMarginEntry.revenue)}, себестоимостная база ${deps.formatMoneyRub(topMarginEntry.costProxy)}, валовая разница ${deps.formatMoneyRub(topMarginEntry.spread)}.`
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
const lines = [directAnswerLine];
const lines = [withAccountScopePrefix(directAnswerLine)];
if (marginBasisRequested) {
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "База расчета:", [
"выручка: подтвержденные строки реализации по номенклатуре;",

View File

@ -188,6 +188,11 @@ function asksInventoryMarginFromPaymentOrBank(userMessage: string | null | undef
);
}
function asksInventoryMarginAccount41Not01(userMessage: string | null | undefined): boolean {
const text = String(userMessage ?? "").toLowerCase();
return /(?:\b41\b|41\s*сч|сч[её]т[ау]?\s*41)/iu.test(text) && /(?:\b01\b|не\s+ос|основн)/iu.test(text);
}
interface InventoryMarginRankingEntry {
item: string;
revenue: number;
@ -659,6 +664,9 @@ export function composeInventoryReply(
const topMarginEntry = highMargin[0] ?? null;
const marginBasisRequested = asksForInventoryMarginBasis(options.userMessage);
const paymentOrBankFalseSourceRequested = asksInventoryMarginFromPaymentOrBank(options.userMessage);
const account41Not01Requested = asksInventoryMarginAccount41Not01(options.userMessage);
const withAccountScopePrefix = (line: string): string =>
account41Not01Requested ? `По счету 41, не по 01/ОС: ${line}` : line;
if (paymentOrBankFalseSourceRequested) {
const lines = [
"По оплатам и банку такой показатель нельзя честно подтвердить: платежи показывают денежный поток и факт оплаты, а не связь реализации с себестоимостью по номенклатуре."
@ -681,9 +689,11 @@ export function composeInventoryReply(
if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines: string[] = [
costBaseRowsRequested && purchasesWithoutSales.length === 0
? `За период ${periodLabel} подтвержденных строк себестоимостной базы по реализованной номенклатуре не найдено.`
: `За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`
withAccountScopePrefix(
costBaseRowsRequested && purchasesWithoutSales.length === 0
? `За период ${periodLabel} подтвержденных строк себестоимостной базы по реализованной номенклатуре не найдено.`
: `За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`
)
];
const findings: string[] = [];
if (salesWithoutCost.length > 0) {
@ -754,7 +764,7 @@ export function composeInventoryReply(
topMarginEntry.costProxy
)}, валовая разница ${deps.formatMoneyRub(topMarginEntry.spread)}.`
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
const lines: string[] = [directAnswerLine];
const lines: string[] = [withAccountScopePrefix(directAnswerLine)];
if (marginBasisRequested) {
appendInventoryBulletSection(lines, "База расчета:", [

View File

@ -641,4 +641,64 @@ describe("address reply builders regressions", () => {
expect(result?.text).not.toContain("Самая маржинальная позиция");
expect(result?.text).not.toMatch(/(?:оплат[аы]|банк|payment_document).{0,80}(?:источник|достаточ|посчитал|марж[ау])/iu);
});
it("acknowledges account 41 guard before repeating margin ranking", () => {
const result = composeInventoryReply(
"inventory_margin_ranking_for_nomenclature",
[
{
kind: "sale",
amount: 10800,
quantity: 1,
item: "Флаг геральдический",
period: "2017-05-20",
registrator: "Реализация товаров"
} as any,
{
kind: "purchase",
amount: 8520,
quantity: 1,
item: "Флаг геральдический",
period: "2017-01-10",
registrator: "Поступление товаров"
} as any
],
{
userMessage: "Анализ по 41 счету, а не по 01.",
periodFrom: "2017-01-01",
periodTo: "2017-12-31"
},
{
resolvePayablesAsOfDate: () => "2017-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) => row.kind === "purchase",
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) => row.kind === "sale"
}
);
const firstLine = result?.text.split("\n")[0] ?? "";
expect(firstLine).toContain("По счету 41, не по 01/ОС");
expect(firstLine).toContain("Самая маржинальная позиция");
expect(result?.text).not.toContain("амортизац");
});
});

View File

@ -259,6 +259,11 @@ GUARDED_INSUFFICIENCY_PRIMARY_MARKERS = (
"\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",
"\u0447\u0435\u0441\u0442\u043d\u043e \u043f\u043e\u0441\u0447\u0438\u0442\u0430\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f",
"\u043d\u0435\u0442 \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e\u0439 \u0431\u0430\u0437\u044b",
"\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u043e\u0439 \u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c",
"\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0445 \u0441\u0442\u0440\u043e\u043a",
"\u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e",
)
GUARDED_INSUFFICIENCY_LIMITATION_MARKERS = (
"\u043f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u044c\u043d",
@ -2457,6 +2462,33 @@ def is_validated_guarded_insufficiency_answer(
)
def is_validated_clarification_answer(
state: dict[str, Any],
execution_status: str,
business_review: dict[str, Any],
violations: list[str],
) -> bool:
if normalize_identifier(state.get("expected_result_mode")) != "clarification_required":
return False
if execution_status != "partial":
return False
if violations:
return False
if str(state.get("reply_type") or "").strip() not in {"partial_coverage", "clarification_required"}:
return False
truth_mode = str(state.get("truth_mode") or "").strip()
answer_shape = str(state.get("answer_shape") or "").strip()
if truth_mode != "clarification_required" and answer_shape != "clarification_required":
return False
return (
business_review.get("business_usefulness_ok") is True
and business_review.get("direct_answer_first_ok") is True
and business_review.get("answer_layering_ok") is True
and business_review.get("technical_garbage_present") is False
and business_review.get("next_action_present") is True
)
def _business_review_is_clean(step_state: dict[str, Any]) -> bool:
business_review = step_state.get("business_first_review")
if not isinstance(business_review, dict):
@ -2698,6 +2730,12 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
business_review,
unique_violations,
)
clarification_validated = is_validated_clarification_answer(
state,
execution_status,
business_review,
unique_violations,
)
state["violated_invariants"] = unique_violations
state["warnings"] = list(dict.fromkeys(warnings))
state["hard_fail"] = hard_fail
@ -2705,10 +2743,17 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
state["memory_checkpoint_validated"] = memory_validated
state["runtime_factual_answer_validated"] = runtime_factual_validated
state["guarded_insufficiency_validated"] = guarded_insufficiency_validated
state["clarification_answer_validated"] = clarification_validated
state["acceptance_status"] = acceptance_status_from_execution(
execution_status,
hard_fail,
bounded_validated or memory_validated or runtime_factual_validated or guarded_insufficiency_validated,
(
bounded_validated
or memory_validated
or runtime_factual_validated
or guarded_insufficiency_validated
or clarification_validated
),
)
state["status"] = state["acceptance_status"]
return state

View File

@ -804,6 +804,87 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
self.assertTrue(step_state["guarded_insufficiency_validated"])
self.assertEqual(step_state["acceptance_status"], "validated")
def test_expected_clarification_partial_answer_validates(self) -> None:
answer_text = (
"Для рейтинга прибыльности номенклатуры нужен период.\n\n"
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, "
"валовую прибыль и маржинальность.\n\n"
"Уточните период: месяц, квартал, год или весь доступный период."
)
step_state = dcl.validate_step_contract(
{
"execution_status": "partial",
"reply_type": "partial_coverage",
"expected_result_mode": "clarification_required",
"required_answer_shape": "direct_answer_first",
"response_type": "LIMITED_WITH_REASON",
"truth_mode": "clarification_required",
"answer_shape": "clarification_required",
"assistant_text": answer_text,
"actual_direct_answer": "Для рейтинга прибыльности номенклатуры нужен период.",
"top_non_empty_lines": [
"Для рейтинга прибыльности номенклатуры нужен период.",
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, валовую прибыль и маржинальность.",
"Уточните период: месяц, квартал, год или весь доступный период.",
],
}
)
self.assertTrue(step_state["clarification_answer_validated"])
self.assertEqual(step_state["acceptance_status"], "validated")
def test_inventory_margin_guarded_insufficiency_validates_without_exact_values(self) -> None:
answer_text = (
"За период 01.05.2020 - 31.05.2020 рейтинг прибыльности номенклатуры построить нельзя.\n\n"
"Что нашлось:\n"
"- Есть реализация по 1 номенклатурной позиции.\n"
"- Подтвержденной себестоимости реализации по этой позиции не найдено.\n"
"- Поэтому валовую прибыль и маржинальность честно посчитать нельзя.\n"
"Вывод: за период 01.05.2020 - 31.05.2020 нет достаточной базы для рейтинга "
"«высокая / низкая прибыль» по номенклатуре.\n\n"
"Что можно сделать дальше:\n"
"- показать найденные реализации за этот период;\n"
"- расширить период до квартала или года;\n"
"- попробовать строгий расчет по проводкам 90.01 / 90.02.\n\n"
"Граница ответа:\n"
"- Прибыльность номенклатуры считаю только когда есть реализация и подтвержденная себестоимость реализации."
)
step_state = dcl.validate_step_contract(
{
"execution_status": "partial",
"reply_type": "partial_coverage",
"expected_result_mode": "ranking_or_limited_accounting_answer",
"required_answer_shape": "direct_answer_first",
"detected_intent": "inventory_margin_ranking_for_nomenclature",
"capability_id": "inventory_inventory_margin_ranking_for_nomenclature",
"fallback_type": "none",
"mcp_call_status": "matched_non_empty",
"response_type": "FACTUAL_SUMMARY",
"truth_mode": "limited",
"answer_shape": "limited_with_reason",
"balance_confirmed": False,
"assistant_text": answer_text,
"actual_direct_answer": "За период 01.05.2020 - 31.05.2020 рейтинг прибыльности номенклатуры построить нельзя.",
"top_non_empty_lines": [
"За период 01.05.2020 - 31.05.2020 рейтинг прибыльности номенклатуры построить нельзя.",
"Что нашлось:",
"- Есть реализация по 1 номенклатурной позиции.",
"- Подтвержденной себестоимости реализации по этой позиции не найдено.",
"- Поэтому валовую прибыль и маржинальность честно посчитать нельзя.",
"Вывод: за период 01.05.2020 - 31.05.2020 нет достаточной базы для рейтинга «высокая / низкая прибыль» по номенклатуре.",
"Что можно сделать дальше:",
"- показать найденные реализации за этот период;",
"- расширить период до квартала или года;",
"- попробовать строгий расчет по проводкам 90.01 / 90.02.",
"Граница ответа:",
"- Прибыльность номенклатуры считаю только когда есть реализация и подтвержденная себестоимость реализации.",
],
}
)
self.assertTrue(step_state["guarded_insufficiency_validated"])
self.assertEqual(step_state["acceptance_status"], "validated")
def test_heuristic_open_items_without_limitation_is_rejected(self) -> None:
step_state = dcl.build_scenario_step_state(
scenario_id="runtime_factual_demo",