From dc8dfcf2378626226d0134e75f94982eee831ae3 Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 17 Apr 2026 09:41:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A7=20=D0=90=D0=9F11=20-=20?= =?UTF-8?q?=D0=92=D1=8B=D0=BD=D0=B5=D1=81=D1=82=D0=B8=20exact-lane=20truth?= =?UTF-8?q?=20gate=20=D0=B4=D0=BB=D1=8F=20factual-negative=20=D0=B8=20limi?= =?UTF-8?q?ted=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20policy?= =?UTF-8?q?-owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/addressQueryService.js | 266 +++++++++------ .../dist/services/addressTruthGatePolicy.js | 243 +++++++++++++ .../assistantRuntimeContractResolver.js | 18 +- ...ssistantTruthAnswerPolicyRuntimeAdapter.js | 14 +- .../src/services/addressQueryService.ts | 112 ++++-- .../src/services/addressTruthGatePolicy.ts | 321 ++++++++++++++++++ .../assistantRuntimeContractResolver.ts | 24 +- ...ssistantTruthAnswerPolicyRuntimeAdapter.ts | 16 +- .../backend/src/types/addressQuery.ts | 15 + .../tests/addressTruthGatePolicy.test.ts | 75 ++++ .../assistantRuntimeContractRegistry.test.ts | 23 ++ ...antTruthAnswerPolicyRuntimeAdapter.test.ts | 28 ++ 12 files changed, 1001 insertions(+), 154 deletions(-) create mode 100644 llm_normalizer/backend/dist/services/addressTruthGatePolicy.js create mode 100644 llm_normalizer/backend/src/services/addressTruthGatePolicy.ts create mode 100644 llm_normalizer/backend/tests/addressTruthGatePolicy.test.ts diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index baaaeaf..a83eb38 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -10,6 +10,7 @@ const composeStage_1 = require("./address_runtime/composeStage"); const addressCapabilityPolicy_1 = require("./addressCapabilityPolicy"); const addressRouteExpectations_1 = require("./addressRouteExpectations"); const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher"); +const addressTruthGatePolicy_1 = require("./addressTruthGatePolicy"); const openaiResponsesClient_1 = require("./openaiResponsesClient"); const files_1 = require("../utils/files"); const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"]; @@ -2509,6 +2510,76 @@ function buildLimitedExecutionResult(input) { requestedResultMode: requestedResultMode, resultMode: resultSemantics.result_mode }); + const runtimeReadiness = runtimeReadinessForLimitedCategory(input.category); + const debugPayload = (0, addressTruthGatePolicy_1.attachAddressTruthGate)({ + detected_mode: input.mode.mode, + detected_mode_confidence: input.mode.confidence, + query_shape: input.shape.shape, + query_shape_confidence: input.shape.confidence, + detected_intent: input.intent.intent, + detected_intent_confidence: input.intent.confidence, + extracted_filters: input.filters, + missing_required_filters: input.missingRequiredFilters, + selected_recipe: input.selectedRecipe, + mcp_call_status_legacy: toLegacyMcpStatus(input.mcpCallStatus), + account_scope_mode: input.accountScopeMode ?? "strict", + account_scope_fallback_applied: input.accountScopeFallbackApplied ?? false, + anchor_type: input.anchor?.anchor_type ?? null, + anchor_value_raw: input.anchor?.anchor_value_raw ?? null, + anchor_value_resolved: input.anchor?.anchor_value_resolved ?? null, + resolver_confidence: input.anchor?.resolver_confidence ?? null, + ambiguity_count: input.anchor?.ambiguity_count ?? 0, + match_failure_stage: input.matchFailureStage ?? "none", + match_failure_reason: input.matchFailureReason ?? null, + mcp_call_status: input.mcpCallStatus, + rows_fetched: input.rowsFetched, + raw_rows_received: input.rawRowsReceived ?? input.rowsFetched, + rows_after_account_scope: input.rowsAfterAccountScope ?? 0, + rows_after_recipe_filter: input.rowsAfterRecipeFilter ?? 0, + rows_materialized: input.rowsMaterialized ?? 0, + rows_matched: input.rowsMatched, + raw_row_keys_sample: input.rawRowKeysSample ?? [], + materialization_drop_reason: input.materializationDropReason ?? "none", + account_token_raw: accountScopeAudit.accountTokenRaw, + account_token_normalized: accountScopeAudit.accountTokenNormalized, + account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked, + account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy, + account_scope_drop_reason: accountScopeAudit.accountScopeDropReason, + runtime_readiness: runtimeReadiness, + limited_reason_category: input.category, + semantic_frame: input.semanticFrame ?? null, + response_type: "LIMITED_WITH_REASON", + capability_id: input.capabilityAudit?.capabilityId ?? null, + capability_layer: input.capabilityAudit?.layer ?? null, + capability_route_mode: input.capabilityAudit?.routeMode ?? null, + capability_route_enabled: input.capabilityAudit?.enabled ?? true, + capability_route_reason: input.capabilityAudit?.reason ?? null, + shadow_route_intent: input.shadowRouteAudit?.intent ?? null, + shadow_route_selected_recipe: input.shadowRouteAudit?.selectedRecipe ?? null, + shadow_route_status: input.shadowRouteAudit?.status ?? "skipped", + route_expectation_status: routeExpectationAudit.status, + route_expectation_reason: routeExpectationAudit.reason, + route_expectation_expected_selected_recipes: routeExpectationAudit.expectedSelectedRecipes, + route_expectation_expected_requested_result_modes: routeExpectationAudit.expectedRequestedResultModes, + route_expectation_expected_result_modes: routeExpectationAudit.expectedResultModes, + ...resultSemantics, + limitations: input.limitations, + reasons + }, { + intent: input.intent.intent, + filters: input.filters, + semanticFrame: input.semanticFrame ?? null, + selectedRecipe: input.selectedRecipe, + rowsMatched: input.rowsMatched, + limitedReasonCategory: input.category, + runtimeReadiness, + missingRequiredFilters: input.missingRequiredFilters, + limitations: input.limitations, + reasons, + routeExpectationStatus: routeExpectationAudit.status, + routeExpectationReason: routeExpectationAudit.reason, + replyType: "partial_coverage" + }); return { handled: true, reply_text: composeLimitedReply({ @@ -2522,61 +2593,7 @@ function buildLimitedExecutionResult(input) { }), reply_type: "partial_coverage", response_type: "LIMITED_WITH_REASON", - debug: { - detected_mode: input.mode.mode, - detected_mode_confidence: input.mode.confidence, - query_shape: input.shape.shape, - query_shape_confidence: input.shape.confidence, - detected_intent: input.intent.intent, - detected_intent_confidence: input.intent.confidence, - extracted_filters: input.filters, - missing_required_filters: input.missingRequiredFilters, - selected_recipe: input.selectedRecipe, - mcp_call_status_legacy: toLegacyMcpStatus(input.mcpCallStatus), - account_scope_mode: input.accountScopeMode ?? "strict", - account_scope_fallback_applied: input.accountScopeFallbackApplied ?? false, - anchor_type: input.anchor?.anchor_type ?? null, - anchor_value_raw: input.anchor?.anchor_value_raw ?? null, - anchor_value_resolved: input.anchor?.anchor_value_resolved ?? null, - resolver_confidence: input.anchor?.resolver_confidence ?? null, - ambiguity_count: input.anchor?.ambiguity_count ?? 0, - match_failure_stage: input.matchFailureStage ?? "none", - match_failure_reason: input.matchFailureReason ?? null, - mcp_call_status: input.mcpCallStatus, - rows_fetched: input.rowsFetched, - raw_rows_received: input.rawRowsReceived ?? input.rowsFetched, - rows_after_account_scope: input.rowsAfterAccountScope ?? 0, - rows_after_recipe_filter: input.rowsAfterRecipeFilter ?? 0, - rows_materialized: input.rowsMaterialized ?? 0, - rows_matched: input.rowsMatched, - raw_row_keys_sample: input.rawRowKeysSample ?? [], - materialization_drop_reason: input.materializationDropReason ?? "none", - account_token_raw: accountScopeAudit.accountTokenRaw, - account_token_normalized: accountScopeAudit.accountTokenNormalized, - account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked, - account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy, - account_scope_drop_reason: accountScopeAudit.accountScopeDropReason, - runtime_readiness: runtimeReadinessForLimitedCategory(input.category), - limited_reason_category: input.category, - semantic_frame: input.semanticFrame ?? null, - response_type: "LIMITED_WITH_REASON", - capability_id: input.capabilityAudit?.capabilityId ?? null, - capability_layer: input.capabilityAudit?.layer ?? null, - capability_route_mode: input.capabilityAudit?.routeMode ?? null, - capability_route_enabled: input.capabilityAudit?.enabled ?? true, - capability_route_reason: input.capabilityAudit?.reason ?? null, - shadow_route_intent: input.shadowRouteAudit?.intent ?? null, - shadow_route_selected_recipe: input.shadowRouteAudit?.selectedRecipe ?? null, - shadow_route_status: input.shadowRouteAudit?.status ?? "skipped", - route_expectation_status: routeExpectationAudit.status, - route_expectation_reason: routeExpectationAudit.reason, - route_expectation_expected_selected_recipes: routeExpectationAudit.expectedSelectedRecipes, - route_expectation_expected_requested_result_modes: routeExpectationAudit.expectedRequestedResultModes, - route_expectation_expected_result_modes: routeExpectationAudit.expectedResultModes, - ...resultSemantics, - limitations: input.limitations, - reasons - } + debug: debugPayload }; } function composeOrganizationClarificationReply(organizations) { @@ -3322,66 +3339,95 @@ class AddressQueryService { responseType, rowsMatched: 0 }), undefined); + const factualNoRowsLimitations = [...filters.warnings, ...extraLimitations]; + const factualNoRowsReasons = withConfirmedBalanceFallbackReason([...baseReasons, noRowsReason], requestedResultMode, undefined, semantics.result_mode); + const routeExpectationAudit = buildRouteExpectationAudit({ + intent: routeExpectationIntent, + selectedRecipe: effectiveRecipeId, + requestedResultMode, + resultMode: semantics.result_mode + }); + const debugPayload = (0, addressTruthGatePolicy_1.attachAddressTruthGate)({ + detected_mode: mode.mode, + detected_mode_confidence: mode.confidence, + query_shape: shape.shape, + query_shape_confidence: shape.confidence, + detected_intent: intent.intent, + detected_intent_confidence: intent.confidence, + extracted_filters: filters.extracted_filters, + missing_required_filters: [], + selected_recipe: effectiveRecipeId, + mcp_call_status_legacy: toLegacyMcpStatus(stageStatus), + account_scope_mode: plan.account_scope_mode, + account_scope_fallback_applied: accountScopeFallbackApplied, + anchor_type: anchor.anchor_type, + anchor_value_raw: anchor.anchor_value_raw, + anchor_value_resolved: anchor.anchor_value_resolved, + resolver_confidence: anchor.resolver_confidence, + ambiguity_count: anchor.ambiguity_count, + match_failure_stage: matchFailureStage, + match_failure_reason: matchFailureReason, + mcp_call_status: stageStatus, + rows_fetched: mcp.fetched_rows, + raw_rows_received: mcp.raw_rows.length, + rows_after_account_scope: normalizedRows.length, + rows_after_recipe_filter: filterByAnchors.length, + rows_materialized: normalizedRows.length, + rows_matched: 0, + raw_row_keys_sample: rowDiagnostics.rawRowKeysSample, + materialization_drop_reason: rowDiagnostics.materializationDropReason, + account_token_raw: accountScopeAudit.accountTokenRaw, + account_token_normalized: accountScopeAudit.accountTokenNormalized, + account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked, + account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy, + account_scope_drop_reason: accountScopeAudit.accountScopeDropReason, + runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", + limited_reason_category: null, + response_type: responseType, + route_expectation_status: routeExpectationAudit.status, + route_expectation_reason: routeExpectationAudit.reason, + route_expectation_expected_selected_recipes: routeExpectationAudit.expectedSelectedRecipes, + route_expectation_expected_requested_result_modes: routeExpectationAudit.expectedRequestedResultModes, + route_expectation_expected_result_modes: routeExpectationAudit.expectedResultModes, + ...semantics, + limitations: factualNoRowsLimitations, + reasons: factualNoRowsReasons, + ...(capabilityAudit + ? { + capability_id: capabilityAudit.capabilityId, + capability_layer: capabilityAudit.layer, + capability_route_mode: capabilityAudit.routeMode, + capability_route_enabled: capabilityAudit.enabled, + capability_route_reason: capabilityAudit.reason + } + : {}), + ...(shadowRouteAudit + ? { + shadow_route_intent: shadowRouteAudit.intent, + shadow_route_selected_recipe: shadowRouteAudit.selectedRecipe, + shadow_route_status: shadowRouteAudit.status + } + : {}) + }, { + intent: intent.intent, + filters: filters.extracted_filters, + semanticFrame, + selectedRecipe: effectiveRecipeId, + rowsMatched: 0, + limitedReasonCategory: "empty_match", + runtimeReadiness: "LIVE_QUERYABLE_WITH_LIMITS", + limitations: factualNoRowsLimitations, + reasons: factualNoRowsReasons, + routeExpectationStatus: routeExpectationAudit.status, + routeExpectationReason: routeExpectationAudit.reason, + replyType: (0, composeStage_1.inferReplyType)(responseType) + }); return { handled: true, reply_text: replyText, reply_type: (0, composeStage_1.inferReplyType)(responseType), response_type: responseType, - debug: { - detected_mode: mode.mode, - detected_mode_confidence: mode.confidence, - query_shape: shape.shape, - query_shape_confidence: shape.confidence, - detected_intent: intent.intent, - detected_intent_confidence: intent.confidence, - extracted_filters: filters.extracted_filters, - missing_required_filters: [], - selected_recipe: effectiveRecipeId, - mcp_call_status_legacy: toLegacyMcpStatus(stageStatus), - account_scope_mode: plan.account_scope_mode, - account_scope_fallback_applied: accountScopeFallbackApplied, - anchor_type: anchor.anchor_type, - anchor_value_raw: anchor.anchor_value_raw, - anchor_value_resolved: anchor.anchor_value_resolved, - resolver_confidence: anchor.resolver_confidence, - ambiguity_count: anchor.ambiguity_count, - match_failure_stage: matchFailureStage, - match_failure_reason: matchFailureReason, - mcp_call_status: stageStatus, - rows_fetched: mcp.fetched_rows, - raw_rows_received: mcp.raw_rows.length, - rows_after_account_scope: normalizedRows.length, - rows_after_recipe_filter: filterByAnchors.length, - rows_materialized: normalizedRows.length, - rows_matched: 0, - raw_row_keys_sample: rowDiagnostics.rawRowKeysSample, - materialization_drop_reason: rowDiagnostics.materializationDropReason, - account_token_raw: accountScopeAudit.accountTokenRaw, - account_token_normalized: accountScopeAudit.accountTokenNormalized, - account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked, - account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy, - account_scope_drop_reason: accountScopeAudit.accountScopeDropReason, - runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", - limited_reason_category: null, - response_type: responseType, - ...semantics, - limitations: [...filters.warnings, ...extraLimitations], - reasons: withConfirmedBalanceFallbackReason([...baseReasons, noRowsReason], requestedResultMode, undefined, semantics.result_mode), - ...(capabilityAudit - ? { - capability_id: capabilityAudit.capabilityId, - capability_layer: capabilityAudit.layer, - capability_route_mode: capabilityAudit.routeMode, - capability_route_enabled: capabilityAudit.enabled, - capability_route_reason: capabilityAudit.reason - } - : {}), - ...(shadowRouteAudit - ? { - shadow_route_status: shadowRouteAudit.status - } - : {}) - } + debug: debugPayload }; }; if (organizationWarehouseRecoveryApplied) { diff --git a/llm_normalizer/backend/dist/services/addressTruthGatePolicy.js b/llm_normalizer/backend/dist/services/addressTruthGatePolicy.js new file mode 100644 index 0000000..1a4b169 --- /dev/null +++ b/llm_normalizer/backend/dist/services/addressTruthGatePolicy.js @@ -0,0 +1,243 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ADDRESS_TRUTH_GATE_SCHEMA_VERSION = void 0; +exports.resolveAddressTruthGate = resolveAddressTruthGate; +exports.toAddressTruthGateContract = toAddressTruthGateContract; +exports.buildAddressTruthGateDebugFields = buildAddressTruthGateDebugFields; +exports.attachAddressTruthGate = attachAddressTruthGate; +exports.ADDRESS_TRUTH_GATE_SCHEMA_VERSION = "address_truth_gate_v1"; +function toRecordObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} +function toNonEmptyString(value) { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} +function toStringList(value) { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => toNonEmptyString(item)) + .filter((item) => Boolean(item)); +} +function isTruthGateStatus(value) { + return (value === "full_confirmed" || + value === "partial_supported" || + value === "blocked_missing_anchor" || + value === "blocked_route_expectation_failure" || + value === "blocked_execution_error" || + value === "limited_temporal_or_contextual" || + value === "unknown"); +} +function isCarryoverDepth(value) { + return value === "full" || value === "root_only" || value === "object_only" || value === "meta_only" || value === "none"; +} +function isLimitedReasonCategory(value) { + return (value === "empty_match" || + value === "missing_anchor" || + value === "recipe_visibility_gap" || + value === "execution_error" || + value === "unsupported"); +} +function isRuntimeReadiness(value) { + return (value === "LIVE_QUERYABLE" || + value === "LIVE_QUERYABLE_WITH_LIMITS" || + value === "REQUIRES_SPECIALIZED_RECIPE" || + value === "DEEP_ONLY" || + value === "UNKNOWN"); +} +function normalizeReasonCode(value) { + const normalized = value + .trim() + .replace(/[^\p{L}\p{N}_.:-]+/gu, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + return normalized.length > 0 ? normalized.slice(0, 120) : null; +} +function pushReason(target, value) { + const text = toNonEmptyString(value); + if (!text) { + return; + } + const normalized = normalizeReasonCode(text); + if (normalized && !target.includes(normalized)) { + target.push(normalized); + } +} +function hasFilterScope(filters) { + if (!filters) { + return false; + } + const scopeKeys = [ + "period_from", + "period_to", + "as_of_date", + "organization", + "counterparty", + "contract", + "account", + "item", + "warehouse", + "document_type", + "document_ref", + "status" + ]; + return scopeKeys.some((key) => toNonEmptyString(filters[key]) !== null); +} +function hasObjectFocus(input) { + if (toNonEmptyString(input.filters?.item)) { + return true; + } + if (input.semanticFrame?.selected_object_scope_detected) { + return true; + } + if (input.semanticFrame?.anchor_kind === "selected_object" || input.semanticFrame?.anchor_kind === "item") { + return true; + } + return (input.intent === "inventory_purchase_provenance_for_item" || + input.intent === "inventory_purchase_documents_for_item" || + input.intent === "inventory_sale_trace_for_item" || + input.intent === "inventory_profitability_for_item" || + input.intent === "inventory_purchase_to_sale_chain" || + input.intent === "inventory_aging_by_purchase_date"); +} +function hasReusableRootScope(input) { + if (hasFilterScope(input.filters)) { + return true; + } + return Boolean(input.semanticFrame && input.semanticFrame.scope_kind !== "none"); +} +function truthGateStatusFrom(input) { + const missingRequiredFilters = input.missingRequiredFilters ?? []; + if (input.routeExpectationStatus === "mismatch") { + return "blocked_route_expectation_failure"; + } + if (input.limitedReasonCategory === "execution_error") { + return "blocked_execution_error"; + } + if (missingRequiredFilters.length > 0 || input.limitedReasonCategory === "missing_anchor") { + return "blocked_missing_anchor"; + } + if (input.temporalGuardOutcome === "ambiguous_limited" || input.temporalAlignmentStatus === "conflicting") { + return "limited_temporal_or_contextual"; + } + if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") { + return "full_confirmed"; + } + if (input.limitedReasonCategory === "empty_match" || + input.limitedReasonCategory === "recipe_visibility_gap" || + input.limitedReasonCategory === "unsupported" || + input.replyType === "partial_coverage") { + return "partial_supported"; + } + if ((input.rowsMatched ?? 0) > 0) { + return "full_confirmed"; + } + return "unknown"; +} +function carryoverEligibilityFor(input, truthGateStatus) { + if (truthGateStatus.startsWith("blocked")) { + return "none"; + } + if (input.limitedReasonCategory === "recipe_visibility_gap" || input.limitedReasonCategory === "unsupported") { + return "meta_only"; + } + if (hasObjectFocus(input)) { + return "object_only"; + } + if (truthGateStatus === "limited_temporal_or_contextual") { + return hasReusableRootScope(input) ? "root_only" : "meta_only"; + } + if (truthGateStatus === "full_confirmed" || truthGateStatus === "partial_supported") { + return hasReusableRootScope(input) ? "root_only" : "meta_only"; + } + return "none"; +} +function explanationFor(truthGateStatus, limitedReasonCategory) { + if (truthGateStatus === "full_confirmed") { + return null; + } + if (truthGateStatus === "blocked_missing_anchor") { + return "required_anchor_missing"; + } + if (truthGateStatus === "blocked_route_expectation_failure") { + return "route_expectation_failed"; + } + if (truthGateStatus === "blocked_execution_error") { + return "execution_failed"; + } + if (truthGateStatus === "limited_temporal_or_contextual") { + return "temporal_or_contextual_limit"; + } + if (limitedReasonCategory === "recipe_visibility_gap") { + return "specialized_recipe_required"; + } + if (limitedReasonCategory === "unsupported") { + return "unsupported_in_current_contour"; + } + return truthGateStatus === "partial_supported" ? "evidence_or_coverage_is_partial" : "truth_gate_not_confirmed"; +} +function collectReasonCodes(input, truthGateStatus) { + const reasons = []; + pushReason(reasons, `address_truth_gate_${truthGateStatus}`); + pushReason(reasons, input.routeExpectationReason); + pushReason(reasons, input.limitedReasonCategory ? `limited_category_${input.limitedReasonCategory}` : null); + (input.missingRequiredFilters ?? []).forEach((item) => pushReason(reasons, `missing_filter_${item}`)); + (input.limitations ?? []).forEach((item) => pushReason(reasons, item)); + (input.reasons ?? []).forEach((item) => pushReason(reasons, item)); + return reasons.slice(0, 32); +} +function resolveAddressTruthGate(input) { + const truthGateStatus = truthGateStatusFrom(input); + return { + schema_version: exports.ADDRESS_TRUTH_GATE_SCHEMA_VERSION, + policy_owner: "addressTruthGatePolicy", + truth_gate_status: truthGateStatus, + carryover_eligibility: carryoverEligibilityFor(input, truthGateStatus), + limited_reason_category: input.limitedReasonCategory ?? null, + runtime_readiness: input.runtimeReadiness ?? "UNKNOWN", + reason_codes: collectReasonCodes(input, truthGateStatus), + blocked_or_limited_explanation: explanationFor(truthGateStatus, input.limitedReasonCategory ?? null) + }; +} +function toAddressTruthGateContract(value) { + const record = toRecordObject(value); + if (!record) { + return null; + } + const truthGateStatus = toNonEmptyString(record.truth_gate_status); + const carryoverEligibility = toNonEmptyString(record.carryover_eligibility); + const limitedReasonCategory = toNonEmptyString(record.limited_reason_category); + const runtimeReadiness = toNonEmptyString(record.runtime_readiness); + if (!isTruthGateStatus(truthGateStatus) || !isCarryoverDepth(carryoverEligibility) || !isRuntimeReadiness(runtimeReadiness)) { + return null; + } + return { + schema_version: exports.ADDRESS_TRUTH_GATE_SCHEMA_VERSION, + policy_owner: "addressTruthGatePolicy", + truth_gate_status: truthGateStatus, + carryover_eligibility: carryoverEligibility, + limited_reason_category: isLimitedReasonCategory(limitedReasonCategory) ? limitedReasonCategory : null, + runtime_readiness: runtimeReadiness, + reason_codes: toStringList(record.reason_codes).slice(0, 32), + blocked_or_limited_explanation: toNonEmptyString(record.blocked_or_limited_explanation) + }; +} +function buildAddressTruthGateDebugFields(input) { + return { + address_truth_gate_v1: resolveAddressTruthGate(input) + }; +} +function attachAddressTruthGate(debugPayload, input) { + return { + ...debugPayload, + ...buildAddressTruthGateDebugFields(input) + }; +} diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeContractResolver.js b/llm_normalizer/backend/dist/services/assistantRuntimeContractResolver.js index 1b9cb27..c705e9d 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeContractResolver.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeContractResolver.js @@ -4,6 +4,7 @@ exports.resolveAssistantRuntimeContractShadow = resolveAssistantRuntimeContractS exports.buildAssistantRuntimeContractShadowFields = buildAssistantRuntimeContractShadowFields; exports.attachAssistantRuntimeContractShadow = attachAssistantRuntimeContractShadow; const assistantRuntimeContracts_1 = require("../types/assistantRuntimeContracts"); +const addressTruthGatePolicy_1 = require("./addressTruthGatePolicy"); const assistantRuntimeContractRegistry_1 = require("./assistantRuntimeContractRegistry"); function toRecordObject(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -134,6 +135,9 @@ function resolveTransitionId(input) { return { transitionId: null, reasons: ["transition_contract_not_resolved"] }; } function resolveTruthGateStatus(input) { + if (input.explicitTruthGateStatus) { + return input.explicitTruthGateStatus; + } if (input.groundingStatus === "route_mismatch_blocked" || input.debug.route_expectation_status === "mismatch") { return "blocked_route_expectation_failure"; } @@ -157,10 +161,13 @@ function resolveTruthGateStatus(input) { } return "unknown"; } -function carryoverEligibilityFor(transitionId, truthGateStatus) { +function carryoverEligibilityFor(transitionId, truthGateStatus, explicitCarryoverEligibility) { if (truthGateStatus.startsWith("blocked")) { return "none"; } + if (explicitCarryoverEligibility) { + return explicitCarryoverEligibility; + } if (transitionId === "T3" || transitionId === "T4" || transitionId === "T5") { return "object_only"; } @@ -178,6 +185,7 @@ function carryoverEligibilityFor(transitionId, truthGateStatus) { function resolveAssistantRuntimeContractShadow(input) { const debug = toRecordObject(input.addressDebug) ?? {}; const meta = runtimeMetaFrom(input); + const addressTruthGate = (0, addressTruthGatePolicy_1.toAddressTruthGateContract)(debug.address_truth_gate_v1); const groundingStatus = toNonEmptyString(input.groundingStatus) ?? toNonEmptyString(toRecordObject(debug.answer_grounding_check)?.status); const capability = resolveCapabilityContractId(debug, meta); @@ -188,7 +196,11 @@ function resolveAssistantRuntimeContractShadow(input) { groundingStatus }); const transitionContract = transition.transitionId ? (0, assistantRuntimeContractRegistry_1.getAssistantTransitionContract)(transition.transitionId) : null; - const truthGateStatus = resolveTruthGateStatus({ debug, groundingStatus }); + const truthGateStatus = resolveTruthGateStatus({ + debug, + groundingStatus, + explicitTruthGateStatus: addressTruthGate?.truth_gate_status ?? null + }); return { schema_version: assistantRuntimeContracts_1.ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION, transition_contract_id: transition.transitionId, @@ -197,7 +209,7 @@ function resolveAssistantRuntimeContractShadow(input) { capability_contract_id: capability.capabilityId, capability_contract_reason: capability.reasons, truth_gate_contract_status: truthGateStatus, - carryover_eligibility: carryoverEligibilityFor(transition.transitionId, truthGateStatus) + carryover_eligibility: carryoverEligibilityFor(transition.transitionId, truthGateStatus, addressTruthGate?.carryover_eligibility ?? null) }; } function buildAssistantRuntimeContractShadowFields(input) { diff --git a/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js index 736a8c1..60f57e1 100644 --- a/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js @@ -4,6 +4,7 @@ exports.resolveAssistantTruthAnswerPolicyRuntime = resolveAssistantTruthAnswerPo exports.buildAssistantTruthAnswerPolicyRuntimeFields = buildAssistantTruthAnswerPolicyRuntimeFields; exports.attachAssistantTruthAnswerPolicy = attachAssistantTruthAnswerPolicy; const assistantRuntimeContracts_1 = require("../types/assistantRuntimeContracts"); +const addressTruthGatePolicy_1 = require("./addressTruthGatePolicy"); const assistantRuntimeContractResolver_1 = require("./assistantRuntimeContractResolver"); function toRecordObject(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -164,6 +165,7 @@ function collectReasonCodes(input) { const reasons = []; pushReason(reasons, `truth_gate_${input.truthGateStatus}`); pushReason(reasons, `truth_mode_${input.truthMode}`); + input.explicitGateReasonCodes.forEach((item) => pushReason(reasons, item)); input.shadow.transition_contract_reason.forEach((item) => pushReason(reasons, item)); input.shadow.capability_contract_reason.forEach((item) => pushReason(reasons, item)); toStringList(input.debug.missing_required_filters).forEach((item) => pushReason(reasons, `missing_filter_${item}`)); @@ -232,12 +234,13 @@ function requiredSectionsFor(shape) { } function resolveAssistantTruthAnswerPolicyRuntime(input) { const debug = toRecordObject(input.addressDebug) ?? {}; + const explicitAddressTruthGate = (0, addressTruthGatePolicy_1.toAddressTruthGateContract)(debug.address_truth_gate_v1); const shadow = (0, assistantRuntimeContractResolver_1.resolveAssistantRuntimeContractShadow)({ addressDebug: debug, addressRuntimeMeta: input.addressRuntimeMeta, groundingStatus: input.groundingStatus }); - const truthGateStatus = shadow.truth_gate_contract_status; + const truthGateStatus = explicitAddressTruthGate?.truth_gate_status ?? shadow.truth_gate_contract_status; const groundingStatus = groundingStatusFrom(debug, input, truthGateStatus); const coverageStatus = coverageStatusFrom(debug, input, truthGateStatus, groundingStatus); const truthMode = truthModeFrom({ @@ -253,14 +256,17 @@ function resolveAssistantTruthAnswerPolicyRuntime(input) { coverageReport, shadow, truthMode, - truthGateStatus + truthGateStatus, + explicitGateReasonCodes: explicitAddressTruthGate?.reason_codes ?? [] }); const shape = answerShapeFrom({ coverageStatus, truthMode, truthGateStatus }); - const carryoverEligibility = coverageStatus === "blocked" || truthMode === "unsupported" ? "none" : shadow.carryover_eligibility; + const carryoverEligibility = coverageStatus === "blocked" || truthMode === "unsupported" + ? "none" + : explicitAddressTruthGate?.carryover_eligibility ?? shadow.carryover_eligibility; const truthGate = { schema_version: assistantRuntimeContracts_1.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", @@ -271,7 +277,7 @@ function resolveAssistantTruthAnswerPolicyRuntime(input) { carryover_eligibility: carryoverEligibility, reason_codes: reasonCodes, source_truth_gate_status: truthGateStatus, - blocked_or_limited_explanation: explanationFor(truthGateStatus, truthMode, coverageStatus) + blocked_or_limited_explanation: explicitAddressTruthGate?.blocked_or_limited_explanation ?? explanationFor(truthGateStatus, truthMode, coverageStatus) }; const answerShape = { schema_version: assistantRuntimeContracts_1.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index ff2a770..fc1ff75 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -63,6 +63,7 @@ import { normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage } from "./assistantOrganizationMatcher"; +import { attachAddressTruthGate } from "./addressTruthGatePolicy"; import { OpenAIResponsesClient, type OpenAIRequestConfig } from "./openaiResponsesClient"; import { readJsonFile } from "../utils/files"; @@ -3157,20 +3158,9 @@ function buildLimitedExecutionResult(input: { requestedResultMode: requestedResultMode, resultMode: resultSemantics.result_mode }); - return { - handled: true, - reply_text: composeLimitedReply({ - category: input.category, - reason: input.reasonText, - nextStep: input.nextStep, - shape: input.shape, - intent: input.intent.intent, - filters: input.filters, - missingRequiredFilters: input.missingRequiredFilters - }), - reply_type: "partial_coverage", - response_type: "LIMITED_WITH_REASON", - debug: { + const runtimeReadiness = runtimeReadinessForLimitedCategory(input.category); + const debugPayload = attachAddressTruthGate( + { detected_mode: input.mode.mode, detected_mode_confidence: input.mode.confidence, query_shape: input.shape.shape, @@ -3204,10 +3194,10 @@ function buildLimitedExecutionResult(input: { account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked, account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy, account_scope_drop_reason: accountScopeAudit.accountScopeDropReason, - runtime_readiness: runtimeReadinessForLimitedCategory(input.category), + runtime_readiness: runtimeReadiness, limited_reason_category: input.category, semantic_frame: input.semanticFrame ?? null, - response_type: "LIMITED_WITH_REASON", + response_type: "LIMITED_WITH_REASON" as const, capability_id: input.capabilityAudit?.capabilityId ?? null, capability_layer: input.capabilityAudit?.layer ?? null, capability_route_mode: input.capabilityAudit?.routeMode ?? null, @@ -3224,7 +3214,37 @@ function buildLimitedExecutionResult(input: { ...resultSemantics, limitations: input.limitations, reasons + }, + { + intent: input.intent.intent, + filters: input.filters, + semanticFrame: input.semanticFrame ?? null, + selectedRecipe: input.selectedRecipe, + rowsMatched: input.rowsMatched, + limitedReasonCategory: input.category, + runtimeReadiness, + missingRequiredFilters: input.missingRequiredFilters, + limitations: input.limitations, + reasons, + routeExpectationStatus: routeExpectationAudit.status, + routeExpectationReason: routeExpectationAudit.reason, + replyType: "partial_coverage" } + ); + return { + handled: true, + reply_text: composeLimitedReply({ + category: input.category, + reason: input.reasonText, + nextStep: input.nextStep, + shape: input.shape, + intent: input.intent.intent, + filters: input.filters, + missingRequiredFilters: input.missingRequiredFilters + }), + reply_type: "partial_coverage", + response_type: "LIMITED_WITH_REASON", + debug: debugPayload }; } @@ -4088,12 +4108,21 @@ export class AddressQueryService { }), undefined ); - return { - handled: true, - reply_text: replyText, - reply_type: inferReplyType(responseType), - response_type: responseType, - debug: { + const factualNoRowsLimitations = [...filters.warnings, ...extraLimitations]; + const factualNoRowsReasons = withConfirmedBalanceFallbackReason( + [...baseReasons, noRowsReason], + requestedResultMode, + undefined, + semantics.result_mode + ); + const routeExpectationAudit = buildRouteExpectationAudit({ + intent: routeExpectationIntent, + selectedRecipe: effectiveRecipeId, + requestedResultMode, + resultMode: semantics.result_mode + }); + const debugPayload = attachAddressTruthGate( + { detected_mode: mode.mode, detected_mode_confidence: mode.confidence, query_shape: shape.shape, @@ -4127,17 +4156,17 @@ export class AddressQueryService { account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked, account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy, account_scope_drop_reason: accountScopeAudit.accountScopeDropReason, - runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", + runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS" as const, limited_reason_category: null, response_type: responseType, + route_expectation_status: routeExpectationAudit.status, + route_expectation_reason: routeExpectationAudit.reason, + route_expectation_expected_selected_recipes: routeExpectationAudit.expectedSelectedRecipes, + route_expectation_expected_requested_result_modes: routeExpectationAudit.expectedRequestedResultModes, + route_expectation_expected_result_modes: routeExpectationAudit.expectedResultModes, ...semantics, - limitations: [...filters.warnings, ...extraLimitations], - reasons: withConfirmedBalanceFallbackReason( - [...baseReasons, noRowsReason], - requestedResultMode, - undefined, - semantics.result_mode - ), + limitations: factualNoRowsLimitations, + reasons: factualNoRowsReasons, ...(capabilityAudit ? { capability_id: capabilityAudit.capabilityId, @@ -4149,10 +4178,33 @@ export class AddressQueryService { : {}), ...(shadowRouteAudit ? { + shadow_route_intent: shadowRouteAudit.intent, + shadow_route_selected_recipe: shadowRouteAudit.selectedRecipe, shadow_route_status: shadowRouteAudit.status } : {}) + }, + { + intent: intent.intent, + filters: filters.extracted_filters, + semanticFrame, + selectedRecipe: effectiveRecipeId, + rowsMatched: 0, + limitedReasonCategory: "empty_match", + runtimeReadiness: "LIVE_QUERYABLE_WITH_LIMITS", + limitations: factualNoRowsLimitations, + reasons: factualNoRowsReasons, + routeExpectationStatus: routeExpectationAudit.status, + routeExpectationReason: routeExpectationAudit.reason, + replyType: inferReplyType(responseType) } + ); + return { + handled: true, + reply_text: replyText, + reply_type: inferReplyType(responseType), + response_type: responseType, + debug: debugPayload }; }; if (organizationWarehouseRecoveryApplied) { diff --git a/llm_normalizer/backend/src/services/addressTruthGatePolicy.ts b/llm_normalizer/backend/src/services/addressTruthGatePolicy.ts new file mode 100644 index 0000000..bb84e37 --- /dev/null +++ b/llm_normalizer/backend/src/services/addressTruthGatePolicy.ts @@ -0,0 +1,321 @@ +import type { + AssistantCarryoverDepth, + AssistantTruthGateContractStatus +} from "../types/assistantRuntimeContracts"; +import type { + AddressFilterSet, + AddressIntent, + AddressLimitedReasonCategory, + AddressRuntimeReadiness, + AddressSemanticFrame +} from "../types/addressQuery"; + +export const ADDRESS_TRUTH_GATE_SCHEMA_VERSION = "address_truth_gate_v1" as const; + +export interface AddressTruthGateContract { + schema_version: typeof ADDRESS_TRUTH_GATE_SCHEMA_VERSION; + policy_owner: "addressTruthGatePolicy"; + truth_gate_status: AssistantTruthGateContractStatus; + carryover_eligibility: AssistantCarryoverDepth; + limited_reason_category: AddressLimitedReasonCategory | null; + runtime_readiness: AddressRuntimeReadiness; + reason_codes: string[]; + blocked_or_limited_explanation: string | null; +} + +export interface ResolveAddressTruthGateInput { + intent: AddressIntent; + filters?: AddressFilterSet | null; + semanticFrame?: AddressSemanticFrame | null; + selectedRecipe?: string | null; + rowsMatched?: number; + limitedReasonCategory?: AddressLimitedReasonCategory | null; + runtimeReadiness?: AddressRuntimeReadiness | null; + missingRequiredFilters?: string[]; + limitations?: string[]; + reasons?: string[]; + routeExpectationStatus?: "matched" | "mismatch" | "not_found" | null; + routeExpectationReason?: string | null; + temporalGuardOutcome?: string | null; + temporalAlignmentStatus?: string | null; + replyType?: "factual" | "partial_coverage" | "deep_analysis" | "unknown" | null; +} + +function toRecordObject(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function toNonEmptyString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + +function toStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => toNonEmptyString(item)) + .filter((item): item is string => Boolean(item)); +} + +function isTruthGateStatus(value: string | null): value is AssistantTruthGateContractStatus { + return ( + value === "full_confirmed" || + value === "partial_supported" || + value === "blocked_missing_anchor" || + value === "blocked_route_expectation_failure" || + value === "blocked_execution_error" || + value === "limited_temporal_or_contextual" || + value === "unknown" + ); +} + +function isCarryoverDepth(value: string | null): value is AssistantCarryoverDepth { + return value === "full" || value === "root_only" || value === "object_only" || value === "meta_only" || value === "none"; +} + +function isLimitedReasonCategory(value: string | null): value is AddressLimitedReasonCategory { + return ( + value === "empty_match" || + value === "missing_anchor" || + value === "recipe_visibility_gap" || + value === "execution_error" || + value === "unsupported" + ); +} + +function isRuntimeReadiness(value: string | null): value is AddressRuntimeReadiness { + return ( + value === "LIVE_QUERYABLE" || + value === "LIVE_QUERYABLE_WITH_LIMITS" || + value === "REQUIRES_SPECIALIZED_RECIPE" || + value === "DEEP_ONLY" || + value === "UNKNOWN" + ); +} + +function normalizeReasonCode(value: string): string | null { + const normalized = value + .trim() + .replace(/[^\p{L}\p{N}_.:-]+/gu, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + return normalized.length > 0 ? normalized.slice(0, 120) : null; +} + +function pushReason(target: string[], value: unknown): void { + const text = toNonEmptyString(value); + if (!text) { + return; + } + const normalized = normalizeReasonCode(text); + if (normalized && !target.includes(normalized)) { + target.push(normalized); + } +} + +function hasFilterScope(filters: AddressFilterSet | null | undefined): boolean { + if (!filters) { + return false; + } + const scopeKeys: Array = [ + "period_from", + "period_to", + "as_of_date", + "organization", + "counterparty", + "contract", + "account", + "item", + "warehouse", + "document_type", + "document_ref", + "status" + ]; + return scopeKeys.some((key) => toNonEmptyString(filters[key]) !== null); +} + +function hasObjectFocus(input: ResolveAddressTruthGateInput): boolean { + if (toNonEmptyString(input.filters?.item)) { + return true; + } + if (input.semanticFrame?.selected_object_scope_detected) { + return true; + } + if (input.semanticFrame?.anchor_kind === "selected_object" || input.semanticFrame?.anchor_kind === "item") { + return true; + } + return ( + input.intent === "inventory_purchase_provenance_for_item" || + input.intent === "inventory_purchase_documents_for_item" || + input.intent === "inventory_sale_trace_for_item" || + input.intent === "inventory_profitability_for_item" || + input.intent === "inventory_purchase_to_sale_chain" || + input.intent === "inventory_aging_by_purchase_date" + ); +} + +function hasReusableRootScope(input: ResolveAddressTruthGateInput): boolean { + if (hasFilterScope(input.filters)) { + return true; + } + return Boolean(input.semanticFrame && input.semanticFrame.scope_kind !== "none"); +} + +function truthGateStatusFrom(input: ResolveAddressTruthGateInput): AssistantTruthGateContractStatus { + const missingRequiredFilters = input.missingRequiredFilters ?? []; + if (input.routeExpectationStatus === "mismatch") { + return "blocked_route_expectation_failure"; + } + if (input.limitedReasonCategory === "execution_error") { + return "blocked_execution_error"; + } + if (missingRequiredFilters.length > 0 || input.limitedReasonCategory === "missing_anchor") { + return "blocked_missing_anchor"; + } + if (input.temporalGuardOutcome === "ambiguous_limited" || input.temporalAlignmentStatus === "conflicting") { + return "limited_temporal_or_contextual"; + } + if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") { + return "full_confirmed"; + } + if ( + input.limitedReasonCategory === "empty_match" || + input.limitedReasonCategory === "recipe_visibility_gap" || + input.limitedReasonCategory === "unsupported" || + input.replyType === "partial_coverage" + ) { + return "partial_supported"; + } + if ((input.rowsMatched ?? 0) > 0) { + return "full_confirmed"; + } + return "unknown"; +} + +function carryoverEligibilityFor( + input: ResolveAddressTruthGateInput, + truthGateStatus: AssistantTruthGateContractStatus +): AssistantCarryoverDepth { + if (truthGateStatus.startsWith("blocked")) { + return "none"; + } + if (input.limitedReasonCategory === "recipe_visibility_gap" || input.limitedReasonCategory === "unsupported") { + return "meta_only"; + } + if (hasObjectFocus(input)) { + return "object_only"; + } + if (truthGateStatus === "limited_temporal_or_contextual") { + return hasReusableRootScope(input) ? "root_only" : "meta_only"; + } + if (truthGateStatus === "full_confirmed" || truthGateStatus === "partial_supported") { + return hasReusableRootScope(input) ? "root_only" : "meta_only"; + } + return "none"; +} + +function explanationFor( + truthGateStatus: AssistantTruthGateContractStatus, + limitedReasonCategory: AddressLimitedReasonCategory | null +): string | null { + if (truthGateStatus === "full_confirmed") { + return null; + } + if (truthGateStatus === "blocked_missing_anchor") { + return "required_anchor_missing"; + } + if (truthGateStatus === "blocked_route_expectation_failure") { + return "route_expectation_failed"; + } + if (truthGateStatus === "blocked_execution_error") { + return "execution_failed"; + } + if (truthGateStatus === "limited_temporal_or_contextual") { + return "temporal_or_contextual_limit"; + } + if (limitedReasonCategory === "recipe_visibility_gap") { + return "specialized_recipe_required"; + } + if (limitedReasonCategory === "unsupported") { + return "unsupported_in_current_contour"; + } + return truthGateStatus === "partial_supported" ? "evidence_or_coverage_is_partial" : "truth_gate_not_confirmed"; +} + +function collectReasonCodes( + input: ResolveAddressTruthGateInput, + truthGateStatus: AssistantTruthGateContractStatus +): string[] { + const reasons: string[] = []; + pushReason(reasons, `address_truth_gate_${truthGateStatus}`); + pushReason(reasons, input.routeExpectationReason); + pushReason(reasons, input.limitedReasonCategory ? `limited_category_${input.limitedReasonCategory}` : null); + (input.missingRequiredFilters ?? []).forEach((item) => pushReason(reasons, `missing_filter_${item}`)); + (input.limitations ?? []).forEach((item) => pushReason(reasons, item)); + (input.reasons ?? []).forEach((item) => pushReason(reasons, item)); + return reasons.slice(0, 32); +} + +export function resolveAddressTruthGate(input: ResolveAddressTruthGateInput): AddressTruthGateContract { + const truthGateStatus = truthGateStatusFrom(input); + return { + schema_version: ADDRESS_TRUTH_GATE_SCHEMA_VERSION, + policy_owner: "addressTruthGatePolicy", + truth_gate_status: truthGateStatus, + carryover_eligibility: carryoverEligibilityFor(input, truthGateStatus), + limited_reason_category: input.limitedReasonCategory ?? null, + runtime_readiness: input.runtimeReadiness ?? "UNKNOWN", + reason_codes: collectReasonCodes(input, truthGateStatus), + blocked_or_limited_explanation: explanationFor(truthGateStatus, input.limitedReasonCategory ?? null) + }; +} + +export function toAddressTruthGateContract(value: unknown): AddressTruthGateContract | null { + const record = toRecordObject(value); + if (!record) { + return null; + } + const truthGateStatus = toNonEmptyString(record.truth_gate_status); + const carryoverEligibility = toNonEmptyString(record.carryover_eligibility); + const limitedReasonCategory = toNonEmptyString(record.limited_reason_category); + const runtimeReadiness = toNonEmptyString(record.runtime_readiness); + if (!isTruthGateStatus(truthGateStatus) || !isCarryoverDepth(carryoverEligibility) || !isRuntimeReadiness(runtimeReadiness)) { + return null; + } + return { + schema_version: ADDRESS_TRUTH_GATE_SCHEMA_VERSION, + policy_owner: "addressTruthGatePolicy", + truth_gate_status: truthGateStatus, + carryover_eligibility: carryoverEligibility, + limited_reason_category: isLimitedReasonCategory(limitedReasonCategory) ? limitedReasonCategory : null, + runtime_readiness: runtimeReadiness, + reason_codes: toStringList(record.reason_codes).slice(0, 32), + blocked_or_limited_explanation: toNonEmptyString(record.blocked_or_limited_explanation) + }; +} + +export function buildAddressTruthGateDebugFields(input: ResolveAddressTruthGateInput): { + address_truth_gate_v1: AddressTruthGateContract; +} { + return { + address_truth_gate_v1: resolveAddressTruthGate(input) + }; +} + +export function attachAddressTruthGate>( + debugPayload: T, + input: ResolveAddressTruthGateInput +): T & { address_truth_gate_v1: AddressTruthGateContract } { + return { + ...debugPayload, + ...buildAddressTruthGateDebugFields(input) + }; +} diff --git a/llm_normalizer/backend/src/services/assistantRuntimeContractResolver.ts b/llm_normalizer/backend/src/services/assistantRuntimeContractResolver.ts index 9c86a34..bef6700 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeContractResolver.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeContractResolver.ts @@ -5,6 +5,7 @@ import { type AssistantTransitionClassId } from "../types/assistantRuntimeContracts"; import type { AddressIntent } from "../types/addressQuery"; +import { toAddressTruthGateContract } from "./addressTruthGatePolicy"; import { getAssistantCapabilityContract, getAssistantCapabilityContractByIntent, @@ -190,7 +191,11 @@ function resolveTransitionId(input: { function resolveTruthGateStatus(input: { debug: Record; groundingStatus: string | null; + explicitTruthGateStatus?: AssistantRuntimeContractShadowDecision["truth_gate_contract_status"] | null; }): AssistantRuntimeContractShadowDecision["truth_gate_contract_status"] { + if (input.explicitTruthGateStatus) { + return input.explicitTruthGateStatus; + } if (input.groundingStatus === "route_mismatch_blocked" || input.debug.route_expectation_status === "mismatch") { return "blocked_route_expectation_failure"; } @@ -219,11 +224,15 @@ function resolveTruthGateStatus(input: { function carryoverEligibilityFor( transitionId: AssistantTransitionClassId | null, - truthGateStatus: AssistantRuntimeContractShadowDecision["truth_gate_contract_status"] + truthGateStatus: AssistantRuntimeContractShadowDecision["truth_gate_contract_status"], + explicitCarryoverEligibility?: AssistantCarryoverDepth | null ): AssistantCarryoverDepth { if (truthGateStatus.startsWith("blocked")) { return "none"; } + if (explicitCarryoverEligibility) { + return explicitCarryoverEligibility; + } if (transitionId === "T3" || transitionId === "T4" || transitionId === "T5") { return "object_only"; } @@ -244,6 +253,7 @@ export function resolveAssistantRuntimeContractShadow( ): AssistantRuntimeContractShadowDecision { const debug = toRecordObject(input.addressDebug) ?? {}; const meta = runtimeMetaFrom(input); + const addressTruthGate = toAddressTruthGateContract(debug.address_truth_gate_v1); const groundingStatus = toNonEmptyString(input.groundingStatus) ?? toNonEmptyString(toRecordObject(debug.answer_grounding_check)?.status); @@ -255,7 +265,11 @@ export function resolveAssistantRuntimeContractShadow( groundingStatus }); const transitionContract = transition.transitionId ? getAssistantTransitionContract(transition.transitionId) : null; - const truthGateStatus = resolveTruthGateStatus({ debug, groundingStatus }); + const truthGateStatus = resolveTruthGateStatus({ + debug, + groundingStatus, + explicitTruthGateStatus: addressTruthGate?.truth_gate_status ?? null + }); return { schema_version: ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION, transition_contract_id: transition.transitionId, @@ -264,7 +278,11 @@ export function resolveAssistantRuntimeContractShadow( capability_contract_id: capability.capabilityId, capability_contract_reason: capability.reasons, truth_gate_contract_status: truthGateStatus, - carryover_eligibility: carryoverEligibilityFor(transition.transitionId, truthGateStatus) + carryover_eligibility: carryoverEligibilityFor( + transition.transitionId, + truthGateStatus, + addressTruthGate?.carryover_eligibility ?? null + ) }; } diff --git a/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts index 2e70c26..d83e7f8 100644 --- a/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts @@ -11,6 +11,7 @@ import { type AssistantTruthGateContractStatus, type AssistantTruthMode } from "../types/assistantRuntimeContracts"; +import { toAddressTruthGateContract } from "./addressTruthGatePolicy"; import { resolveAssistantRuntimeContractShadow } from "./assistantRuntimeContractResolver"; export interface ResolveAssistantTruthAnswerPolicyRuntimeInput { @@ -228,10 +229,12 @@ function collectReasonCodes(input: { shadow: AssistantRuntimeContractShadowDecision; truthMode: AssistantTruthMode; truthGateStatus: AssistantTruthGateContractStatus; + explicitGateReasonCodes: string[]; }): string[] { const reasons: string[] = []; pushReason(reasons, `truth_gate_${input.truthGateStatus}`); pushReason(reasons, `truth_mode_${input.truthMode}`); + input.explicitGateReasonCodes.forEach((item) => pushReason(reasons, item)); input.shadow.transition_contract_reason.forEach((item) => pushReason(reasons, item)); input.shadow.capability_contract_reason.forEach((item) => pushReason(reasons, item)); toStringList(input.debug.missing_required_filters).forEach((item) => pushReason(reasons, `missing_filter_${item}`)); @@ -314,12 +317,13 @@ export function resolveAssistantTruthAnswerPolicyRuntime( input: ResolveAssistantTruthAnswerPolicyRuntimeInput ): AssistantTruthAnswerPolicyRuntimeContract { const debug = toRecordObject(input.addressDebug) ?? {}; + const explicitAddressTruthGate = toAddressTruthGateContract(debug.address_truth_gate_v1); const shadow = resolveAssistantRuntimeContractShadow({ addressDebug: debug, addressRuntimeMeta: input.addressRuntimeMeta, groundingStatus: input.groundingStatus }); - const truthGateStatus = shadow.truth_gate_contract_status; + const truthGateStatus = explicitAddressTruthGate?.truth_gate_status ?? shadow.truth_gate_contract_status; const groundingStatus = groundingStatusFrom(debug, input, truthGateStatus); const coverageStatus = coverageStatusFrom(debug, input, truthGateStatus, groundingStatus); const truthMode = truthModeFrom({ @@ -335,7 +339,8 @@ export function resolveAssistantTruthAnswerPolicyRuntime( coverageReport, shadow, truthMode, - truthGateStatus + truthGateStatus, + explicitGateReasonCodes: explicitAddressTruthGate?.reason_codes ?? [] }); const shape = answerShapeFrom({ coverageStatus, @@ -343,7 +348,9 @@ export function resolveAssistantTruthAnswerPolicyRuntime( truthGateStatus }); const carryoverEligibility = - coverageStatus === "blocked" || truthMode === "unsupported" ? "none" : shadow.carryover_eligibility; + coverageStatus === "blocked" || truthMode === "unsupported" + ? "none" + : explicitAddressTruthGate?.carryover_eligibility ?? shadow.carryover_eligibility; const truthGate = { schema_version: ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, @@ -355,7 +362,8 @@ export function resolveAssistantTruthAnswerPolicyRuntime( carryover_eligibility: carryoverEligibility, reason_codes: reasonCodes, source_truth_gate_status: truthGateStatus, - blocked_or_limited_explanation: explanationFor(truthGateStatus, truthMode, coverageStatus) + blocked_or_limited_explanation: + explicitAddressTruthGate?.blocked_or_limited_explanation ?? explanationFor(truthGateStatus, truthMode, coverageStatus) } satisfies AssistantTruthAnswerPolicyRuntimeContract["truth_gate"]; const answerShape = { diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index 599eba8..b68461a 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -1,5 +1,10 @@ export type AddressQuestionMode = "address_query" | "deep_analysis" | "unsupported"; +import type { + AssistantCarryoverDepth, + AssistantTruthGateContractStatus +} from "./assistantRuntimeContracts"; + export type AddressIntent = | "period_coverage_profile" | "document_type_and_account_section_profile" @@ -261,6 +266,16 @@ export interface AddressExecutionDebug { | "rows_remaining_after_scope_filter"; runtime_readiness: AddressRuntimeReadiness; limited_reason_category: AddressLimitedReasonCategory | null; + address_truth_gate_v1?: { + schema_version: "address_truth_gate_v1"; + policy_owner: "addressTruthGatePolicy"; + truth_gate_status: AssistantTruthGateContractStatus; + carryover_eligibility: AssistantCarryoverDepth; + limited_reason_category: AddressLimitedReasonCategory | null; + runtime_readiness: AddressRuntimeReadiness; + reason_codes: string[]; + blocked_or_limited_explanation: string | null; + } | null; organization_candidates?: string[]; semantic_frame?: AddressSemanticFrame | null; response_type: AddressResponseType; diff --git a/llm_normalizer/backend/tests/addressTruthGatePolicy.test.ts b/llm_normalizer/backend/tests/addressTruthGatePolicy.test.ts new file mode 100644 index 0000000..132f54a --- /dev/null +++ b/llm_normalizer/backend/tests/addressTruthGatePolicy.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { resolveAddressTruthGate } from "../src/services/addressTruthGatePolicy"; + +describe("address truth gate policy", () => { + it("treats factual exact no-match as confirmed negative with reusable root scope", () => { + const gate = resolveAddressTruthGate({ + intent: "open_items_by_counterparty_or_contract", + filters: { + account: "60", + period_from: "2022-08-01", + period_to: "2022-08-31" + }, + selectedRecipe: "open_items_by_counterparty_or_contract", + rowsMatched: 0, + limitedReasonCategory: "empty_match", + runtimeReadiness: "LIVE_QUERYABLE_WITH_LIMITS", + reasons: ["open_items_account_exact_negative_response"], + routeExpectationStatus: "matched", + replyType: "factual" + }); + + expect(gate.truth_gate_status).toBe("full_confirmed"); + expect(gate.carryover_eligibility).toBe("root_only"); + expect(gate.blocked_or_limited_explanation).toBeNull(); + expect(gate.reason_codes).toContain("limited_category_empty_match"); + }); + + it("keeps selected-item limited answers object-scoped", () => { + const gate = resolveAddressTruthGate({ + intent: "inventory_sale_trace_for_item", + filters: { + item: "Рабочая станция" + }, + semanticFrame: { + scope_kind: "selected_object_scope", + anchor_kind: "selected_object", + anchor_value: "Рабочая станция", + date_scope_kind: "explicit", + date_basis_hint: "explicit_as_of_date", + self_scope_detected: false, + selected_object_scope_detected: true + }, + selectedRecipe: "inventory_sale_trace_for_item", + rowsMatched: 0, + limitedReasonCategory: "empty_match", + runtimeReadiness: "LIVE_QUERYABLE_WITH_LIMITS", + limitations: ["no_rows_after_recipe_and_scope_filter"], + routeExpectationStatus: "matched", + replyType: "partial_coverage" + }); + + expect(gate.truth_gate_status).toBe("partial_supported"); + expect(gate.carryover_eligibility).toBe("object_only"); + expect(gate.blocked_or_limited_explanation).toBe("evidence_or_coverage_is_partial"); + }); + + it("blocks missing-anchor clarifications instead of pretending they are reusable evidence", () => { + const gate = resolveAddressTruthGate({ + intent: "inventory_purchase_provenance_for_item", + selectedRecipe: "inventory_purchase_provenance_for_item", + rowsMatched: 0, + limitedReasonCategory: "missing_anchor", + runtimeReadiness: "LIVE_QUERYABLE_WITH_LIMITS", + missingRequiredFilters: ["item"], + limitations: ["missing_required_filters"], + routeExpectationStatus: "matched", + replyType: "partial_coverage" + }); + + expect(gate.truth_gate_status).toBe("blocked_missing_anchor"); + expect(gate.carryover_eligibility).toBe("none"); + expect(gate.blocked_or_limited_explanation).toBe("required_anchor_missing"); + expect(gate.reason_codes).toContain("missing_filter_item"); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts b/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts index d5fc71f..a0ff38b 100644 --- a/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts +++ b/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts @@ -143,4 +143,27 @@ describe("assistant runtime contract registry", () => { expect(decision.truth_gate_contract_status).toBe("limited_temporal_or_contextual"); }); + + it("reuses explicit exact-lane truth gate contracts for factual no-match carryover", () => { + const decision = resolveAssistantRuntimeContractShadow({ + addressDebug: { + detected_intent: "open_items_by_counterparty_or_contract", + selected_recipe: "open_items_by_counterparty_or_contract", + rows_matched: 0, + address_truth_gate_v1: { + schema_version: "address_truth_gate_v1", + policy_owner: "addressTruthGatePolicy", + truth_gate_status: "full_confirmed", + carryover_eligibility: "root_only", + limited_reason_category: "empty_match", + runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", + reason_codes: ["open_items_account_exact_negative_response"], + blocked_or_limited_explanation: null + } + } + }); + + expect(decision.truth_gate_contract_status).toBe("full_confirmed"); + expect(decision.carryover_eligibility).toBe("root_only"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts index 15960e4..bccf0c0 100644 --- a/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts @@ -89,6 +89,34 @@ describe("assistant truth answer policy runtime adapter", () => { expect(policy.answer_shape.may_power_followup).toBe(false); }); + it("honors explicit exact-lane truth gate contracts for factual negative answers", () => { + const policy = resolveAssistantTruthAnswerPolicyRuntime({ + addressDebug: { + capability_id: "inventory_counterparty_item_flow", + rows_matched: 0, + address_truth_gate_v1: { + schema_version: "address_truth_gate_v1", + policy_owner: "addressTruthGatePolicy", + truth_gate_status: "full_confirmed", + carryover_eligibility: "root_only", + limited_reason_category: "empty_match", + runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", + reason_codes: ["counterparty_item_flow_exact_negative_response"], + blocked_or_limited_explanation: null + } + }, + replyType: "factual" + }); + + expect(policy.truth_gate.coverage_status).toBe("full"); + expect(policy.truth_gate.grounding_status).toBe("grounded"); + expect(policy.truth_gate.truth_mode).toBe("confirmed"); + expect(policy.truth_gate.carryover_eligibility).toBe("root_only"); + expect(policy.truth_gate.reason_codes).toContain("counterparty_item_flow_exact_negative_response"); + expect(policy.answer_shape.answer_shape).toBe("confirmed_factual"); + expect(policy.answer_shape.may_power_followup).toBe(true); + }); + it("attaches top-level debug fields without hiding the nested contract", () => { const debug = attachAssistantTruthAnswerPolicy( {