From f3649e4a7d6005419f342560f33a6bf26dc4fbc9 Mon Sep 17 00:00:00 2001 From: dctouch Date: Tue, 2 Jun 2026 09:14:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=BA=D1=80=D0=B5=D0=BF=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20Y9=20selected-object=20agent=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...y_selected_object_regression_20260525.json | 8 +- .../dist/services/addressFilterExtractor.js | 2 +- .../dist/services/addressNavigationState.js | 9 +- .../dist/services/addressQueryService.js | 11 ++ .../services/address_runtime/composeStage.js | 6 +- .../dist/services/assistantRoutePolicy.js | 4 + .../src/services/addressFilterExtractor.ts | 2 +- .../src/services/addressNavigationState.ts | 9 +- .../src/services/addressQueryService.ts | 14 ++ .../services/address_runtime/composeStage.ts | 6 +- .../src/services/assistantRoutePolicy.ts | 6 + .../addressInventoryAgingFollowup.test.ts | 2 +- .../tests/addressQueryRuntimeM23.test.ts | 4 +- scripts/domain_case_loop.py | 130 ++++++++++++- scripts/test_domain_case_loop_step_state.py | 173 ++++++++++++++++++ 15 files changed, 363 insertions(+), 23 deletions(-) diff --git a/docs/orchestration/assistant_stage1_y9_inventory_selected_object_regression_20260525.json b/docs/orchestration/assistant_stage1_y9_inventory_selected_object_regression_20260525.json index f97920a..6f31ec7 100644 --- a/docs/orchestration/assistant_stage1_y9_inventory_selected_object_regression_20260525.json +++ b/docs/orchestration/assistant_stage1_y9_inventory_selected_object_regression_20260525.json @@ -89,7 +89,7 @@ "depends_on": ["s06_vat_by_ambiguous_purchase_date"], "semantic_tags": ["vat", "purchase_date_carryover", "first_purchase"], "required_answer_shape": "direct_answer_first", - "required_answer_patterns_all": ["НДС", "первую подтвержденную дату закупки", "05.02.2015", "01.02.2015", "28.02.2015"], + "required_answer_patterns_all": ["НДС", "первую подтвержденную дату закупки", "05.02.2015", "01.01.2015", "31.03.2015"], "forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery"] }, { @@ -99,7 +99,7 @@ "depends_on": ["s07_vat_first_confirmed_purchase"], "semantic_tags": ["vat", "purchase_date_carryover", "last_purchase"], "required_answer_shape": "direct_answer_first", - "required_answer_patterns_all": ["НДС", "последнюю подтвержденную дату закупки", "22.07.2015", "01.07.2015", "31.07.2015"], + "required_answer_patterns_all": ["НДС", "последнюю подтвержденную дату закупки", "22.07.2015", "01.07.2015", "30.09.2015"], "forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery"] } ] @@ -116,7 +116,7 @@ "question": "Есть ли у {{bindings.organization}} остатки товара, которые закупались очень давно", "semantic_tags": ["inventory", "aging", "supplier_provenance", "field_truth"], "required_answer_shape": "direct_answer_first", - "required_answer_patterns_all": ["самым старым закупкам", "05.11.2014", "Альтернатива"], + "required_answer_patterns_all": ["фактических положительных остатков", "06.02.2019", "возраст закупочного следа", "Альтернатива"], "forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery", "контрагент:\\s*Стенд информационный"] }, { @@ -126,7 +126,7 @@ "depends_on": ["s01_old_stock_aging_for_org"], "semantic_tags": ["inventory", "aging", "followup_date_carryover"], "required_answer_shape": "direct_answer_first", - "required_answer_patterns_all": ["самым старым закупкам", "05.11.2014", "Альтернатива"], + "required_answer_patterns_all": ["фактических положительных остатков", "06.02.2019", "25.05.2026", "возраст закупочного следа"], "forbidden_answer_patterns": ["(?i)capability_id", "(?i)runtime_", "(?i)mcp_discovery"] } ] diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 757998b..02e4074 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -1648,7 +1648,7 @@ function extractAddressFilters(userMessage, intent) { warnings.push("supplier_anchor_derived_for_inventory_documentary_chain"); } } - const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent); + const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent) && intent !== "inventory_on_hand_as_of_date"; const knownFinancialCounterparty = allowGenericCounterpartyAnchor && shouldPreferKnownFinancialCounterpartyAnchor(intent) ? extractKnownFinancialCounterpartyAnchor(text) : undefined; diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index d3fa2e1..852aba9 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -376,7 +376,14 @@ function buildFocusObjectFromDebug(debug, resultSetId, createdAt) { } } const objectType = toAddressFocusObjectType(debug.anchor_type); - const canonicalType = objectType === "unknown" ? inferDisplayEntityType(toAddressIntent(debug.detected_intent)) : objectType; + const detectedIntent = toAddressIntent(debug.detected_intent); + if (detectedIntent === "inventory_on_hand_as_of_date") { + const organization = (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug, toNonEmptyString); + if (organization) { + return buildFocusObject("organization", organization, resultSetId, createdAt); + } + } + const canonicalType = objectType === "unknown" ? inferDisplayEntityType(detectedIntent) : objectType; if (canonicalType === "item") { const item = (0, assistantContinuityPolicy_1.readAddressDebugItem)(debug, toNonEmptyString); return item ? buildFocusObject(canonicalType, item, resultSetId, createdAt) : null; diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index ddb9234..ce9c345 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1629,6 +1629,17 @@ function applyPreExecutionOrganizationScopeGrounding(input) { input.semanticFrame.anchor_value = activeOrganization; } } + if (activeOrganization && + sameNormalizedOrganizationScope(input.filters.organization ?? null, activeOrganization) && + referentialOrganizationScopeDetected && + typeof input.filters.counterparty !== "string") { + if (!input.warnings.includes("counterparty_cleared_from_referential_organization_scope")) { + input.warnings.push("counterparty_cleared_from_referential_organization_scope"); + } + if (!input.baseReasons.includes("counterparty_cleared_from_referential_organization_scope")) { + input.baseReasons.push("counterparty_cleared_from_referential_organization_scope"); + } + } if (activeOrganization && sameNormalizedOrganizationScope(input.filters.organization ?? null, activeOrganization) && typeof input.filters.counterparty === "string" && diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 9feeb6d..6a6b26d 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -3198,9 +3198,9 @@ function composeFactualReplyBody(intent, rows, options = {}) { const purchaseBasisLabel = vatPurchaseDateBasisLabel(options.purchaseDateBridge?.basis, hasMultiplePurchaseDates); const directVatLine = selectedPurchaseDate ? hasMultiplePurchaseDates - ? `Коротко: если брать ${purchaseBasisLabel} ${formatDateRu(selectedPurchaseDate)}, подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.` - : `Коротко: по дате покупки ${formatDateRu(selectedPurchaseDate)} подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.` - : `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`; + ? `Если брать ${purchaseBasisLabel} ${formatDateRu(selectedPurchaseDate)}, подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.` + : `По дате покупки ${formatDateRu(selectedPurchaseDate)} подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.` + : `Подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`; const lines = [ directVatLine, "Расчет сделан по подтвержденным строкам книг продаж и покупок.", diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 4cd7f2b..f0e4434 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -496,6 +496,9 @@ function createAssistantRoutePolicy(deps) { hasDataRetrievalRequestSignal(repairedRawUserMessage); const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); const llmFirstAddressCandidate = Boolean(llmContractMode === "address_query" && llmContractIntent && llmContractIntent !== "unknown"); + const llmSupportedAddressQueryIntent = Boolean(llmContractMode === "address_query" && + llmContractIntent && + ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)); const llmFirstUnsupportedCandidate = Boolean(llmContractMode === "unsupported" && (!llmContractIntent || llmContractIntent === "unknown")); const dangerOrCoercionSignal = hasDangerOrCoercionSignal(rawUserMessage) || @@ -832,6 +835,7 @@ function createAssistantRoutePolicy(deps) { !capabilityMetaQuery && !dangerOrCoercionSignal && !effectiveGroundedValueFlowFollowupContextDetected && + !llmSupportedAddressQueryIntent && !organizationClarificationContinuationDetected); if (unsupportedCurrentTurnMeaningBoundary) { return { diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 3e3095a..7c0f78c 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -1912,7 +1912,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent } } - const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent); + const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent) && intent !== "inventory_on_hand_as_of_date"; const knownFinancialCounterparty = allowGenericCounterpartyAnchor && shouldPreferKnownFinancialCounterpartyAnchor(intent) ? extractKnownFinancialCounterpartyAnchor(text) diff --git a/llm_normalizer/backend/src/services/addressNavigationState.ts b/llm_normalizer/backend/src/services/addressNavigationState.ts index cd24552..8adfe4b 100644 --- a/llm_normalizer/backend/src/services/addressNavigationState.ts +++ b/llm_normalizer/backend/src/services/addressNavigationState.ts @@ -442,7 +442,14 @@ function buildFocusObjectFromDebug(debug: Record, resultSetId: } } const objectType = toAddressFocusObjectType(debug.anchor_type); - const canonicalType = objectType === "unknown" ? inferDisplayEntityType(toAddressIntent(debug.detected_intent)) : objectType; + const detectedIntent = toAddressIntent(debug.detected_intent); + if (detectedIntent === "inventory_on_hand_as_of_date") { + const organization = readAddressDebugOrganization(debug, toNonEmptyString); + if (organization) { + return buildFocusObject("organization", organization, resultSetId, createdAt); + } + } + const canonicalType = objectType === "unknown" ? inferDisplayEntityType(detectedIntent) : objectType; if (canonicalType === "item") { const item = readAddressDebugItem(debug, toNonEmptyString); return item ? buildFocusObject(canonicalType, item, resultSetId, createdAt) : null; diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index e3336ff..95ca502 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -2012,6 +2012,20 @@ function applyPreExecutionOrganizationScopeGrounding(input: { } } + if ( + activeOrganization && + sameNormalizedOrganizationScope(input.filters.organization ?? null, activeOrganization) && + referentialOrganizationScopeDetected && + typeof input.filters.counterparty !== "string" + ) { + if (!input.warnings.includes("counterparty_cleared_from_referential_organization_scope")) { + input.warnings.push("counterparty_cleared_from_referential_organization_scope"); + } + if (!input.baseReasons.includes("counterparty_cleared_from_referential_organization_scope")) { + input.baseReasons.push("counterparty_cleared_from_referential_organization_scope"); + } + } + if ( activeOrganization && sameNormalizedOrganizationScope(input.filters.organization ?? null, activeOrganization) && diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index ff286f7..49f1357 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -4094,9 +4094,9 @@ function composeFactualReplyBody( const purchaseBasisLabel = vatPurchaseDateBasisLabel(options.purchaseDateBridge?.basis, hasMultiplePurchaseDates); const directVatLine = selectedPurchaseDate ? hasMultiplePurchaseDates - ? `Коротко: если брать ${purchaseBasisLabel} ${formatDateRu(selectedPurchaseDate)}, подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.` - : `Коротко: по дате покупки ${formatDateRu(selectedPurchaseDate)} подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.` - : `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`; + ? `Если брать ${purchaseBasisLabel} ${formatDateRu(selectedPurchaseDate)}, подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.` + : `По дате покупки ${formatDateRu(selectedPurchaseDate)} подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.` + : `Подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`; const lines = [ directVatLine, diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 1b3c803..80003bd 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -584,6 +584,11 @@ export function createAssistantRoutePolicy(deps) { hasDataRetrievalRequestSignal(repairedRawUserMessage); const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); const llmFirstAddressCandidate = Boolean(llmContractMode === "address_query" && llmContractIntent && llmContractIntent !== "unknown"); + const llmSupportedAddressQueryIntent = Boolean( + llmContractMode === "address_query" && + llmContractIntent && + ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent) + ); const llmFirstUnsupportedCandidate = Boolean(llmContractMode === "unsupported" && (!llmContractIntent || llmContractIntent === "unknown")); const dangerOrCoercionSignal = hasDangerOrCoercionSignal(rawUserMessage) || @@ -924,6 +929,7 @@ export function createAssistantRoutePolicy(deps) { !capabilityMetaQuery && !dangerOrCoercionSignal && !effectiveGroundedValueFlowFollowupContextDetected && + !llmSupportedAddressQueryIntent && !organizationClarificationContinuationDetected); if (unsupportedCurrentTurnMeaningBoundary) { return { diff --git a/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts b/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts index 7fd0588..fe44172 100644 --- a/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts @@ -101,7 +101,7 @@ describe("inventory aging follow-up", () => { ); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); - expect(reply.text).toContain("К самым старым закупкам"); + expect(reply.text).toContain("Среди фактических положительных остатков"); expect(reply.text).toContain("Дата среза: 30.09.2021"); expect(reply.text).toContain("Позиции от самых старых закупок:"); expect(reply.text).toContain("Ограничения:"); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index bfd1e10..1128812 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -2138,7 +2138,7 @@ describe("address compose stage utf8 headers", () => { ); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); - expect(reply.text).toContain("Коротко: подтвержденный НДС к уплате за налоговый период по организации ООО Альтернатива Плюс"); + expect(reply.text).toContain("Подтвержденный НДС к уплате за налоговый период по организации ООО Альтернатива Плюс"); expect(reply.text).toContain("Расчет сделан по подтвержденным строкам книг продаж и покупок."); expect(reply.text).toContain("- Организация: ООО Альтернатива Плюс."); expect(reply.text).toContain("50.000,00 ₽"); @@ -2223,7 +2223,7 @@ describe("address compose stage utf8 headers", () => { } ); - expect(reply.text).toContain("если брать первую подтвержденную дату закупки 05.02.2015"); + expect(reply.text).toContain("Если брать первую подтвержденную дату закупки 05.02.2015"); expect(reply.text).toContain("у позиции несколько подтвержденных дат закупки"); expect(reply.text).toContain("05.02.2015..22.07.2015"); }); diff --git a/scripts/domain_case_loop.py b/scripts/domain_case_loop.py index 324ca6f..e6d1b21 100644 --- a/scripts/domain_case_loop.py +++ b/scripts/domain_case_loop.py @@ -2937,15 +2937,27 @@ def build_scenario_step_state( current_date_scope = derive_mcp_discovery_business_overview_date_scope(debug) if current_date_scope is None: current_date_scope = context.get("date_scope") + runtime_contract = ( + debug.get("assistant_runtime_contract_v1") + if isinstance(debug.get("assistant_runtime_contract_v1"), dict) + else {} + ) + assistant_truth_policy = ( + debug.get("assistant_truth_answer_policy_v1") + if isinstance(debug.get("assistant_truth_answer_policy_v1"), dict) + else {} + ) truth_policy = debug.get("truth_answer_policy_v1") if isinstance(debug.get("truth_answer_policy_v1"), dict) else {} + if not truth_policy: + truth_policy = assistant_truth_policy truth_gate = truth_policy.get("truth_gate") if isinstance(truth_policy.get("truth_gate"), dict) else {} truth_answer_shape = truth_policy.get("answer_shape") if isinstance(truth_policy.get("answer_shape"), dict) else {} - coverage_gate_contract = ( - debug.get("coverage_gate_contract") if isinstance(debug.get("coverage_gate_contract"), dict) else {} - ) - answer_shape_contract = ( - debug.get("answer_shape_contract") if isinstance(debug.get("answer_shape_contract"), dict) else {} - ) + coverage_gate_contract = debug.get("coverage_gate_contract") if isinstance(debug.get("coverage_gate_contract"), dict) else {} + if not coverage_gate_contract: + coverage_gate_contract = truth_gate + answer_shape_contract = debug.get("answer_shape_contract") if isinstance(debug.get("answer_shape_contract"), dict) else {} + if not answer_shape_contract: + answer_shape_contract = truth_answer_shape raw_answer_shape = debug.get("answer_shape") debug_answer_shape = raw_answer_shape.get("answer_shape") if isinstance(raw_answer_shape, dict) else raw_answer_shape @@ -2982,7 +2994,22 @@ def build_scenario_step_state( "detected_intent": debug.get("detected_intent"), "selected_recipe": debug.get("selected_recipe"), "capability_id": debug.get("capability_id"), + "capability_contract_id": first_non_empty_string( + debug.get("capability_contract_id"), + runtime_contract.get("capability_contract_id"), + answer_shape_contract.get("capability_contract_id"), + ), "capability_route_mode": debug.get("capability_route_mode"), + "truth_gate_contract_status": first_non_empty_string( + debug.get("truth_gate_contract_status"), + runtime_contract.get("truth_gate_contract_status"), + truth_gate.get("source_truth_gate_status"), + ), + "carryover_eligibility": first_non_empty_string( + debug.get("carryover_eligibility"), + runtime_contract.get("carryover_eligibility"), + truth_gate.get("carryover_eligibility"), + ), "mcp_discovery_catalog_chain_alignment_status": debug.get("mcp_discovery_catalog_chain_alignment_status"), "mcp_discovery_catalog_chain_top_match": debug.get("mcp_discovery_catalog_chain_top_match"), "mcp_discovery_catalog_chain_selected_matches_top": debug.get("mcp_discovery_catalog_chain_selected_matches_top"), @@ -4081,6 +4108,9 @@ def compact_step_output_for_review(step_output: Any) -> dict[str, Any]: "detected_intent": step_output.get("detected_intent"), "selected_recipe": step_output.get("selected_recipe"), "capability_id": step_output.get("capability_id"), + "capability_contract_id": step_output.get("capability_contract_id"), + "truth_gate_contract_status": step_output.get("truth_gate_contract_status"), + "carryover_eligibility": step_output.get("carryover_eligibility"), "depends_on": step_output.get("depends_on"), "semantic_tags": step_output.get("semantic_tags"), "required_state_objects": step_output.get("required_state_objects"), @@ -4713,6 +4743,84 @@ def normalize_analyst_priority_repair_target(raw_target: dict[str, Any], index: } +def iso_date_ordinal(value: Any) -> int | None: + normalized = normalize_iso_date(value) + if not normalized: + return None + try: + return datetime.strptime(normalized, "%Y-%m-%d").date().toordinal() + except ValueError: + return None + + +def extract_date_ordinals_from_text(value: Any) -> list[int]: + text = str(value or "") + dates: list[int] = [] + for match in re.finditer(r"\b(\d{4})-(\d{2})-(\d{2})\b", text): + ordinal = iso_date_ordinal(match.group(0)) + if ordinal is not None: + dates.append(ordinal) + for match in re.finditer(r"\b(\d{2})\.(\d{2})\.(\d{4})\b", text): + day, month, year = match.groups() + ordinal = iso_date_ordinal(f"{year}-{month}-{day}") + if ordinal is not None: + dates.append(ordinal) + return dates + + +def validated_temporal_cutoff_target_is_false_positive( + target: dict[str, Any], + step_snapshot: dict[str, Any], + target_text: str, +) -> bool: + temporal_markers = ( + "temporal", + "date", + "cutoff", + "historical", + "later", + "post-", + "out-of-window", + "window", + "scope", + "дата", + "дат", + "позже", + "после", + "границ", + "временн", + "окн", + "срез", + ) + if not any(marker in target_text for marker in temporal_markers): + return False + + extracted_filters = step_snapshot.get("extracted_filters") if isinstance(step_snapshot.get("extracted_filters"), dict) else {} + date_scope = step_snapshot.get("date_scope") if isinstance(step_snapshot.get("date_scope"), dict) else {} + analysis_context = ( + step_snapshot.get("analysis_context") if isinstance(step_snapshot.get("analysis_context"), dict) else {} + ) + cutoff_ordinal = ( + iso_date_ordinal(extracted_filters.get("as_of_date")) + or iso_date_ordinal(date_scope.get("as_of_date")) + or iso_date_ordinal(analysis_context.get("as_of_date")) + ) + if cutoff_ordinal is None: + return False + + assistant_dates = extract_date_ordinals_from_text(step_snapshot.get("assistant_text_excerpt")) + if not assistant_dates: + return False + if any(date_ordinal > cutoff_ordinal for date_ordinal in assistant_dates): + return False + + target_dates = extract_date_ordinals_from_text(target_text) + if target_dates and any(date_ordinal > cutoff_ordinal for date_ordinal in target_dates): + return False + + return True + + def analyst_target_contradicts_validated_step(target: dict[str, Any], step_snapshot: dict[str, Any] | None) -> bool: if not isinstance(step_snapshot, dict): return False @@ -4742,6 +4850,13 @@ def analyst_target_contradicts_validated_step(target: dict[str, Any], step_snaps "guarded_insufficiency_validated", "runtime_factual_answer_validated", "bounded_mcp_answer_validated", + "truth gate", + "runtime truth", + "truth confirmation", + "validated factual status", + "runtime truth confirmation", + "shape-only", + "candidate-only", "heuristic_candidates", "silent heuristic", "unvalidated", @@ -4751,6 +4866,9 @@ def analyst_target_contradicts_validated_step(target: dict[str, Any], step_snaps if any(marker in target_text for marker in stale_validation_markers): return True + if validated_temporal_cutoff_target_is_false_positive(target, step_snapshot, target_text): + return True + assistant_text = str(step_snapshot.get("assistant_text_excerpt") or "").casefold() problem_type = str(target.get("problem_type") or "").strip() root_cause_layers = set(normalize_string_list(target.get("root_cause_layers"))) diff --git a/scripts/test_domain_case_loop_step_state.py b/scripts/test_domain_case_loop_step_state.py index b3e35fe..a8c7d76 100644 --- a/scripts/test_domain_case_loop_step_state.py +++ b/scripts/test_domain_case_loop_step_state.py @@ -677,6 +677,179 @@ class DomainCaseLoopStepStateTests(unittest.TestCase): self.assertEqual(step_state["business_first_review"]["issue_codes"], []) self.assertEqual(step_state["acceptance_status"], "validated") + def test_assistant_truth_policy_runtime_contract_validates_exact_factual_step(self) -> None: + step_state = dcl.build_scenario_step_state( + scenario_id="truth_contract_demo", + domain="inventory_selected_object_reliability", + step={ + "step_id": "step_01", + "title": "VAT by selected purchase date", + "depends_on": [], + "question_template": "calculate VAT by selected purchase date", + "required_answer_shape": "direct_answer_first", + "required_answer_patterns_all": ["VAT", "Q1 2015"], + }, + step_index=1, + question_resolved="calculate VAT by selected purchase date", + analysis_context={}, + turn_artifact={ + "assistant_message": { + "reply_type": "factual", + "text": "VAT for Q1 2015 is confirmed: 1,556,449.06. Basis: sales and purchase ledgers.", + "message_id": "msg-1", + "trace_id": "trace-1", + }, + "technical_debug_payload": { + "detected_mode": "address_query", + "detected_intent": "vat_liability_confirmed_for_tax_period", + "selected_recipe": "address_vat_liability_confirmed_tax_period_v1", + "capability_id": "confirmed_vat_liability_for_tax_period", + "capability_route_mode": "exact", + "fallback_type": "none", + "mcp_call_status": "matched_non_empty", + "response_type": "FACTUAL_SUMMARY", + "result_mode": "confirmed_balance", + "balance_confirmed": True, + "mcp_discovery_route_candidate_status": "ready_for_reviewed_execution", + "mcp_discovery_response_applied": False, + "assistant_runtime_contract_v1": { + "capability_contract_id": "confirmed_vat_liability_for_tax_period", + "truth_gate_contract_status": "full_confirmed", + "carryover_eligibility": "root_only", + }, + "assistant_truth_answer_policy_v1": { + "truth_gate": { + "coverage_status": "full", + "evidence_grade": "strong", + "truth_mode": "confirmed", + "source_truth_gate_status": "full_confirmed", + }, + "answer_shape": { + "answer_shape": "confirmed_factual", + "capability_contract_id": "confirmed_vat_liability_for_tax_period", + }, + }, + }, + "session_summary": {}, + }, + entries=[], + ) + + self.assertEqual(step_state["capability_contract_id"], "confirmed_vat_liability_for_tax_period") + self.assertEqual(step_state["truth_gate_contract_status"], "full_confirmed") + self.assertEqual(step_state["truth_mode"], "confirmed") + self.assertEqual(step_state["coverage_status"], "full") + self.assertEqual(step_state["answer_shape"], "confirmed_factual") + self.assertTrue(step_state["runtime_factual_answer_validated"]) + self.assertEqual(step_state["acceptance_status"], "validated") + + def test_analyst_shape_only_target_is_suppressed_for_truth_confirmed_runtime_step(self) -> None: + repair_targets = { + "targets": [], + "step_validation_index": { + "scenario_a:step_01": { + "acceptance_status": "validated", + "violated_invariants": [], + "warnings": [], + "runtime_factual_answer_validated": True, + "truth_gate_contract_status": "full_confirmed", + "assistant_text_excerpt": "Confirmed business answer for the selected item.", + } + }, + } + analyst_verdict = { + "priority_targets": [ + { + "scenario_id": "scenario_a", + "step_id": "step_01", + "severity": "P1", + "problem_type": "capability_gap", + "root_cause_layers": ["runtime_capability_gap"], + "fix_goal": "Remove shape-only acceptance and add runtime truth confirmation.", + } + ] + } + + merged = dcl.merge_analyst_priority_repair_targets(repair_targets, analyst_verdict) + + self.assertEqual(merged["target_count"], 0) + self.assertEqual(merged["suppressed_analyst_priority_target_count"], 1) + self.assertEqual( + merged["suppressed_analyst_priority_targets"][0]["reason"], + "analyst_target_contradicts_validated_step", + ) + + def test_analyst_temporal_cutoff_false_positive_is_suppressed_for_validated_step(self) -> None: + repair_targets = { + "targets": [], + "step_validation_index": { + "inventory_chain:s03": { + "acceptance_status": "validated", + "violated_invariants": [], + "warnings": [], + "runtime_factual_answer_validated": True, + "extracted_filters": {"as_of_date": "2016-03-31"}, + "assistant_text_excerpt": ( + "По позиции до 31.03.2016 поставщик не подтвержден. " + "Первая закупка: 05.02.2015. Последняя закупка: 22.07.2015. " + "Опорный документ от 22.07.2015." + ), + } + }, + } + analyst_verdict = { + "priority_targets": [ + { + "scenario_id": "inventory_chain", + "step_id": "s03", + "severity": "P0", + "problem_type": "other", + "root_cause_layers": ["temporal_honesty_gap"], + "fix_goal": "The answer declares a historical cutoff but cites proof rows with later dates.", + } + ] + } + + merged = dcl.merge_analyst_priority_repair_targets(repair_targets, analyst_verdict) + + self.assertEqual(merged["target_count"], 0) + self.assertEqual(merged["suppressed_analyst_priority_target_count"], 1) + + def test_analyst_temporal_cutoff_real_out_of_window_target_is_kept(self) -> None: + repair_targets = { + "targets": [], + "step_validation_index": { + "inventory_chain:s03": { + "acceptance_status": "validated", + "violated_invariants": [], + "warnings": [], + "runtime_factual_answer_validated": True, + "extracted_filters": {"as_of_date": "2016-03-31"}, + "assistant_text_excerpt": ( + "По позиции до 31.03.2016 поставщик не подтвержден. " + "Опорный документ от 25.05.2026." + ), + } + }, + } + analyst_verdict = { + "priority_targets": [ + { + "scenario_id": "inventory_chain", + "step_id": "s03", + "severity": "P0", + "problem_type": "other", + "root_cause_layers": ["temporal_honesty_gap"], + "fix_goal": "The answer declares a historical cutoff but cites proof rows with later dates.", + } + ] + } + + merged = dcl.merge_analyst_priority_repair_targets(repair_targets, analyst_verdict) + + self.assertEqual(merged["target_count"], 1) + self.assertEqual(merged["suppressed_analyst_priority_target_count"], 0) + def test_literal_result_mode_contract_still_rejects_mismatch(self) -> None: step_state = dcl.build_scenario_step_state( scenario_id="literal_result_mode_demo",