From 89bcaccda8c74d0027a11d93a67cc41cd79db5a2 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 15 Apr 2026 23:21:13 +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=B4=D0=B5=D0=BB=D0=B8=D1=82=D1=8C=20truth=20?= =?UTF-8?q?=D0=B8=20answer-shape=20policy=20=D0=B2=20assistant=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...istantAddressLaneResponseRuntimeAdapter.js | 9 +- .../assistantDebugPayloadAssembler.js | 9 +- ...ssistantTruthAnswerPolicyRuntimeAdapter.js | 315 ++++++++++++++ .../dist/types/assistantRuntimeContracts.js | 3 +- ...istantAddressLaneResponseRuntimeAdapter.ts | 9 +- .../assistantDebugPayloadAssembler.ts | 9 +- ...ssistantTruthAnswerPolicyRuntimeAdapter.ts | 408 ++++++++++++++++++ llm_normalizer/backend/src/types/assistant.ts | 9 + .../src/types/assistantRuntimeContracts.ts | 66 ++- ...tAddressLaneResponseRuntimeAdapter.test.ts | 24 +- .../assistantDebugPayloadAssembler.test.ts | 18 + ...antTruthAnswerPolicyRuntimeAdapter.test.ts | 111 +++++ 12 files changed, 970 insertions(+), 20 deletions(-) create mode 100644 llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js create mode 100644 llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts create mode 100644 llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js index 189cd39..9d99df9 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js @@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.runAssistantAddressLaneResponseRuntime = runAssistantAddressLaneResponseRuntime; const assistantAddressTurnFinalizeRuntimeAdapter_1 = require("./assistantAddressTurnFinalizeRuntimeAdapter"); const assistantRuntimeContractResolver_1 = require("./assistantRuntimeContractResolver"); +const assistantTruthAnswerPolicyRuntimeAdapter_1 = require("./assistantTruthAnswerPolicyRuntimeAdapter"); function toRecordObject(value) { if (!value || typeof value !== "object") { return null; @@ -192,6 +193,10 @@ function runAssistantAddressLaneResponseRuntime(input) { userMessage: input.userMessage, addressRuntimeMeta: input.llmPreDecomposeMeta }); + const debugWithTruthAnswerPolicy = (0, assistantTruthAnswerPolicyRuntimeAdapter_1.attachAssistantTruthAnswerPolicy)(debugWithRuntimeContracts, { + addressRuntimeMeta: input.llmPreDecomposeMeta, + replyType: normalizeAddressReplyType(input.addressLane.reply_type) + }); const finalization = finalizeAddressTurnSafe({ sessionId: input.sessionId, userMessage: input.userMessage, @@ -199,7 +204,7 @@ function runAssistantAddressLaneResponseRuntime(input) { assistantReply: safeAddressReply, replyType: normalizeAddressReplyType(input.addressLane.reply_type), addressLaneDebug: normalizeAddressLaneDebug(input.addressLane.debug), - debug: debugWithRuntimeContracts, + debug: debugWithTruthAnswerPolicy, carryoverMeta: normalizeCarryoverMeta(input.carryoverMeta), llmPreDecomposeMeta: normalizeLlmPreDecomposeMeta(input.llmPreDecomposeMeta), appendItem: input.appendItem, @@ -211,6 +216,6 @@ function runAssistantAddressLaneResponseRuntime(input) { }); return { response: finalization.response, - debug: debugWithRuntimeContracts + debug: debugWithTruthAnswerPolicy }; } diff --git a/llm_normalizer/backend/dist/services/assistantDebugPayloadAssembler.js b/llm_normalizer/backend/dist/services/assistantDebugPayloadAssembler.js index a51789b..cdc6704 100644 --- a/llm_normalizer/backend/dist/services/assistantDebugPayloadAssembler.js +++ b/llm_normalizer/backend/dist/services/assistantDebugPayloadAssembler.js @@ -2,6 +2,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.buildDeepAnalysisDebugPayload = buildDeepAnalysisDebugPayload; const assistantRuntimeContractResolver_1 = require("./assistantRuntimeContractResolver"); +const assistantTruthAnswerPolicyRuntimeAdapter_1 = require("./assistantTruthAnswerPolicyRuntimeAdapter"); const assistantStage4AnswerContractAudit_1 = require("./assistantStage4AnswerContractAudit"); function toAnalysisContext(input) { if (!input.active) { @@ -94,8 +95,14 @@ function buildDeepAnalysisDebugPayload(input) { investigation_state_snapshot: input.investigationStateSnapshot, normalized: input.normalizedPayload }; - return (0, assistantRuntimeContractResolver_1.attachAssistantRuntimeContractShadow)(debugPayload, { + const debugWithRuntimeContracts = (0, assistantRuntimeContractResolver_1.attachAssistantRuntimeContractShadow)(debugPayload, { addressRuntimeMeta: input.addressRuntimeMetaForDeep, groundingStatus: input.groundingCheck.status }); + return (0, assistantTruthAnswerPolicyRuntimeAdapter_1.attachAssistantTruthAnswerPolicy)(debugWithRuntimeContracts, { + addressRuntimeMeta: input.addressRuntimeMetaForDeep, + groundingStatus: input.groundingCheck.status, + coverageReport: input.coverageReport, + replyType: "deep_analysis" + }); } diff --git a/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js new file mode 100644 index 0000000..736a8c1 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js @@ -0,0 +1,315 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.resolveAssistantTruthAnswerPolicyRuntime = resolveAssistantTruthAnswerPolicyRuntime; +exports.buildAssistantTruthAnswerPolicyRuntimeFields = buildAssistantTruthAnswerPolicyRuntimeFields; +exports.attachAssistantTruthAnswerPolicy = attachAssistantTruthAnswerPolicy; +const assistantRuntimeContracts_1 = require("../types/assistantRuntimeContracts"); +const assistantRuntimeContractResolver_1 = require("./assistantRuntimeContractResolver"); +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 asNumber(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} +function toStringList(value) { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => toNonEmptyString(item)) + .filter((item) => Boolean(item)); +} +function isGroundingStatus(value) { + return (value === "grounded" || + value === "partial" || + value === "route_mismatch_blocked" || + value === "no_grounded_answer" || + value === "unsupported"); +} +function isEvidenceGrade(value) { + return value === "weak" || value === "medium" || value === "strong" || value === "none"; +} +function normalizeReplyType(value) { + if (value === "factual" || value === "partial_coverage" || value === "deep_analysis") { + return value; + } + return "unknown"; +} +function groundingStatusFrom(debug, input, truthGateStatus) { + const explicit = toNonEmptyString(input.groundingStatus) ?? + toNonEmptyString(toRecordObject(debug.answer_grounding_check)?.status); + if (isGroundingStatus(explicit)) { + return explicit; + } + if (truthGateStatus === "blocked_route_expectation_failure") { + return "route_mismatch_blocked"; + } + if (truthGateStatus === "full_confirmed") { + return "grounded"; + } + if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") { + return "partial"; + } + if (truthGateStatus === "blocked_missing_anchor" || truthGateStatus === "blocked_execution_error") { + return "no_grounded_answer"; + } + const rowsMatched = asNumber(debug.rows_matched); + if (rowsMatched !== null && rowsMatched > 0) { + return "grounded"; + } + return "unsupported"; +} +function coverageStatusFrom(debug, input, truthGateStatus, groundingStatus) { + if (truthGateStatus === "full_confirmed") { + return "full"; + } + if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") { + return "partial"; + } + if (truthGateStatus.startsWith("blocked")) { + return "blocked"; + } + if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") { + return "blocked"; + } + const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report); + if (coverageReport) { + const total = asNumber(coverageReport.requirements_total); + const covered = asNumber(coverageReport.requirements_covered) ?? 0; + const uncovered = toStringList(coverageReport.requirements_uncovered); + const partial = toStringList(coverageReport.requirements_partially_covered); + const clarification = toStringList(coverageReport.clarification_needed_for); + const outOfScope = toStringList(coverageReport.out_of_scope_requirements); + if (total !== null && total > 0) { + if (covered >= total && uncovered.length === 0 && partial.length === 0 && clarification.length === 0 && outOfScope.length === 0) { + return "full"; + } + if (covered > 0 || partial.length > 0) { + return "partial"; + } + return "blocked"; + } + } + const rowsMatched = asNumber(debug.rows_matched); + if (rowsMatched !== null && rowsMatched > 0) { + return "full"; + } + return groundingStatus === "partial" ? "partial" : "blocked"; +} +function truthModeFrom(input) { + if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) { + return "clarification_required"; + } + if (input.truthGateStatus === "full_confirmed" || (input.coverageStatus === "full" && input.groundingStatus === "grounded")) { + return "confirmed"; + } + if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual" || input.coverageStatus === "partial") { + return "limited"; + } + return "unsupported"; +} +function evidenceGradeFrom(debug, coverageStatus, groundingStatus, truthGateStatus) { + const explicit = toNonEmptyString(debug.evidence_strength); + if (isEvidenceGrade(explicit)) { + return explicit; + } + if (coverageStatus === "blocked") { + return "none"; + } + if (truthGateStatus === "full_confirmed" || groundingStatus === "grounded") { + return "strong"; + } + const rowsMatched = asNumber(debug.rows_matched); + if (rowsMatched !== null && rowsMatched > 0) { + return "medium"; + } + return coverageStatus === "partial" ? "weak" : "none"; +} +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 collectReasonCodes(input) { + const reasons = []; + pushReason(reasons, `truth_gate_${input.truthGateStatus}`); + pushReason(reasons, `truth_mode_${input.truthMode}`); + 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}`)); + toStringList(input.debug.limitations).forEach((item) => pushReason(reasons, item)); + toStringList(input.debug.reasons).forEach((item) => pushReason(reasons, item)); + pushReason(reasons, input.debug.route_expectation_reason); + toStringList(toRecordObject(input.debug.answer_grounding_check)?.reasons).forEach((item) => pushReason(reasons, item)); + toStringList(input.coverageReport?.requirements_uncovered).forEach((item) => pushReason(reasons, `coverage_uncovered_${item}`)); + toStringList(input.coverageReport?.requirements_partially_covered).forEach((item) => pushReason(reasons, `coverage_partial_${item}`)); + toStringList(input.coverageReport?.clarification_needed_for).forEach((item) => pushReason(reasons, `coverage_clarification_${item}`)); + return reasons.slice(0, 32); +} +function explanationFor(truthGateStatus, truthMode, coverageStatus) { + if (truthGateStatus === "full_confirmed" && truthMode === "confirmed") { + return null; + } + if (truthGateStatus === "blocked_missing_anchor" || truthMode === "clarification_required") { + 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 (truthGateStatus === "partial_supported" || truthMode === "limited" || coverageStatus === "partial") { + return "evidence_or_coverage_is_partial"; + } + return "truth_gate_not_confirmed"; +} +function answerShapeFrom(input) { + if (input.truthMode === "confirmed") { + return "confirmed_factual"; + } + if (input.truthMode === "limited") { + return "limited_with_reason"; + } + if (input.truthMode === "clarification_required") { + return "clarification_required"; + } + if (input.coverageStatus === "blocked" || input.truthGateStatus.startsWith("blocked")) { + return "blocked_no_answer"; + } + return input.truthGateStatus === "unknown" ? "unknown" : "unsupported_boundary"; +} +function requiredSectionsFor(shape) { + if (shape === "confirmed_factual") { + return ["direct_answer", "evidence_basis"]; + } + if (shape === "limited_with_reason") { + return ["direct_answer", "evidence_window", "limitations"]; + } + if (shape === "clarification_required") { + return ["clarifying_question", "missing_anchors"]; + } + if (shape === "blocked_no_answer") { + return ["blocked_reason", "safe_next_step"]; + } + if (shape === "unsupported_boundary") { + return ["boundary_reason", "safe_next_step"]; + } + return ["safe_next_step"]; +} +function resolveAssistantTruthAnswerPolicyRuntime(input) { + const debug = toRecordObject(input.addressDebug) ?? {}; + const shadow = (0, assistantRuntimeContractResolver_1.resolveAssistantRuntimeContractShadow)({ + addressDebug: debug, + addressRuntimeMeta: input.addressRuntimeMeta, + groundingStatus: input.groundingStatus + }); + const truthGateStatus = shadow.truth_gate_contract_status; + const groundingStatus = groundingStatusFrom(debug, input, truthGateStatus); + const coverageStatus = coverageStatusFrom(debug, input, truthGateStatus, groundingStatus); + const truthMode = truthModeFrom({ + coverageStatus, + groundingStatus, + truthGateStatus, + debug + }); + const evidenceGrade = evidenceGradeFrom(debug, coverageStatus, groundingStatus, truthGateStatus); + const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report); + const reasonCodes = collectReasonCodes({ + debug, + coverageReport, + shadow, + truthMode, + truthGateStatus + }); + const shape = answerShapeFrom({ + coverageStatus, + truthMode, + truthGateStatus + }); + const carryoverEligibility = coverageStatus === "blocked" || truthMode === "unsupported" ? "none" : shadow.carryover_eligibility; + const truthGate = { + schema_version: assistantRuntimeContracts_1.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + coverage_status: coverageStatus, + evidence_grade: evidenceGrade, + grounding_status: groundingStatus, + truth_mode: truthMode, + carryover_eligibility: carryoverEligibility, + reason_codes: reasonCodes, + source_truth_gate_status: truthGateStatus, + blocked_or_limited_explanation: explanationFor(truthGateStatus, truthMode, coverageStatus) + }; + const answerShape = { + schema_version: assistantRuntimeContracts_1.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + answer_shape: shape, + reply_type: normalizeReplyType(input.replyType), + capability_contract_id: shadow.capability_contract_id, + transition_contract_id: shadow.transition_contract_id, + may_state_confirmed_facts: truthMode === "confirmed" || truthMode === "limited", + must_include_limitation: truthMode !== "confirmed", + may_power_followup: carryoverEligibility !== "none" && coverageStatus !== "blocked", + required_sections: requiredSectionsFor(shape), + downgrade_only: true + }; + return { + schema_version: assistantRuntimeContracts_1.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + truth_gate: truthGate, + answer_shape: answerShape + }; +} +function buildAssistantTruthAnswerPolicyRuntimeFields(input) { + const policy = resolveAssistantTruthAnswerPolicyRuntime(input); + return { + assistant_truth_answer_policy_v1: policy, + coverage_gate_contract: policy.truth_gate, + answer_shape_contract: policy.answer_shape, + truth_mode: policy.truth_gate.truth_mode, + carryover_eligibility: policy.truth_gate.carryover_eligibility, + answer_shape: policy.answer_shape.answer_shape + }; +} +function attachAssistantTruthAnswerPolicy(debugPayload, input) { + return { + ...debugPayload, + ...buildAssistantTruthAnswerPolicyRuntimeFields({ + ...input, + addressDebug: debugPayload + }) + }; +} diff --git a/llm_normalizer/backend/dist/types/assistantRuntimeContracts.js b/llm_normalizer/backend/dist/types/assistantRuntimeContracts.js index f0416fb..3574b11 100644 --- a/llm_normalizer/backend/dist/types/assistantRuntimeContracts.js +++ b/llm_normalizer/backend/dist/types/assistantRuntimeContracts.js @@ -1,4 +1,5 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION = void 0; +exports.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION = exports.ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION = void 0; exports.ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION = "assistant_runtime_contracts_v1"; +exports.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION = "assistant_truth_answer_policy_runtime_v1"; diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts index 624388c..17849a1 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts @@ -8,6 +8,7 @@ import { type FinalizeAssistantAddressTurnInput } from "./assistantAddressTurnFinalizeRuntimeAdapter"; import { attachAssistantRuntimeContractShadow } from "./assistantRuntimeContractResolver"; +import { attachAssistantTruthAnswerPolicy } from "./assistantTruthAnswerPolicyRuntimeAdapter"; export interface RunAssistantAddressLaneResponseRuntimeInput { sessionId: string; @@ -251,6 +252,10 @@ export function runAssistantAddressLaneResponseRuntime, { + const debugWithRuntimeContracts = attachAssistantRuntimeContractShadow(debugPayload as unknown as Record, { addressRuntimeMeta: input.addressRuntimeMetaForDeep as unknown as Record | null | undefined, groundingStatus: input.groundingCheck.status + }); + return attachAssistantTruthAnswerPolicy(debugWithRuntimeContracts, { + addressRuntimeMeta: input.addressRuntimeMetaForDeep as unknown as Record | null | undefined, + groundingStatus: input.groundingCheck.status, + coverageReport: input.coverageReport as unknown as Record, + replyType: "deep_analysis" }) as unknown as AssistantDebugPayload; } diff --git a/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts new file mode 100644 index 0000000..2e70c26 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts @@ -0,0 +1,408 @@ +import { + ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + type AssistantAnswerShapeKind, + type AssistantAnswerShapeReplyType, + type AssistantCarryoverDepth, + type AssistantCoverageStatus, + type AssistantEvidenceGrade, + type AssistantGroundingStatus, + type AssistantRuntimeContractShadowDecision, + type AssistantTruthAnswerPolicyRuntimeContract, + type AssistantTruthGateContractStatus, + type AssistantTruthMode +} from "../types/assistantRuntimeContracts"; +import { resolveAssistantRuntimeContractShadow } from "./assistantRuntimeContractResolver"; + +export interface ResolveAssistantTruthAnswerPolicyRuntimeInput { + addressDebug?: Record | null; + addressRuntimeMeta?: Record | null; + groundingStatus?: unknown; + coverageReport?: Record | null; + replyType?: unknown; +} + +export interface AssistantTruthAnswerPolicyRuntimeFields { + assistant_truth_answer_policy_v1: AssistantTruthAnswerPolicyRuntimeContract; + coverage_gate_contract: AssistantTruthAnswerPolicyRuntimeContract["truth_gate"]; + answer_shape_contract: AssistantTruthAnswerPolicyRuntimeContract["answer_shape"]; + truth_mode: AssistantTruthMode; + carryover_eligibility: AssistantCarryoverDepth; + answer_shape: AssistantAnswerShapeKind; +} + +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 asNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return 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 isGroundingStatus(value: string | null): value is AssistantGroundingStatus { + return ( + value === "grounded" || + value === "partial" || + value === "route_mismatch_blocked" || + value === "no_grounded_answer" || + value === "unsupported" + ); +} + +function isEvidenceGrade(value: string | null): value is AssistantEvidenceGrade { + return value === "weak" || value === "medium" || value === "strong" || value === "none"; +} + +function normalizeReplyType(value: unknown): AssistantAnswerShapeReplyType { + if (value === "factual" || value === "partial_coverage" || value === "deep_analysis") { + return value; + } + return "unknown"; +} + +function groundingStatusFrom( + debug: Record, + input: ResolveAssistantTruthAnswerPolicyRuntimeInput, + truthGateStatus: AssistantTruthGateContractStatus +): AssistantGroundingStatus { + const explicit = + toNonEmptyString(input.groundingStatus) ?? + toNonEmptyString(toRecordObject(debug.answer_grounding_check)?.status); + if (isGroundingStatus(explicit)) { + return explicit; + } + if (truthGateStatus === "blocked_route_expectation_failure") { + return "route_mismatch_blocked"; + } + if (truthGateStatus === "full_confirmed") { + return "grounded"; + } + if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") { + return "partial"; + } + if (truthGateStatus === "blocked_missing_anchor" || truthGateStatus === "blocked_execution_error") { + return "no_grounded_answer"; + } + const rowsMatched = asNumber(debug.rows_matched); + if (rowsMatched !== null && rowsMatched > 0) { + return "grounded"; + } + return "unsupported"; +} + +function coverageStatusFrom( + debug: Record, + input: ResolveAssistantTruthAnswerPolicyRuntimeInput, + truthGateStatus: AssistantTruthGateContractStatus, + groundingStatus: AssistantGroundingStatus +): AssistantCoverageStatus { + if (truthGateStatus === "full_confirmed") { + return "full"; + } + if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") { + return "partial"; + } + if (truthGateStatus.startsWith("blocked")) { + return "blocked"; + } + if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") { + return "blocked"; + } + + const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report); + if (coverageReport) { + const total = asNumber(coverageReport.requirements_total); + const covered = asNumber(coverageReport.requirements_covered) ?? 0; + const uncovered = toStringList(coverageReport.requirements_uncovered); + const partial = toStringList(coverageReport.requirements_partially_covered); + const clarification = toStringList(coverageReport.clarification_needed_for); + const outOfScope = toStringList(coverageReport.out_of_scope_requirements); + if (total !== null && total > 0) { + if (covered >= total && uncovered.length === 0 && partial.length === 0 && clarification.length === 0 && outOfScope.length === 0) { + return "full"; + } + if (covered > 0 || partial.length > 0) { + return "partial"; + } + return "blocked"; + } + } + + const rowsMatched = asNumber(debug.rows_matched); + if (rowsMatched !== null && rowsMatched > 0) { + return "full"; + } + return groundingStatus === "partial" ? "partial" : "blocked"; +} + +function truthModeFrom(input: { + coverageStatus: AssistantCoverageStatus; + groundingStatus: AssistantGroundingStatus; + truthGateStatus: AssistantTruthGateContractStatus; + debug: Record; +}): AssistantTruthMode { + if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) { + return "clarification_required"; + } + if (input.truthGateStatus === "full_confirmed" || (input.coverageStatus === "full" && input.groundingStatus === "grounded")) { + return "confirmed"; + } + if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual" || input.coverageStatus === "partial") { + return "limited"; + } + return "unsupported"; +} + +function evidenceGradeFrom( + debug: Record, + coverageStatus: AssistantCoverageStatus, + groundingStatus: AssistantGroundingStatus, + truthGateStatus: AssistantTruthGateContractStatus +): AssistantEvidenceGrade { + const explicit = toNonEmptyString(debug.evidence_strength); + if (isEvidenceGrade(explicit)) { + return explicit; + } + if (coverageStatus === "blocked") { + return "none"; + } + if (truthGateStatus === "full_confirmed" || groundingStatus === "grounded") { + return "strong"; + } + const rowsMatched = asNumber(debug.rows_matched); + if (rowsMatched !== null && rowsMatched > 0) { + return "medium"; + } + return coverageStatus === "partial" ? "weak" : "none"; +} + +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 collectReasonCodes(input: { + debug: Record; + coverageReport: Record | null; + shadow: AssistantRuntimeContractShadowDecision; + truthMode: AssistantTruthMode; + truthGateStatus: AssistantTruthGateContractStatus; +}): string[] { + const reasons: string[] = []; + pushReason(reasons, `truth_gate_${input.truthGateStatus}`); + pushReason(reasons, `truth_mode_${input.truthMode}`); + 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}`)); + toStringList(input.debug.limitations).forEach((item) => pushReason(reasons, item)); + toStringList(input.debug.reasons).forEach((item) => pushReason(reasons, item)); + pushReason(reasons, input.debug.route_expectation_reason); + toStringList(toRecordObject(input.debug.answer_grounding_check)?.reasons).forEach((item) => pushReason(reasons, item)); + toStringList(input.coverageReport?.requirements_uncovered).forEach((item) => pushReason(reasons, `coverage_uncovered_${item}`)); + toStringList(input.coverageReport?.requirements_partially_covered).forEach((item) => pushReason(reasons, `coverage_partial_${item}`)); + toStringList(input.coverageReport?.clarification_needed_for).forEach((item) => pushReason(reasons, `coverage_clarification_${item}`)); + return reasons.slice(0, 32); +} + +function explanationFor( + truthGateStatus: AssistantTruthGateContractStatus, + truthMode: AssistantTruthMode, + coverageStatus: AssistantCoverageStatus +): string | null { + if (truthGateStatus === "full_confirmed" && truthMode === "confirmed") { + return null; + } + if (truthGateStatus === "blocked_missing_anchor" || truthMode === "clarification_required") { + 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 (truthGateStatus === "partial_supported" || truthMode === "limited" || coverageStatus === "partial") { + return "evidence_or_coverage_is_partial"; + } + return "truth_gate_not_confirmed"; +} + +function answerShapeFrom(input: { + coverageStatus: AssistantCoverageStatus; + truthMode: AssistantTruthMode; + truthGateStatus: AssistantTruthGateContractStatus; +}): AssistantAnswerShapeKind { + if (input.truthMode === "confirmed") { + return "confirmed_factual"; + } + if (input.truthMode === "limited") { + return "limited_with_reason"; + } + if (input.truthMode === "clarification_required") { + return "clarification_required"; + } + if (input.coverageStatus === "blocked" || input.truthGateStatus.startsWith("blocked")) { + return "blocked_no_answer"; + } + return input.truthGateStatus === "unknown" ? "unknown" : "unsupported_boundary"; +} + +function requiredSectionsFor(shape: AssistantAnswerShapeKind): string[] { + if (shape === "confirmed_factual") { + return ["direct_answer", "evidence_basis"]; + } + if (shape === "limited_with_reason") { + return ["direct_answer", "evidence_window", "limitations"]; + } + if (shape === "clarification_required") { + return ["clarifying_question", "missing_anchors"]; + } + if (shape === "blocked_no_answer") { + return ["blocked_reason", "safe_next_step"]; + } + if (shape === "unsupported_boundary") { + return ["boundary_reason", "safe_next_step"]; + } + return ["safe_next_step"]; +} + +export function resolveAssistantTruthAnswerPolicyRuntime( + input: ResolveAssistantTruthAnswerPolicyRuntimeInput +): AssistantTruthAnswerPolicyRuntimeContract { + const debug = toRecordObject(input.addressDebug) ?? {}; + const shadow = resolveAssistantRuntimeContractShadow({ + addressDebug: debug, + addressRuntimeMeta: input.addressRuntimeMeta, + groundingStatus: input.groundingStatus + }); + const truthGateStatus = shadow.truth_gate_contract_status; + const groundingStatus = groundingStatusFrom(debug, input, truthGateStatus); + const coverageStatus = coverageStatusFrom(debug, input, truthGateStatus, groundingStatus); + const truthMode = truthModeFrom({ + coverageStatus, + groundingStatus, + truthGateStatus, + debug + }); + const evidenceGrade = evidenceGradeFrom(debug, coverageStatus, groundingStatus, truthGateStatus); + const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report); + const reasonCodes = collectReasonCodes({ + debug, + coverageReport, + shadow, + truthMode, + truthGateStatus + }); + const shape = answerShapeFrom({ + coverageStatus, + truthMode, + truthGateStatus + }); + const carryoverEligibility = + coverageStatus === "blocked" || truthMode === "unsupported" ? "none" : shadow.carryover_eligibility; + + const truthGate = { + schema_version: ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + coverage_status: coverageStatus, + evidence_grade: evidenceGrade, + grounding_status: groundingStatus, + truth_mode: truthMode, + carryover_eligibility: carryoverEligibility, + reason_codes: reasonCodes, + source_truth_gate_status: truthGateStatus, + blocked_or_limited_explanation: explanationFor(truthGateStatus, truthMode, coverageStatus) + } satisfies AssistantTruthAnswerPolicyRuntimeContract["truth_gate"]; + + const answerShape = { + schema_version: ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + answer_shape: shape, + reply_type: normalizeReplyType(input.replyType), + capability_contract_id: shadow.capability_contract_id, + transition_contract_id: shadow.transition_contract_id, + may_state_confirmed_facts: truthMode === "confirmed" || truthMode === "limited", + must_include_limitation: truthMode !== "confirmed", + may_power_followup: carryoverEligibility !== "none" && coverageStatus !== "blocked", + required_sections: requiredSectionsFor(shape), + downgrade_only: true + } satisfies AssistantTruthAnswerPolicyRuntimeContract["answer_shape"]; + + return { + schema_version: ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + truth_gate: truthGate, + answer_shape: answerShape + }; +} + +export function buildAssistantTruthAnswerPolicyRuntimeFields( + input: ResolveAssistantTruthAnswerPolicyRuntimeInput +): AssistantTruthAnswerPolicyRuntimeFields { + const policy = resolveAssistantTruthAnswerPolicyRuntime(input); + return { + assistant_truth_answer_policy_v1: policy, + coverage_gate_contract: policy.truth_gate, + answer_shape_contract: policy.answer_shape, + truth_mode: policy.truth_gate.truth_mode, + carryover_eligibility: policy.truth_gate.carryover_eligibility, + answer_shape: policy.answer_shape.answer_shape + }; +} + +export function attachAssistantTruthAnswerPolicy>( + debugPayload: T, + input: Omit +): T & AssistantTruthAnswerPolicyRuntimeFields { + return { + ...debugPayload, + ...buildAssistantTruthAnswerPolicyRuntimeFields({ + ...input, + addressDebug: debugPayload + }) + }; +} diff --git a/llm_normalizer/backend/src/types/assistant.ts b/llm_normalizer/backend/src/types/assistant.ts index 1aafca4..2f5b8f2 100644 --- a/llm_normalizer/backend/src/types/assistant.ts +++ b/llm_normalizer/backend/src/types/assistant.ts @@ -18,7 +18,10 @@ import type { import type { AccountingGraphBuildResult } from "./stage4Graph"; import type { AddressNavigationState } from "./addressNavigation"; import type { + AssistantAnswerShapeKind, AssistantRuntimeContractShadowDecision, + AssistantTruthAnswerPolicyRuntimeContract, + AssistantTruthMode, AssistantTransitionClassId } from "./assistantRuntimeContracts"; @@ -454,6 +457,12 @@ export interface AssistantDebugPayload { transition_contract_id?: AssistantTransitionClassId | null; capability_contract_id?: string | null; truth_gate_contract_status?: AssistantRuntimeContractShadowDecision["truth_gate_contract_status"]; + assistant_truth_answer_policy_v1?: AssistantTruthAnswerPolicyRuntimeContract; + coverage_gate_contract?: AssistantTruthAnswerPolicyRuntimeContract["truth_gate"]; + answer_shape_contract?: AssistantTruthAnswerPolicyRuntimeContract["answer_shape"]; + truth_mode?: AssistantTruthMode; + carryover_eligibility?: AssistantTruthAnswerPolicyRuntimeContract["truth_gate"]["carryover_eligibility"]; + answer_shape?: AssistantAnswerShapeKind; execution_lane?: "address_query" | "deep_analysis"; llm_decomposition_applied?: boolean; llm_decomposition_attempted?: boolean; diff --git a/llm_normalizer/backend/src/types/assistantRuntimeContracts.ts b/llm_normalizer/backend/src/types/assistantRuntimeContracts.ts index 47aba41..53b71b5 100644 --- a/llm_normalizer/backend/src/types/assistantRuntimeContracts.ts +++ b/llm_normalizer/backend/src/types/assistantRuntimeContracts.ts @@ -1,6 +1,7 @@ import type { AddressIntent } from "./addressQuery"; export const ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION = "assistant_runtime_contracts_v1" as const; +export const ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION = "assistant_truth_answer_policy_runtime_v1" as const; export type AssistantLivingMode = "address_data" | "assistant_data_scope" | "chat" | "meta_followup" | "clarification"; export type AssistantFrameStatus = "active" | "suspended" | "closed" | "blocked"; @@ -15,6 +16,26 @@ export type AssistantStateSlice = | "answer_context_state"; export type AssistantCarryoverDepth = "full" | "root_only" | "object_only" | "meta_only" | "none"; export type AssistantAnswerMode = "confirmed" | "limited" | "clarification" | "boundary" | "meta" | "recap"; +export type AssistantCoverageStatus = "full" | "partial" | "blocked"; +export type AssistantEvidenceGrade = "none" | "weak" | "medium" | "strong"; +export type AssistantGroundingStatus = "grounded" | "partial" | "route_mismatch_blocked" | "no_grounded_answer" | "unsupported"; +export type AssistantTruthMode = "confirmed" | "limited" | "clarification_required" | "unsupported"; +export type AssistantTruthGateContractStatus = + | "full_confirmed" + | "partial_supported" + | "blocked_missing_anchor" + | "blocked_route_expectation_failure" + | "blocked_execution_error" + | "limited_temporal_or_contextual" + | "unknown"; +export type AssistantAnswerShapeKind = + | "confirmed_factual" + | "limited_with_reason" + | "clarification_required" + | "unsupported_boundary" + | "blocked_no_answer" + | "unknown"; +export type AssistantAnswerShapeReplyType = "factual" | "partial_coverage" | "deep_analysis" | "unknown"; export interface AssistantDateScopeState { as_of_date: string | null; @@ -58,14 +79,42 @@ export interface AssistantClarificationState { } export interface AssistantCoverageGateState { - coverage_status: "full" | "partial" | "blocked"; - evidence_grade: "none" | "weak" | "medium" | "strong"; - grounding_status: "grounded" | "partial" | "route_mismatch_blocked" | "no_grounded_answer" | "unsupported"; - truth_mode: "confirmed" | "limited" | "clarification_required" | "unsupported"; + coverage_status: AssistantCoverageStatus; + evidence_grade: AssistantEvidenceGrade; + grounding_status: AssistantGroundingStatus; + truth_mode: AssistantTruthMode; carryover_eligibility: AssistantCarryoverDepth; reason_codes: string[]; } +export interface AssistantTruthGateRuntimeContract extends AssistantCoverageGateState { + schema_version: typeof ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION; + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter"; + source_truth_gate_status: AssistantTruthGateContractStatus; + blocked_or_limited_explanation: string | null; +} + +export interface AssistantAnswerShapeRuntimeContract { + schema_version: typeof ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION; + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter"; + answer_shape: AssistantAnswerShapeKind; + reply_type: AssistantAnswerShapeReplyType; + capability_contract_id: string | null; + transition_contract_id: AssistantTransitionClassId | null; + may_state_confirmed_facts: boolean; + must_include_limitation: boolean; + may_power_followup: boolean; + required_sections: string[]; + downgrade_only: true; +} + +export interface AssistantTruthAnswerPolicyRuntimeContract { + schema_version: typeof ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION; + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter"; + truth_gate: AssistantTruthGateRuntimeContract; + answer_shape: AssistantAnswerShapeRuntimeContract; +} + export interface AssistantSessionAggregateState { schema_version: typeof ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION; living_mode_state: { @@ -160,13 +209,6 @@ export interface AssistantRuntimeContractShadowDecision { transition_contract_reason: string[]; capability_contract_id: string | null; capability_contract_reason: string[]; - truth_gate_contract_status: - | "full_confirmed" - | "partial_supported" - | "blocked_missing_anchor" - | "blocked_route_expectation_failure" - | "blocked_execution_error" - | "limited_temporal_or_contextual" - | "unknown"; + truth_gate_contract_status: AssistantTruthGateContractStatus; carryover_eligibility: AssistantCarryoverDepth; } diff --git a/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts index dd8521c..8ac8cdc 100644 --- a/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts @@ -64,6 +64,16 @@ describe("assistant address lane response runtime adapter", () => { address_followup_offer: { suggestion: "continue_previous" }, assistant_runtime_contract_v1: expect.objectContaining({ schema_version: "assistant_runtime_contracts_v1" + }), + assistant_truth_answer_policy_v1: expect.objectContaining({ + schema_version: "assistant_truth_answer_policy_runtime_v1", + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter" + }), + coverage_gate_contract: expect.objectContaining({ + schema_version: "assistant_truth_answer_policy_runtime_v1" + }), + answer_shape_contract: expect.objectContaining({ + reply_type: "factual" }) }) ); @@ -107,9 +117,21 @@ describe("assistant address lane response runtime adapter", () => { capability_contract_id: null, truth_gate_contract_status: "unknown" }), + assistant_truth_answer_policy_v1: expect.objectContaining({ + schema_version: "assistant_truth_answer_policy_runtime_v1" + }), + coverage_gate_contract: expect.objectContaining({ + coverage_status: "blocked", + truth_mode: "unsupported" + }), + answer_shape_contract: expect.objectContaining({ + answer_shape: "blocked_no_answer", + reply_type: "partial_coverage" + }), transition_contract_id: null, capability_contract_id: null, - truth_gate_contract_status: "unknown" + truth_gate_contract_status: "unknown", + carryover_eligibility: "none" }) ); expect(runtime.response).toEqual({ ok: true }); diff --git a/llm_normalizer/backend/tests/assistantDebugPayloadAssembler.test.ts b/llm_normalizer/backend/tests/assistantDebugPayloadAssembler.test.ts index 210d6ea..078c130 100644 --- a/llm_normalizer/backend/tests/assistantDebugPayloadAssembler.test.ts +++ b/llm_normalizer/backend/tests/assistantDebugPayloadAssembler.test.ts @@ -116,6 +116,24 @@ describe("assistant debug payload assembler", () => { expect(payload.address_llm_predecompose_applied).toBe(true); expect(payload.assistant_outcome_class_v1).toBe("FULLY_ANSWERED"); expect(payload.answer_contract_stage4_v1?.is_stage4_shape).toBe(true); + expect(payload.assistant_truth_answer_policy_v1).toEqual( + expect.objectContaining({ + schema_version: "assistant_truth_answer_policy_runtime_v1", + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter" + }) + ); + expect(payload.coverage_gate_contract).toEqual( + expect.objectContaining({ + coverage_status: "full", + truth_mode: "confirmed" + }) + ); + expect(payload.answer_shape_contract).toEqual( + expect.objectContaining({ + answer_shape: "confirmed_factual", + reply_type: "deep_analysis" + }) + ); }); it("omits optional fields when they are not provided", () => { diff --git a/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts new file mode 100644 index 0000000..15960e4 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + attachAssistantTruthAnswerPolicy, + resolveAssistantTruthAnswerPolicyRuntime +} from "../src/services/assistantTruthAnswerPolicyRuntimeAdapter"; + +describe("assistant truth answer policy runtime adapter", () => { + it("emits confirmed truth gate and factual answer shape for grounded exact results", () => { + const policy = resolveAssistantTruthAnswerPolicyRuntime({ + addressDebug: { + capability_id: "confirmed_inventory_on_hand_as_of_date", + detected_intent: "inventory_on_hand_as_of_date", + rows_matched: 3, + route_expectation_status: "matched", + evidence_strength: "strong" + }, + groundingStatus: "grounded", + replyType: "factual" + }); + + expect(policy.truth_gate).toEqual( + expect.objectContaining({ + schema_version: "assistant_truth_answer_policy_runtime_v1", + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + coverage_status: "full", + grounding_status: "grounded", + truth_mode: "confirmed", + evidence_grade: "strong", + source_truth_gate_status: "full_confirmed", + carryover_eligibility: "root_only", + blocked_or_limited_explanation: null + }) + ); + expect(policy.answer_shape).toEqual( + expect.objectContaining({ + answer_shape: "confirmed_factual", + reply_type: "factual", + capability_contract_id: "confirmed_inventory_on_hand_as_of_date", + transition_contract_id: "T1", + may_state_confirmed_facts: true, + must_include_limitation: false, + may_power_followup: true, + required_sections: ["direct_answer", "evidence_basis"], + downgrade_only: true + }) + ); + }); + + it("keeps temporal/contextual limits as limited answer shape with explicit reason codes", () => { + const policy = resolveAssistantTruthAnswerPolicyRuntime({ + addressDebug: { + capability_id: "confirmed_inventory_on_hand_as_of_date", + temporal_guard_outcome: "ambiguous_limited", + rows_matched: 2, + limitations: ["period_window_auto_broadened_to_available_data"] + }, + groundingStatus: "partial", + replyType: "partial_coverage" + }); + + expect(policy.truth_gate.coverage_status).toBe("partial"); + expect(policy.truth_gate.truth_mode).toBe("limited"); + expect(policy.truth_gate.source_truth_gate_status).toBe("limited_temporal_or_contextual"); + expect(policy.truth_gate.blocked_or_limited_explanation).toBe("temporal_or_contextual_limit"); + expect(policy.truth_gate.reason_codes).toContain("period_window_auto_broadened_to_available_data"); + expect(policy.answer_shape.answer_shape).toBe("limited_with_reason"); + expect(policy.answer_shape.must_include_limitation).toBe(true); + expect(policy.answer_shape.required_sections).toEqual(["direct_answer", "evidence_window", "limitations"]); + }); + + it("blocks route expectation failures and prevents follow-up carryover", () => { + const policy = resolveAssistantTruthAnswerPolicyRuntime({ + addressDebug: { + capability_id: "confirmed_inventory_on_hand_as_of_date", + route_expectation_status: "mismatch", + route_expectation_reason: "expected_confirmed_balance_route" + }, + groundingStatus: "route_mismatch_blocked", + replyType: "partial_coverage" + }); + + expect(policy.truth_gate.coverage_status).toBe("blocked"); + expect(policy.truth_gate.grounding_status).toBe("route_mismatch_blocked"); + expect(policy.truth_gate.truth_mode).toBe("unsupported"); + expect(policy.truth_gate.carryover_eligibility).toBe("none"); + expect(policy.truth_gate.reason_codes).toContain("expected_confirmed_balance_route"); + expect(policy.answer_shape.answer_shape).toBe("blocked_no_answer"); + expect(policy.answer_shape.may_state_confirmed_facts).toBe(false); + expect(policy.answer_shape.may_power_followup).toBe(false); + }); + + it("attaches top-level debug fields without hiding the nested contract", () => { + const debug = attachAssistantTruthAnswerPolicy( + { + capability_id: "inventory_inventory_purchase_provenance_for_item", + missing_required_filters: ["item"], + limited_reason_category: "missing_anchor" + }, + { + replyType: "partial_coverage" + } + ); + + expect(debug.assistant_truth_answer_policy_v1.schema_version).toBe("assistant_truth_answer_policy_runtime_v1"); + expect(debug.coverage_gate_contract.truth_mode).toBe("clarification_required"); + expect(debug.answer_shape_contract.answer_shape).toBe("clarification_required"); + expect(debug.truth_mode).toBe("clarification_required"); + expect(debug.carryover_eligibility).toBe("none"); + expect(debug.answer_shape).toBe("clarification_required"); + }); +});