Сжать supplier-overlap ответы и detector utility gap

This commit is contained in:
dctouch 2026-06-03 12:52:44 +03:00
parent fdd04abbde
commit ed44c6428a
6 changed files with 109 additions and 49 deletions

View File

@ -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/<step_id>/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/<step_id>/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/<step_id>/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/<step_id>/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/<step_id>/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/<step_id>/output.md"],
"check": {
"forbidden_patterns": ["(?im)^\\s*(Что проверили|Опорные документы):"]
}
},
"route_candidate_needs_enablement": {
"kind": "stage_review_signal",
"automation_level": "semi_automatic",

View File

@ -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",

View File

@ -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

View File

@ -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
]);
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)} найденных строк; полный след остается в подтвержденном срезе.`
"",
`Проверка: срез ${deps.formatDateRu(asOfDate)}; закупочный след ${purchasePeriodLabel}; документов/операций: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`
);
if (observedCounterparties.length > 0) {
lines.push(`Поставщик в найденном следе: ${inventoryPartyLeadList(observedCounterparties)}.`);
}
}
lines.push(
"Ограничение: без партионного учета это закупочный след остатка, а не доказательство владельца каждой конкретной партии.",
INVENTORY_SUPPLIER_ATTRIBUTION_NEXT_ACTION
);
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(

View File

@ -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", () => {

View File

@ -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(