From 7cc65e808eb2430e7d27f2ac786c340aecc7fc50 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 24 May 2026 18:23:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20proof=20matrix=20=D0=BC=D0=B0=D1=80=D0=B6=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20follow?= =?UTF-8?q?-up=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...y_margin_ranking_reliability_20260524.json | 226 ++++++++++++++++++ .../address_runtime/inventoryReplyBuilders.js | 12 +- .../address_runtime/inventoryReplyBuilders.ts | 18 +- .../addressReplyBuildersRegression.test.ts | 60 +++++ scripts/domain_case_loop.py | 47 +++- scripts/test_domain_case_loop_step_state.py | 81 +++++++ 6 files changed, 436 insertions(+), 8 deletions(-) create mode 100644 docs/orchestration/agent_inventory_margin_ranking_reliability_20260524.json diff --git a/docs/orchestration/agent_inventory_margin_ranking_reliability_20260524.json b/docs/orchestration/agent_inventory_margin_ranking_reliability_20260524.json new file mode 100644 index 0000000..a0fd84f --- /dev/null +++ b/docs/orchestration/agent_inventory_margin_ranking_reliability_20260524.json @@ -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" + ] + } + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 8b9d398..7139b94 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -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, "База расчета:", [ "выручка: подтвержденные строки реализации по номенклатуре;", diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 8558aff..997edbe 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -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, "База расчета:", [ diff --git a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts index 8cae26b..4a5b52a 100644 --- a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts +++ b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts @@ -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("амортизац"); + }); }); diff --git a/scripts/domain_case_loop.py b/scripts/domain_case_loop.py index ed06c4f..183841a 100644 --- a/scripts/domain_case_loop.py +++ b/scripts/domain_case_loop.py @@ -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 diff --git a/scripts/test_domain_case_loop_step_state.py b/scripts/test_domain_case_loop_step_state.py index da4bbb3..42eb28d 100644 --- a/scripts/test_domain_case_loop_step_state.py +++ b/scripts/test_domain_case_loop_step_state.py @@ -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",