diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index f59445d..f0ec078 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -4317,11 +4317,10 @@ class AddressQueryService { rowsMatched: expandedFilteredRows.length }); const expandedFactual = (0, composeStage_1.composeFactualReply)(intent.intent, expandedFilteredRows, composeRuntimeOptions(expandedLimitFilters)); - const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`; const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"]; const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"]; return buildFactualExecutionResult({ - replyText: `${expandedPrefix}\n${expandedFactual.text}`, + replyText: expandedFactual.text, responseType: expandedFactual.responseType, responseSemantics: expandedFactual.semantics, selectedRecipe: expandedSelection.selected_recipe.recipe_id, diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js index 88d27fc..d616fae 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js @@ -582,7 +582,8 @@ function runAssistantAddressLaneResponseRuntime(input) { mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate, mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied, comparison_scope_response_augmentation_v1: comparisonScopeProofReply?.audit ?? null, - ...buildAppliedMcpDiscoveryRoutePatch(debugWithResponseGuard, mcpDiscoveryResponsePolicy.applied) + ...buildAppliedMcpDiscoveryRoutePatch(debugWithResponseGuard, mcpDiscoveryResponsePolicy.applied), + ...(0, assistantMcpDiscoveryResponsePolicy_1.buildAppliedMcpDiscoveryTruthPatch)(mcpDiscoveryResponsePolicy, debugWithResponseGuard) }; const finalization = finalizeAddressTurnSafe({ sessionId: input.sessionId, diff --git a/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js index 59f7d24..7175d4d 100644 --- a/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js @@ -142,7 +142,8 @@ function runAssistantDeepTurnResponseRuntime(input) { ...packagingRuntime.debug, mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy, mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate, - mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied + mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied, + ...(0, assistantMcpDiscoveryResponsePolicy_1.buildAppliedMcpDiscoveryTruthPatch)(mcpDiscoveryResponsePolicy, packagingRuntime.debug) }; const assistantItem = mcpDiscoveryResponsePolicy.applied ? { diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index ab8e6c0..b99f61a 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -362,6 +362,7 @@ async function runAssistantLivingChatRuntime(input) { mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy, mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate, mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied, + ...(0, assistantMcpDiscoveryResponsePolicy_1.buildAppliedMcpDiscoveryTruthPatch)(mcpDiscoveryResponsePolicy, addressRuntimeMeta), living_chat_script_guard_applied: livingChatScriptGuardApplied, living_chat_script_guard_reason: livingChatScriptGuardReason, living_chat_grounding_guard_applied: livingChatGroundingGuardApplied, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 5f541b5..b41a4af 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -73,6 +73,18 @@ function requestsBusinessOverviewNextChecks(turnMeaning, graph) { const text = normalizedTurnAndGraphText(turnMeaning, graph); return /(?:что\s+(?:дальше\s+)?провер\w*|что\s+провер\w*\s+дальше|следующ\w*\s+шаг|в\s+первую\s+очередь\s+провер\w*|next\s+checks?|next\s+steps?)/iu.test(text); } +function requestsShortDebtBoundaryFollowup(turnMeaning, graph) { + const text = normalizeQuestionText([ + turnMeaning?.raw_message, + turnMeaning?.effective_message, + graph?.source_message, + graph?.question + ].join(" ")); + if (!/\u043f\u0440\u043e\u0441\u0440\u043e\u0447/iu.test(text)) { + return false; + } + return /(?:\u043a\u043e\u0440\u043e\u0442\u043a|\u043f\u043e\u0447\u0435\u043c\u0443|\u0434\u043e\u043a\u0430\u0437\p{L}*)/iu.test(text); +} function requestsCompactCashflowAnswer(turnMeaning, graph) { const text = normalizeQuestionText([ turnMeaning?.raw_message, @@ -902,6 +914,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { const debtDueDateBoundary = actionFamily === "debt_due_date_boundary" || unsupportedFamily === "debt_due_date_boundary"; const vendorRiskBoundary = actionFamily === "vendor_risk_procurement_boundary" || unsupportedFamily === "vendor_risk_procurement_boundary"; const inventoryReserveBoundary = actionFamily === "inventory_reserve_boundary" || unsupportedFamily === "inventory_reserve_liquidation_boundary"; + const shortDebtBoundaryFollowup = debtDueDateBoundary && requestsShortDebtBoundaryFollowup(turnMeaning, graph); const compactCashflowRequested = directMoneyOnlyAnswer && requestsCompactCashflowAnswer(turnMeaning, graph); const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph); const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph); @@ -1035,19 +1048,20 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { const rowsWithPaymentTerms = typeof dueDateAging.rows_with_payment_terms === "number" && Number.isFinite(dueDateAging.rows_with_payment_terms) ? dueDateAging.rows_with_payment_terms : null; - const rowsWithAmount = typeof dueDateAging.rows_with_amount === "number" && Number.isFinite(dueDateAging.rows_with_amount) - ? dueDateAging.rows_with_amount - : null; const dueDateScopePrefix = organizationScope ? `по компании ${organizationScope} ` : ""; if (status === "confirmed_overdue") { lines.push(`Коротко: ${dueDateScopePrefix}на ${asOfDate} подтвержденная просрочка есть: ${overdueAmount ?? "сумма не распознана"} по ${dueDateAging.overdue_rows ?? "найденным"} строкам.`); lines.push("Основа ответа: открытые расчеты 60/62/76, договорный срок оплаты и дата расчетного документа; это проверка просрочки по срокам оплаты, а не просто возраст договора."); } else if (status === "no_payment_terms_configured") { + if (shortDebtBoundaryFollowup) { + lines.push(`Коротко: да, доказать просрочку ${dueDateScopePrefix}на ${asOfDate} нельзя, потому что в подтвержденных данных не установлен срок оплаты.`); + lines.push(`Открытый долг${grossAmount ? ` на ${grossAmount}` : ""} виден, но без due date это задолженность без доказанной договорной просрочки.`); + return joinBusinessReplyLines(lines); + } lines.push(`Коротко: ${dueDateScopePrefix}на ${asOfDate} подтвержденной просрочки нет: открытые расчеты проверены${grossAmount ? ` на ${grossAmount}` : ""}, но в найденных договорах срок оплаты не установлен.`); - lines.push(rowsWithAmount !== null - ? `Проверено строк с суммой: ${rowsWithAmount}. Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой.` - : "Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой."); + lines.push("Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой."); + lines.push("Что проверить дальше: сроки оплаты и даты документов-оснований в договорах/расчетах; без них просрочку доказать нельзя."); } else if (status === "insufficient_due_date_basis") { lines.push(`Коротко: ${dueDateScopePrefix}на ${asOfDate} просрочка не подтверждена: по строкам с установленным сроком оплаты не хватило даты расчетного документа.`); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 392a3ef..dce8ae7 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -1,9 +1,111 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION = void 0; +exports.buildAppliedMcpDiscoveryTruthPatch = buildAppliedMcpDiscoveryTruthPatch; exports.applyAssistantMcpDiscoveryResponsePolicy = applyAssistantMcpDiscoveryResponsePolicy; const assistantMcpDiscoveryResponseCandidate_1 = require("./assistantMcpDiscoveryResponseCandidate"); +const assistantRuntimeContracts_1 = require("../types/assistantRuntimeContracts"); exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION = "assistant_mcp_discovery_response_policy_v1"; +function uniqueReasonCodes(values) { + const result = []; + values.forEach((value) => { + const text = toNonEmptyString(value); + if (!text) { + return; + } + const normalized = normalizeReasonCode(text); + if (normalized && !result.includes(normalized)) { + result.push(normalized); + } + }); + return result; +} +function patchCoverageGateState(value, gate) { + const source = toRecordObject(value); + if (!source) { + return value; + } + const currentGate = toRecordObject(source.coverage_gate_state); + return { + ...source, + coverage_gate_state: { + ...(currentGate ?? {}), + coverage_status: gate.coverage_status, + evidence_grade: gate.evidence_grade, + grounding_status: gate.grounding_status, + truth_mode: gate.truth_mode, + carryover_eligibility: gate.carryover_eligibility, + reason_codes: gate.reason_codes + } + }; +} +function buildAppliedMcpDiscoveryTruthPatch(policy, currentDebug) { + if (!policy.applied || !ALLOWED_CANDIDATE_STATUSES.has(policy.candidate.candidate_status)) { + return {}; + } + const reasonCodes = uniqueReasonCodes([ + ...policy.reason_codes, + "mcp_discovery_response_policy_candidate_applied", + "mcp_discovery_response_candidate_guarded_truth_patch" + ]); + const truthGate = { + schema_version: assistantRuntimeContracts_1.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + coverage_status: "partial", + evidence_grade: "medium", + grounding_status: "partial", + truth_mode: "limited", + carryover_eligibility: "root_only", + reason_codes: reasonCodes, + source_truth_gate_status: "partial_supported", + blocked_or_limited_explanation: "mcp_discovery_guarded_bounded_answer" + }; + const answerShape = { + schema_version: assistantRuntimeContracts_1.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + answer_shape: "limited_with_reason", + reply_type: "partial_coverage", + capability_contract_id: null, + transition_contract_id: null, + may_state_confirmed_facts: true, + must_include_limitation: true, + may_power_followup: true, + required_sections: ["direct_answer", "evidence_window", "limitations"], + downgrade_only: true + }; + const truthPolicy = { + schema_version: assistantRuntimeContracts_1.ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + truth_gate: truthGate, + answer_shape: answerShape + }; + const debug = currentDebug ?? {}; + const patch = { + assistant_truth_answer_policy_v1: truthPolicy, + coverage_gate_contract: truthGate, + answer_shape_contract: answerShape, + truth_gate_contract_status: "partial_supported", + truth_mode: "limited", + carryover_eligibility: "root_only", + answer_shape: "limited_with_reason" + }; + const stateTransition = patchCoverageGateState(debug.assistant_state_transition_v1, truthGate); + if (stateTransition !== debug.assistant_state_transition_v1) { + patch.assistant_state_transition_v1 = stateTransition; + } + const stateTransitionContract = patchCoverageGateState(debug.state_transition_contract, truthGate); + if (stateTransitionContract !== debug.state_transition_contract) { + patch.state_transition_contract = stateTransitionContract; + } + const runtimeContract = toRecordObject(debug.assistant_runtime_contract_v1); + if (runtimeContract) { + patch.assistant_runtime_contract_v1 = { + ...runtimeContract, + truth_gate_contract_status: "partial_supported" + }; + } + return patch; +} const ALLOWED_CANDIDATE_STATUSES = new Set([ "ready_for_guarded_use", "checked_sources_only_candidate", diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 74d6c25..4455924 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -5278,11 +5278,10 @@ export class AddressQueryService { expandedFilteredRows, composeRuntimeOptions(expandedLimitFilters) ); - const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`; const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"]; const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"]; return buildFactualExecutionResult({ - replyText: `${expandedPrefix}\n${expandedFactual.text}`, + replyText: expandedFactual.text, responseType: expandedFactual.responseType, responseSemantics: expandedFactual.semantics, selectedRecipe: expandedSelection.selected_recipe.recipe_id, diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts index 2e0a4dd..dd5c36b 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts @@ -10,7 +10,10 @@ import { import { applyAssistantCapabilityBindingResponseGuard } from "./assistantCapabilityBindingResponseGuard"; import { attachAssistantCapabilityRuntimeBinding } from "./assistantCapabilityRuntimeBindingAdapter"; import { attachAssistantMcpDiscoveryDebug } from "./assistantMcpDiscoveryDebugAttachment"; -import { applyAssistantMcpDiscoveryResponsePolicy } from "./assistantMcpDiscoveryResponsePolicy"; +import { + applyAssistantMcpDiscoveryResponsePolicy, + buildAppliedMcpDiscoveryTruthPatch +} from "./assistantMcpDiscoveryResponsePolicy"; import { attachAssistantRuntimeContractShadow } from "./assistantRuntimeContractResolver"; import { attachAssistantStateTransition } from "./assistantStateTransitionRuntimeAdapter"; import { attachAssistantTruthAnswerPolicy } from "./assistantTruthAnswerPolicyRuntimeAdapter"; @@ -712,7 +715,8 @@ export function runAssistantAddressLaneResponseRuntime + ) } as AssistantDebugPayload; const assistantItem = mcpDiscoveryResponsePolicy.applied ? { diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 8176e2b..9fa5e7b 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -8,7 +8,10 @@ import { } from "./assistantMemoryRecapPolicy"; import { resolveAssistantOrganizationAuthority } from "./assistantContinuityPolicy"; import { attachAssistantMcpDiscoveryDebug } from "./assistantMcpDiscoveryDebugAttachment"; -import { applyAssistantMcpDiscoveryResponsePolicy } from "./assistantMcpDiscoveryResponsePolicy"; +import { + applyAssistantMcpDiscoveryResponsePolicy, + buildAppliedMcpDiscoveryTruthPatch +} from "./assistantMcpDiscoveryResponsePolicy"; export interface AssistantLivingChatSessionScopeInput { knownOrganizations?: unknown[]; @@ -473,6 +476,7 @@ export async function runAssistantLivingChatRuntime( mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy, mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate, mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied, + ...buildAppliedMcpDiscoveryTruthPatch(mcpDiscoveryResponsePolicy, addressRuntimeMeta), living_chat_script_guard_applied: livingChatScriptGuardApplied, living_chat_script_guard_reason: livingChatScriptGuardReason, living_chat_grounding_guard_applied: livingChatGroundingGuardApplied, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 2fc87ef..8817c3f 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -117,6 +117,22 @@ function requestsBusinessOverviewNextChecks( ); } +function requestsShortDebtBoundaryFollowup( + turnMeaning: Record | null, + graph: Record | null +): boolean { + const text = normalizeQuestionText([ + turnMeaning?.raw_message, + turnMeaning?.effective_message, + graph?.source_message, + graph?.question + ].join(" ")); + if (!/\u043f\u0440\u043e\u0441\u0440\u043e\u0447/iu.test(text)) { + return false; + } + return /(?:\u043a\u043e\u0440\u043e\u0442\u043a|\u043f\u043e\u0447\u0435\u043c\u0443|\u0434\u043e\u043a\u0430\u0437\p{L}*)/iu.test(text); +} + function requestsCompactCashflowAnswer( turnMeaning: Record | null, graph: Record | null @@ -1086,6 +1102,7 @@ function buildCompactBusinessOverviewReply( actionFamily === "vendor_risk_procurement_boundary" || unsupportedFamily === "vendor_risk_procurement_boundary"; const inventoryReserveBoundary = actionFamily === "inventory_reserve_boundary" || unsupportedFamily === "inventory_reserve_liquidation_boundary"; + const shortDebtBoundaryFollowup = debtDueDateBoundary && requestsShortDebtBoundaryFollowup(turnMeaning, graph); const compactCashflowRequested = directMoneyOnlyAnswer && requestsCompactCashflowAnswer(turnMeaning, graph); const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph); const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph); @@ -1259,10 +1276,6 @@ function buildCompactBusinessOverviewReply( typeof dueDateAging.rows_with_payment_terms === "number" && Number.isFinite(dueDateAging.rows_with_payment_terms) ? dueDateAging.rows_with_payment_terms : null; - const rowsWithAmount = - typeof dueDateAging.rows_with_amount === "number" && Number.isFinite(dueDateAging.rows_with_amount) - ? dueDateAging.rows_with_amount - : null; const dueDateScopePrefix = organizationScope ? `по компании ${organizationScope} ` : ""; if (status === "confirmed_overdue") { lines.push( @@ -1270,13 +1283,23 @@ function buildCompactBusinessOverviewReply( ); lines.push("Основа ответа: открытые расчеты 60/62/76, договорный срок оплаты и дата расчетного документа; это проверка просрочки по срокам оплаты, а не просто возраст договора."); } else if (status === "no_payment_terms_configured") { + if (shortDebtBoundaryFollowup) { + lines.push( + `Коротко: да, доказать просрочку ${dueDateScopePrefix}на ${asOfDate} нельзя, потому что в подтвержденных данных не установлен срок оплаты.` + ); + lines.push( + `Открытый долг${grossAmount ? ` на ${grossAmount}` : ""} виден, но без due date это задолженность без доказанной договорной просрочки.` + ); + return joinBusinessReplyLines(lines); + } lines.push( `Коротко: ${dueDateScopePrefix}на ${asOfDate} подтвержденной просрочки нет: открытые расчеты проверены${grossAmount ? ` на ${grossAmount}` : ""}, но в найденных договорах срок оплаты не установлен.` ); lines.push( - rowsWithAmount !== null - ? `Проверено строк с суммой: ${rowsWithAmount}. Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой.` - : "Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой." + "Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой." + ); + lines.push( + "Что проверить дальше: сроки оплаты и даты документов-оснований в договорах/расчетах; без них просрочку доказать нельзя." ); } else if (status === "insufficient_due_date_basis") { lines.push( diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 9f404d9..efb5704 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -4,6 +4,11 @@ import { type AssistantMcpDiscoveryResponseCandidateStatus } from "./assistantMcpDiscoveryResponseCandidate"; import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint"; +import { + ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + type AssistantCoverageGateState, + type AssistantTruthAnswerPolicyRuntimeContract +} from "../types/assistantRuntimeContracts"; export const ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION = "assistant_mcp_discovery_response_policy_v1" as const; @@ -31,6 +36,112 @@ export interface AssistantMcpDiscoveryResponsePolicyResult { reason_codes: string[]; } +function uniqueReasonCodes(values: unknown[]): string[] { + const result: string[] = []; + values.forEach((value) => { + const text = toNonEmptyString(value); + if (!text) { + return; + } + const normalized = normalizeReasonCode(text); + if (normalized && !result.includes(normalized)) { + result.push(normalized); + } + }); + return result; +} + +function patchCoverageGateState(value: unknown, gate: AssistantCoverageGateState): unknown { + const source = toRecordObject(value); + if (!source) { + return value; + } + const currentGate = toRecordObject(source.coverage_gate_state); + return { + ...source, + coverage_gate_state: { + ...(currentGate ?? {}), + coverage_status: gate.coverage_status, + evidence_grade: gate.evidence_grade, + grounding_status: gate.grounding_status, + truth_mode: gate.truth_mode, + carryover_eligibility: gate.carryover_eligibility, + reason_codes: gate.reason_codes + } + }; +} + +export function buildAppliedMcpDiscoveryTruthPatch( + policy: AssistantMcpDiscoveryResponsePolicyResult, + currentDebug?: Record | null +): Record { + if (!policy.applied || !ALLOWED_CANDIDATE_STATUSES.has(policy.candidate.candidate_status)) { + return {}; + } + const reasonCodes = uniqueReasonCodes([ + ...policy.reason_codes, + "mcp_discovery_response_policy_candidate_applied", + "mcp_discovery_response_candidate_guarded_truth_patch" + ]); + const truthGate: AssistantTruthAnswerPolicyRuntimeContract["truth_gate"] = { + schema_version: ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + coverage_status: "partial", + evidence_grade: "medium", + grounding_status: "partial", + truth_mode: "limited", + carryover_eligibility: "root_only", + reason_codes: reasonCodes, + source_truth_gate_status: "partial_supported", + blocked_or_limited_explanation: "mcp_discovery_guarded_bounded_answer" + }; + const answerShape: AssistantTruthAnswerPolicyRuntimeContract["answer_shape"] = { + schema_version: ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + answer_shape: "limited_with_reason", + reply_type: "partial_coverage", + capability_contract_id: null, + transition_contract_id: null, + may_state_confirmed_facts: true, + must_include_limitation: true, + may_power_followup: true, + required_sections: ["direct_answer", "evidence_window", "limitations"], + downgrade_only: true + }; + const truthPolicy: AssistantTruthAnswerPolicyRuntimeContract = { + schema_version: ASSISTANT_TRUTH_ANSWER_POLICY_RUNTIME_SCHEMA_VERSION, + policy_owner: "assistantTruthAnswerPolicyRuntimeAdapter", + truth_gate: truthGate, + answer_shape: answerShape + }; + const debug = currentDebug ?? {}; + const patch: Record = { + assistant_truth_answer_policy_v1: truthPolicy, + coverage_gate_contract: truthGate, + answer_shape_contract: answerShape, + truth_gate_contract_status: "partial_supported", + truth_mode: "limited", + carryover_eligibility: "root_only", + answer_shape: "limited_with_reason" + }; + const stateTransition = patchCoverageGateState(debug.assistant_state_transition_v1, truthGate); + if (stateTransition !== debug.assistant_state_transition_v1) { + patch.assistant_state_transition_v1 = stateTransition; + } + const stateTransitionContract = patchCoverageGateState(debug.state_transition_contract, truthGate); + if (stateTransitionContract !== debug.state_transition_contract) { + patch.state_transition_contract = stateTransitionContract; + } + const runtimeContract = toRecordObject(debug.assistant_runtime_contract_v1); + if (runtimeContract) { + patch.assistant_runtime_contract_v1 = { + ...runtimeContract, + truth_gate_contract_status: "partial_supported" + }; + } + return patch; +} + const ALLOWED_CANDIDATE_STATUSES = new Set([ "ready_for_guarded_use", "checked_sources_only_candidate", diff --git a/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts index c80cc5f..43cef06 100644 --- a/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts @@ -235,6 +235,24 @@ describe("assistant deep turn response runtime adapter", () => { expect(runtime.response.assistant_reply).toContain("Discovery answer"); expect(runtime.debug?.mcp_discovery_response_applied).toBe(true); + expect(runtime.debug?.truth_gate_contract_status).toBe("partial_supported"); + expect(runtime.debug?.truth_mode).toBe("limited"); + expect(runtime.debug?.coverage_gate_contract).toEqual( + expect.objectContaining({ + coverage_status: "partial", + evidence_grade: "medium", + grounding_status: "partial", + truth_mode: "limited", + source_truth_gate_status: "partial_supported" + }) + ); + expect(runtime.debug?.answer_shape_contract).toEqual( + expect.objectContaining({ + answer_shape: "limited_with_reason", + reply_type: "partial_coverage", + may_state_confirmed_facts: true + }) + ); expect(runFinalizeDeepTurn).toHaveBeenCalledWith( expect.objectContaining({ assistantReply: expect.stringContaining("Discovery answer"), diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index e7daa70..4ad1e49 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -523,6 +523,116 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("руб.."); }); + it("keeps debt due-date boundary answers useful and hides row counters", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + raw_message: "По ООО Альтернатива Плюс на конец 2020 можно точно понять, какая дебиторка просрочена?", + effective_message: "Проверить просроченную дебиторку ООО Альтернатива Плюс на конец 2020.", + asked_domain_family: "business_overview", + asked_action_family: "debt_due_date_boundary", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020-12-31" + }, + data_need_graph: { + business_fact_family: "business_overview", + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + debt_due_date_aging: { + evidence_status: "no_payment_terms_configured", + as_of_date: "2020-12-31", + gross_open_amount_human_ru: "35 472 380,36 руб.", + rows_with_amount: 46 + } + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Debt boundary.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("35 472 380,36 руб."); + expect(candidate.reply_text).toContain("срок оплаты не установлен"); + expect(candidate.reply_text).toContain("нельзя честно назвать эти остатки просрочкой"); + expect(candidate.reply_text).toContain("Что проверить дальше"); + expect(candidate.reply_text).not.toContain("Проверено строк"); + expect(candidate.reply_text).not.toContain("строк с суммой"); + }); + + it("answers short debt boundary follow-ups directly instead of replaying the full debt packet", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + raw_message: "То есть просрочку доказать нельзя, коротко почему?", + effective_message: "Коротко объяснить, почему просрочку нельзя доказать.", + asked_domain_family: "business_overview", + asked_action_family: "debt_due_date_boundary", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020-12-31" + }, + data_need_graph: { + business_fact_family: "business_overview", + question: "То есть просрочку доказать нельзя, коротко почему?", + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + debt_due_date_aging: { + evidence_status: "no_payment_terms_configured", + as_of_date: "2020-12-31", + gross_open_amount_human_ru: "35 472 380,36 руб.", + rows_with_amount: 46 + } + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Debt boundary.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text?.split("\n")[0]).toContain("доказать просрочку"); + expect(candidate.reply_text?.split("\n")[0]).toContain("нельзя"); + expect(candidate.reply_text).toContain("due date"); + expect(candidate.reply_text).not.toContain("Проверено строк"); + expect(candidate.reply_text).not.toContain("строк с суммой"); + }); + it("uses a compact direct answer for business-overview top year questions", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({