From ed44c6428afc07c49d54e3311567be2bb0a7d196 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 3 Jun 2026 12:52:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B6=D0=B0=D1=82=D1=8C=20supplier-overl?= =?UTF-8?q?ap=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B=20=D0=B8=20detector?= =?UTF-8?q?=20utility=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/orchestration/detector_registry.json | 20 ++++++--- docs/orchestration/issue_catalog.json | 44 ++++++++++++++++++- .../address_runtime/inventoryReplyBuilders.js | 21 ++------- .../address_runtime/inventoryReplyBuilders.ts | 33 +++++--------- .../tests/addressQueryRuntimeM23.test.ts | 13 ++++-- scripts/test_domain_case_loop_lead_handoff.py | 27 ++++++++++++ 6 files changed, 109 insertions(+), 49 deletions(-) diff --git a/docs/orchestration/detector_registry.json b/docs/orchestration/detector_registry.json index 41d9ee9..f3043b5 100644 --- a/docs/orchestration/detector_registry.json +++ b/docs/orchestration/detector_registry.json @@ -134,7 +134,7 @@ "kind": "answer_text_shape", "automation_level": "semi_automatic", "description": "The first meaningful line is not a direct business answer for a direct user question.", - "issue_codes": ["business_direct_answer_missing"], + "issue_codes": ["business_direct_answer_missing", "business_utility_gap"], "inputs": ["steps//output.md"], "check": { "first_line_should_be": "business_answer_or_honest_boundary" @@ -144,7 +144,7 @@ "kind": "answer_text_regex_forbidden_in_prefix", "automation_level": "automatic", "description": "Answer starts with scaffold/service narration before the user-facing business conclusion.", - "issue_codes": ["business_direct_answer_missing"], + "issue_codes": ["business_direct_answer_missing", "business_utility_gap"], "inputs": ["steps//output.md"], "check": { "prefix_line_count": 3, @@ -155,7 +155,7 @@ "kind": "answer_text_regex_forbidden", "automation_level": "automatic", "description": "Final user-facing answer contains runtime/debug/service tokens.", - "issue_codes": ["technical_garbage_in_answer"], + "issue_codes": ["technical_garbage_in_answer", "business_utility_gap"], "inputs": ["steps//output.md"], "check": { "forbidden_patterns": ["(?i)(route_id|capability_id|runtime_|snapshot_items|debug|answer_object|selected_object)"] @@ -165,7 +165,7 @@ "kind": "answer_text_regex_forbidden", "automation_level": "automatic", "description": "Final user-facing answer contains capability ids or route ids.", - "issue_codes": ["technical_garbage_in_answer"], + "issue_codes": ["technical_garbage_in_answer", "business_utility_gap"], "inputs": ["steps//output.md"], "check": { "forbidden_patterns": ["(?i)(capability[_ -]?id|route[_ -]?id|address\\.[a-z0-9_\\.]+)"] @@ -185,13 +185,23 @@ "kind": "answer_text_required_when_limited", "automation_level": "semi_automatic", "description": "Answer states a limitation but gives no concrete next action for recovering or narrowing the answer.", - "issue_codes": ["business_next_step_missing"], + "issue_codes": ["business_next_step_missing", "business_utility_gap"], "inputs": ["steps//output.md"], "check": { "limited_patterns": ["(?i)(не могу|не хватает|не подтвержден|нет данных|недостаточно)"], "required_next_action_patterns_any": ["(?i)(можно|следующ|уточн|перезапусти|проверь|выбери|нужен|добавь)"] } }, + "business_answer_too_verbose_output": { + "kind": "answer_text_regex_forbidden", + "automation_level": "automatic", + "description": "A scoped business follow-up answer still exposes verbose proof/scaffold sections that should be collapsed into a compact proof line.", + "issue_codes": ["business_utility_gap", "business_answer_too_verbose"], + "inputs": ["steps//output.md"], + "check": { + "forbidden_patterns": ["(?im)^\\s*(Что проверили|Опорные документы):"] + } + }, "route_candidate_needs_enablement": { "kind": "stage_review_signal", "automation_level": "semi_automatic", diff --git a/docs/orchestration/issue_catalog.json b/docs/orchestration/issue_catalog.json index ca64752..be26a1d 100644 --- a/docs/orchestration/issue_catalog.json +++ b/docs/orchestration/issue_catalog.json @@ -237,6 +237,48 @@ ] } }, + "business_utility_gap": { + "severity": "P2", + "business_meaning": "Ответ технически заземлен, но для узкого бизнес follow-up остается слишком тяжелым или недостаточно практичным.", + "root_layers": ["answer_surface", "business_utility"], + "expected_answer_contract": "compact_business_followup_surface_v1", + "detectors": [ + "business_answer_too_verbose_output", + "top_level_scaffold_before_answer", + "runtime_tokens_in_user_answer", + "capability_ids_in_user_answer", + "limited_answer_without_next_action" + ], + "allowed_patch_targets": [ + "llm_normalizer/backend/src/services/address_runtime/composeStage.ts", + "llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts", + "llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts", + "llm_normalizer/backend/src/services/assistantService.ts" + ], + "forbidden_patch_targets": [ + "route masking", + "fake evidence", + "evidence deletion from artifacts" + ], + "rerun_matrix": [ + "failed_scenario", + "answer_surface_pack", + "accepted_smoke_pack" + ], + "acceptance": { + "must_have": [ + "direct_answer_first", + "compact_business_surface", + "short_evidence_basis", + "honest_limitation_when_needed" + ], + "must_not_have": [ + "verbose proof packet by default", + "technical ids", + "duplicated answer blocks" + ] + } + }, "route_candidate_enablement_gap": { "severity": "P1", "business_meaning": "Планировщик понял бизнес-запрос, но route candidate ещё не исполняется как точная возможность.", @@ -427,7 +469,7 @@ "severity": "P1", "business_meaning": "Ответ слишком длинный для прямого бизнес-вопроса и снижает практическую полезность.", "root_layers": ["answer_surface", "business_utility"], - "detectors": ["business_answer_too_verbose_signal"], + "detectors": ["business_answer_too_verbose_output", "business_answer_too_verbose_signal"], "allowed_patch_targets": [ "llm_normalizer/backend/src/services/address_runtime/composeStage.ts", "llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts", diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 2dc17d7..f299d94 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -543,7 +543,7 @@ function composeInventoryReply(intent, rows, options, deps) { if (supplierScopedItemQuestion) { const requestedSupplierLabel = summary.counterparties[0] ?? String(options.counterpartyHint ?? "").trim(); const directAnswerLine = supplierScopedItems.length > 0 - ? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждены позиции: ${inventoryItemLeadList(supplierScopedItems)}.` + ? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждено позиций: ${deps.formatNumberWithDots(supplierScopedItems.length)}.` : `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} позиции не подтверждены в доступном закупочном следе.`; const lines = [directAnswerLine]; if (supplierScopedItems.length > 0) { @@ -552,24 +552,11 @@ function composeInventoryReply(intent, rows, options, deps) { lines.push(`- Показаны первые 8 из ${deps.formatNumberWithDots(supplierScopedItems.length)} позиций.`); } } - (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [ - `Дата среза: ${deps.formatDateRu(asOfDate)}.`, - `Период найденного закупочного следа: ${purchasePeriodLabel}.`, - `Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.` - ]); - (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ - "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии.", - INVENTORY_SUPPLIER_ATTRIBUTION_NEXT_ACTION - ]); + lines.push("", `Проверка: срез ${deps.formatDateRu(asOfDate)}; закупочный след ${purchasePeriodLabel}; документов/операций: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`); if (observedCounterparties.length > 0) { - lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`); - } - if (purchaseRows.length > 0) { - (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT)); - if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { - lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`); - } + lines.push(`Поставщик в найденном следе: ${inventoryPartyLeadList(observedCounterparties)}.`); } + lines.push("Ограничение: без партионного учета это закупочный след остатка, а не доказательство владельца каждой конкретной партии.", INVENTORY_SUPPLIER_ATTRIBUTION_NEXT_ACTION); return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (supplierScopedItems.length > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0)); } const directAnswerLine = observedCounterparties.length === 1 diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 2ab23ca..34f669f 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -747,7 +747,7 @@ export function composeInventoryReply( const requestedSupplierLabel = summary.counterparties[0] ?? String(options.counterpartyHint ?? "").trim(); const directAnswerLine = supplierScopedItems.length > 0 - ? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждены позиции: ${inventoryItemLeadList(supplierScopedItems)}.` + ? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждено позиций: ${deps.formatNumberWithDots(supplierScopedItems.length)}.` : `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} позиции не подтверждены в доступном закупочном следе.`; const lines: string[] = [directAnswerLine]; if (supplierScopedItems.length > 0) { @@ -760,30 +760,17 @@ export function composeInventoryReply( lines.push(`- Показаны первые 8 из ${deps.formatNumberWithDots(supplierScopedItems.length)} позиций.`); } } - appendInventoryBulletSection(lines, "Что проверили:", [ - `Дата среза: ${deps.formatDateRu(asOfDate)}.`, - `Период найденного закупочного следа: ${purchasePeriodLabel}.`, - `Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.` - ]); - appendInventoryBulletSection(lines, "Ограничения:", [ - "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии.", - INVENTORY_SUPPLIER_ATTRIBUTION_NEXT_ACTION - ]); + lines.push( + "", + `Проверка: срез ${deps.formatDateRu(asOfDate)}; закупочный след ${purchasePeriodLabel}; документов/операций: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.` + ); if (observedCounterparties.length > 0) { - lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`); - } - if (purchaseRows.length > 0) { - appendInventorySection( - lines, - "Опорные документы:", - deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) - ); - if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { - lines.push( - `- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.` - ); - } + lines.push(`Поставщик в найденном следе: ${inventoryPartyLeadList(observedCounterparties)}.`); } + lines.push( + "Ограничение: без партионного учета это закупочный след остатка, а не доказательство владельца каждой конкретной партии.", + INVENTORY_SUPPLIER_ATTRIBUTION_NEXT_ACTION + ); return buildFactualSummaryReply( lines, buildConfirmedBalanceSemantics( diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 441f4d5..685fbdf 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -6308,11 +6308,18 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { ); const firstLine = reply.text.split("\n")[0] ?? ""; - expect(firstLine).toContain("подтверждены позиции"); - expect(firstLine).toContain("Столешница 600*3050*26 альмандин"); - expect(firstLine).toContain("Столешница 600*3050*26 дуб ниагара"); + expect(firstLine).toContain("подтверждено позиций: 2"); expect(firstLine).not.toContain("выявлен поставщик закупочного следа"); expect(reply.text).toContain("Позиции:"); + expect(reply.text.indexOf("Позиции:")).toBeLessThan(reply.text.indexOf("Проверка:")); + expect(reply.text).toContain("Столешница 600*3050*26 альмандин"); + expect(reply.text).toContain("Столешница 600*3050*26 дуб ниагара"); + expect(reply.text).toContain("Проверка:"); + expect(reply.text).toContain("Ограничение:"); + expect(reply.text).toContain("Следующий шаг:"); + expect(reply.text).not.toContain("Что проверили:"); + expect(reply.text).not.toContain("Опорные документы:"); + expect(reply.text).not.toContain("Поступление товаров и услуг"); }); it("routes inventory provenance questions to a dedicated intent", () => { diff --git a/scripts/test_domain_case_loop_lead_handoff.py b/scripts/test_domain_case_loop_lead_handoff.py index 3868061..4369c35 100644 --- a/scripts/test_domain_case_loop_lead_handoff.py +++ b/scripts/test_domain_case_loop_lead_handoff.py @@ -211,6 +211,33 @@ class DomainCaseLoopLeadHandoffTests(unittest.TestCase): self.assertIn("first_line_not_direct_answer", detector_names) self.assertNotIn("runtime_tokens_in_user_answer", detector_names) + def test_business_utility_gap_uses_known_surface_detectors(self) -> None: + candidates = dcl.build_detector_candidates( + { + "target_count": 1, + "acceptance_status": "partial", + "final_status": "partial", + "targets": [ + { + "target_id": "inventory_supplier_overlap:step_04_supplier_items_on_stock", + "issue_code": "business_utility_gap", + "severity": "P2", + "evidence_paths": [ + "artifacts/domain_runs/pack/scenarios/inventory_supplier_overlap/steps/step_04_supplier_items_on_stock/output.md" + ], + } + ], + } + ) + + detector_names = [item["detector"] for item in candidates["candidates"]] + + self.assertIn("business_answer_too_verbose_output", detector_names) + self.assertIn("top_level_scaffold_before_answer", detector_names) + self.assertIn("runtime_tokens_in_user_answer", detector_names) + self.assertIn("limited_answer_without_next_action", detector_names) + self.assertNotIn("business_utility_gap_detector", detector_names) + def test_detector_results_gate_allows_pass_and_blocks_fail_or_review(self) -> None: pass_ok, pass_reason = dcl.evaluate_detector_results_gate({"status": "pass"}) fail_ok, fail_reason = dcl.evaluate_detector_results_gate(