diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js index e158b31..2ce9c63 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js @@ -2,6 +2,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.runAssistantAddressLaneResponseRuntime = runAssistantAddressLaneResponseRuntime; const assistantAddressTurnFinalizeRuntimeAdapter_1 = require("./assistantAddressTurnFinalizeRuntimeAdapter"); +const assistantCapabilityBindingResponseGuard_1 = require("./assistantCapabilityBindingResponseGuard"); const assistantCapabilityRuntimeBindingAdapter_1 = require("./assistantCapabilityRuntimeBindingAdapter"); const assistantRuntimeContractResolver_1 = require("./assistantRuntimeContractResolver"); const assistantStateTransitionRuntimeAdapter_1 = require("./assistantStateTransitionRuntimeAdapter"); @@ -207,14 +208,23 @@ function runAssistantAddressLaneResponseRuntime(input) { addressRuntimeMeta: input.llmPreDecomposeMeta, replyType: normalizeAddressReplyType(input.addressLane.reply_type) }); + const guardedResponse = (0, assistantCapabilityBindingResponseGuard_1.applyAssistantCapabilityBindingResponseGuard)({ + assistantReply: safeAddressReply, + replyType: normalizeAddressReplyType(input.addressLane.reply_type), + capabilityBinding: debugWithCapabilityBinding.assistant_capability_binding_v1 + }); + const debugWithResponseGuard = { + ...debugWithCapabilityBinding, + capability_binding_response_guard: guardedResponse.audit + }; const finalization = finalizeAddressTurnSafe({ sessionId: input.sessionId, userMessage: input.userMessage, effectiveAddressUserMessage: input.effectiveAddressUserMessage, - assistantReply: safeAddressReply, - replyType: normalizeAddressReplyType(input.addressLane.reply_type), + assistantReply: guardedResponse.assistantReply, + replyType: guardedResponse.replyType, addressLaneDebug: normalizeAddressLaneDebug(input.addressLane.debug), - debug: debugWithCapabilityBinding, + debug: debugWithResponseGuard, carryoverMeta: normalizeCarryoverMeta(input.carryoverMeta), llmPreDecomposeMeta: normalizeLlmPreDecomposeMeta(input.llmPreDecomposeMeta), appendItem: input.appendItem, @@ -226,6 +236,6 @@ function runAssistantAddressLaneResponseRuntime(input) { }); return { response: finalization.response, - debug: debugWithCapabilityBinding + debug: debugWithResponseGuard }; } diff --git a/llm_normalizer/backend/dist/services/assistantCapabilityBindingResponseGuard.js b/llm_normalizer/backend/dist/services/assistantCapabilityBindingResponseGuard.js new file mode 100644 index 0000000..af090b8 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantCapabilityBindingResponseGuard.js @@ -0,0 +1,77 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.applyAssistantCapabilityBindingResponseGuard = applyAssistantCapabilityBindingResponseGuard; +function formatMissingAnchors(anchors) { + if (anchors.length === 0) { + return "нужный объект, период или организацию"; + } + return anchors.join(", "); +} +function buildClarificationReply(binding) { + return [ + "Нужно уточнение, чтобы не подставить неподтвержденный объект в расчет.", + `Не хватает: ${formatMissingAnchors(binding.missing_anchors)}.`, + "Уточните это, и я продолжу тот же сценарий." + ].join("\n"); +} +function buildBlockedReply(binding) { + const reasons = binding.violations.length > 0 ? binding.violations.join(", ") : "runtime_binding_blocked"; + return [ + "Не могу надежно подтвердить ответ в текущем контексте.", + `Проверка сценария остановила ответ: ${reasons}.`, + "Лучше уточнить объект или перезапустить вопрос от корневого запроса, чем выдавать это как подтвержденный факт." + ].join("\n"); +} +function applyAssistantCapabilityBindingResponseGuard(input) { + const binding = input.capabilityBinding; + const baseAudit = { + schema_version: "assistant_capability_binding_response_guard_v1", + guard_owner: "assistantCapabilityBindingResponseGuard", + applied: false, + action: binding?.binding_action ?? "none", + original_reply_type: input.replyType, + guarded_reply_type: input.replyType, + reason_codes: binding?.reason_codes ?? [] + }; + if (!binding || binding.binding_action === "allow" || binding.binding_action === "observe_only") { + return { + assistantReply: input.assistantReply, + replyType: input.replyType, + audit: baseAudit + }; + } + if (binding.binding_action === "clarify") { + return { + assistantReply: buildClarificationReply(binding), + replyType: "partial_coverage", + audit: { + ...baseAudit, + applied: true, + guarded_reply_type: "partial_coverage", + reason_codes: [...baseAudit.reason_codes, "capability_binding_guard_clarification_reply"] + } + }; + } + if (binding.binding_action === "block") { + return { + assistantReply: buildBlockedReply(binding), + replyType: "partial_coverage", + audit: { + ...baseAudit, + applied: true, + guarded_reply_type: "partial_coverage", + reason_codes: [...baseAudit.reason_codes, "capability_binding_guard_blocked_reply"] + } + }; + } + return { + assistantReply: input.assistantReply, + replyType: input.replyType === "factual" ? "partial_coverage" : input.replyType, + audit: { + ...baseAudit, + applied: input.replyType === "factual", + guarded_reply_type: input.replyType === "factual" ? "partial_coverage" : input.replyType, + reason_codes: [...baseAudit.reason_codes, "capability_binding_guard_limited_reply_type"] + } + }; +} diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts index 0afd26b..a6d1ed6 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts @@ -7,6 +7,7 @@ import { type AddressLlmPreDecomposeMetaLogInput, type FinalizeAssistantAddressTurnInput } from "./assistantAddressTurnFinalizeRuntimeAdapter"; +import { applyAssistantCapabilityBindingResponseGuard } from "./assistantCapabilityBindingResponseGuard"; import { attachAssistantCapabilityRuntimeBinding } from "./assistantCapabilityRuntimeBindingAdapter"; import { attachAssistantRuntimeContractShadow } from "./assistantRuntimeContractResolver"; import { attachAssistantStateTransition } from "./assistantStateTransitionRuntimeAdapter"; @@ -266,14 +267,23 @@ export function runAssistantAddressLaneResponseRuntime 0 ? binding.violations.join(", ") : "runtime_binding_blocked"; + return [ + "Не могу надежно подтвердить ответ в текущем контексте.", + `Проверка сценария остановила ответ: ${reasons}.`, + "Лучше уточнить объект или перезапустить вопрос от корневого запроса, чем выдавать это как подтвержденный факт." + ].join("\n"); +} + +export function applyAssistantCapabilityBindingResponseGuard( + input: ApplyAssistantCapabilityBindingResponseGuardInput +): ApplyAssistantCapabilityBindingResponseGuardOutput { + const binding = input.capabilityBinding; + const baseAudit: AssistantCapabilityBindingResponseGuardAudit = { + schema_version: "assistant_capability_binding_response_guard_v1", + guard_owner: "assistantCapabilityBindingResponseGuard", + applied: false, + action: binding?.binding_action ?? "none", + original_reply_type: input.replyType, + guarded_reply_type: input.replyType, + reason_codes: binding?.reason_codes ?? [] + }; + + if (!binding || binding.binding_action === "allow" || binding.binding_action === "observe_only") { + return { + assistantReply: input.assistantReply, + replyType: input.replyType, + audit: baseAudit + }; + } + + if (binding.binding_action === "clarify") { + return { + assistantReply: buildClarificationReply(binding), + replyType: "partial_coverage", + audit: { + ...baseAudit, + applied: true, + guarded_reply_type: "partial_coverage", + reason_codes: [...baseAudit.reason_codes, "capability_binding_guard_clarification_reply"] + } + }; + } + + if (binding.binding_action === "block") { + return { + assistantReply: buildBlockedReply(binding), + replyType: "partial_coverage", + audit: { + ...baseAudit, + applied: true, + guarded_reply_type: "partial_coverage", + reason_codes: [...baseAudit.reason_codes, "capability_binding_guard_blocked_reply"] + } + }; + } + + return { + assistantReply: input.assistantReply, + replyType: input.replyType === "factual" ? "partial_coverage" : input.replyType, + audit: { + ...baseAudit, + applied: input.replyType === "factual", + guarded_reply_type: input.replyType === "factual" ? "partial_coverage" : input.replyType, + reason_codes: [...baseAudit.reason_codes, "capability_binding_guard_limited_reply_type"] + } + }; +} diff --git a/llm_normalizer/backend/src/types/assistant.ts b/llm_normalizer/backend/src/types/assistant.ts index de45d2f..960bea2 100644 --- a/llm_normalizer/backend/src/types/assistant.ts +++ b/llm_normalizer/backend/src/types/assistant.ts @@ -479,6 +479,7 @@ export interface AssistantDebugPayload { capability_binding_status?: AssistantCapabilityBindingStatus; capability_binding_action?: AssistantCapabilityBindingAction; capability_binding_violations?: AssistantCapabilityBindingViolation[]; + capability_binding_response_guard?: Record; execution_lane?: "address_query" | "deep_analysis"; llm_decomposition_applied?: boolean; llm_decomposition_attempted?: boolean; diff --git a/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts index a786548..9534cc7 100644 --- a/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts @@ -88,6 +88,10 @@ describe("assistant address lane response runtime adapter", () => { }), capability_binding_contract: expect.objectContaining({ schema_version: "assistant_capability_runtime_binding_v1" + }), + capability_binding_response_guard: expect.objectContaining({ + schema_version: "assistant_capability_binding_response_guard_v1", + applied: false }) }) ); @@ -155,9 +159,67 @@ describe("assistant address lane response runtime adapter", () => { effective_carryover_depth: "none", capability_binding_status: "not_applicable", capability_binding_action: "observe_only", - capability_binding_violations: [] + capability_binding_violations: [], + capability_binding_response_guard: expect.objectContaining({ + applied: false, + action: "observe_only" + }) }) ); expect(runtime.response).toEqual({ ok: true }); }); + + it("guards blocked capability binding responses before finalizing the turn", () => { + const finalizeAddressTurn = vi.fn(() => ({ + response: { + ok: true + } + })); + + runAssistantAddressLaneResponseRuntime({ + sessionId: "asst-3", + userMessage: "кто поставщик?", + effectiveAddressUserMessage: "кто поставщик?", + addressLane: { + handled: true, + reply_text: "unsafe factual answer", + reply_type: "factual", + debug: { + capability_id: "inventory_inventory_purchase_provenance_for_item", + limited_reason_category: "missing_anchor", + missing_required_filters: ["item"] + } + }, + knownOrganizations: [], + activeOrganization: null, + sanitizeOutgoingAssistantText: (text) => String(text ?? ""), + buildAddressDebugPayload: (addressDebug) => ({ ...(addressDebug as Record) }), + buildAddressFollowupOffer: () => null, + mergeKnownOrganizations: (items) => items, + toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null), + appendItem: () => {}, + getSession: () => ({ session_id: "asst-3", updated_at: "", items: [], investigation_state: null } as any), + persistSession: () => {}, + cloneConversation: (items) => items, + logEvent: () => {}, + messageIdFactory: () => "msg-3", + finalizeAddressTurn + }); + + expect(finalizeAddressTurn).toHaveBeenCalledWith( + expect.objectContaining({ + assistantReply: expect.stringContaining("Нужно уточнение"), + replyType: "partial_coverage", + debug: expect.objectContaining({ + capability_binding_status: "blocked", + capability_binding_action: "clarify", + capability_binding_response_guard: expect.objectContaining({ + applied: true, + action: "clarify", + guarded_reply_type: "partial_coverage" + }) + }) + }) + ); + }); }); diff --git a/llm_normalizer/backend/tests/assistantCapabilityBindingResponseGuard.test.ts b/llm_normalizer/backend/tests/assistantCapabilityBindingResponseGuard.test.ts new file mode 100644 index 0000000..39fb79e --- /dev/null +++ b/llm_normalizer/backend/tests/assistantCapabilityBindingResponseGuard.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { applyAssistantCapabilityBindingResponseGuard } from "../src/services/assistantCapabilityBindingResponseGuard"; +import type { AssistantCapabilityRuntimeBindingContract } from "../src/types/assistantRuntimeContracts"; + +function binding(overrides: Partial): AssistantCapabilityRuntimeBindingContract { + return { + schema_version: "assistant_capability_runtime_binding_v1", + binding_owner: "assistantCapabilityRuntimeBindingAdapter", + capability_id: "inventory_inventory_purchase_provenance_for_item", + capability_contract_id: "inventory_inventory_purchase_provenance_for_item", + binding_status: "bound", + binding_action: "allow", + runtime_lane_expected: "address_exact", + runtime_lane_observed: "address_exact", + execution_adapter: "AddressQueryService", + transition_id: "T4", + transition_allowed: true, + required_anchors: ["item"], + provided_anchors: ["item"], + missing_anchors: [], + requires_focus_object: true, + focus_object_binding_status: "inferred_from_anchor", + result_shape: "supplier_purchase_provenance_trace", + answer_object_shape: "inventory_provenance_bundle", + truth_gate_behavior: "partial_or_blocked_if_evidence_insufficient", + truth_fallback_allowed: true, + answer_shape_compatible: true, + violations: [], + reason_codes: ["binding_status_bound"], + ...overrides + }; +} + +describe("assistant capability binding response guard", () => { + it("leaves allowed answers unchanged", () => { + const output = applyAssistantCapabilityBindingResponseGuard({ + assistantReply: "answer", + replyType: "factual", + capabilityBinding: binding({}) + }); + + expect(output.assistantReply).toBe("answer"); + expect(output.replyType).toBe("factual"); + expect(output.audit.applied).toBe(false); + expect(output.audit.guarded_reply_type).toBe("factual"); + }); + + it("turns missing required anchors into clarification replies", () => { + const output = applyAssistantCapabilityBindingResponseGuard({ + assistantReply: "unsafe answer", + replyType: "factual", + capabilityBinding: binding({ + binding_status: "blocked", + binding_action: "clarify", + missing_anchors: ["item"], + focus_object_binding_status: "missing", + violations: ["required_anchor_missing", "focus_object_required_but_unbound"], + reason_codes: ["required_anchor_missing"] + }) + }); + + expect(output.replyType).toBe("partial_coverage"); + expect(output.assistantReply).toContain("Нужно уточнение"); + expect(output.assistantReply).toContain("item"); + expect(output.audit.applied).toBe(true); + expect(output.audit.reason_codes).toContain("capability_binding_guard_clarification_reply"); + }); + + it("turns blocked incompatible transitions into bounded replies", () => { + const output = applyAssistantCapabilityBindingResponseGuard({ + assistantReply: "unsafe answer", + replyType: "factual", + capabilityBinding: binding({ + binding_status: "blocked", + binding_action: "block", + violations: ["transition_not_supported_by_capability"], + reason_codes: ["transition_not_supported_by_capability"] + }) + }); + + expect(output.replyType).toBe("partial_coverage"); + expect(output.assistantReply).toContain("Не могу надежно подтвердить"); + expect(output.assistantReply).toContain("transition_not_supported_by_capability"); + expect(output.audit.applied).toBe(true); + expect(output.audit.reason_codes).toContain("capability_binding_guard_blocked_reply"); + }); + + it("downgrades factual reply type for limited bound answers without rewriting text", () => { + const output = applyAssistantCapabilityBindingResponseGuard({ + assistantReply: "limited answer", + replyType: "factual", + capabilityBinding: binding({ + binding_status: "bound_with_limits", + binding_action: "limit", + reason_codes: ["truth_mode_limited"] + }) + }); + + expect(output.assistantReply).toBe("limited answer"); + expect(output.replyType).toBe("partial_coverage"); + expect(output.audit.applied).toBe(true); + expect(output.audit.reason_codes).toContain("capability_binding_guard_limited_reply_type"); + }); +});