From a75da4017855a6d636329213b18b2491099d5df6 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 20 Apr 2026 09:01:14 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B7=D0=B0=D0=BB=D0=BE=D0=B6=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=20MCP=20semantic=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...alog_authority_recovery_plan_2026-04-19.md | 23 + .../services/assistantMcpDiscoveryPolicy.js | 297 +++++++++++++ .../services/assistantMcpDiscoveryPolicy.ts | 411 ++++++++++++++++++ .../tests/assistantMcpDiscoveryPolicy.test.ts | 141 ++++++ 4 files changed, 872 insertions(+) create mode 100644 llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js create mode 100644 llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts create mode 100644 llm_normalizer/backend/tests/assistantMcpDiscoveryPolicy.test.ts diff --git a/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md b/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md index 5dbdbf0..c5a583d 100644 --- a/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md +++ b/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md @@ -659,6 +659,29 @@ Contract-layer result: This closes the misleading `capability_contract_missing` / `transition_contract_not_resolved` debug gap for the core phase18 path without changing the user-facing answer semantics. +## Progress Update - 2026-04-20 MCP Discovery Contract Seed + +The first implementation slice of Big Block 5 added a non-runtime-disruptive contract owner for guarded MCP semantic discovery: + +- `assistantMcpDiscoveryPolicy.ts` +- `assistantMcpDiscoveryPolicy.test.ts` + +This seed does not yet route live user traffic into discovery. + +It establishes the safety contract that future runtime wiring must obey: + +- Qwen3 may propose business-level MCP exploration primitives; +- unregistered primitives are rejected by runtime policy; +- raw model claims are never answer-authoritative; +- every discovery answer must pass an evidence gate; +- evidence must separate confirmed facts, inferred facts, and unknown facts; +- probe execution cannot bypass the runtime-approved primitive plan. + +Validation: + +- `npm test -- assistantMcpDiscoveryPolicy.test.ts` passed 6/6; +- `npm run build` passed. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js new file mode 100644 index 0000000..be9cae1 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js @@ -0,0 +1,297 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_DISCOVERY_PRIMITIVES = exports.ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION = void 0; +exports.isAssistantMcpDiscoveryPrimitive = isAssistantMcpDiscoveryPrimitive; +exports.buildAssistantMcpDiscoveryPlan = buildAssistantMcpDiscoveryPlan; +exports.resolveAssistantMcpDiscoveryEvidence = resolveAssistantMcpDiscoveryEvidence; +exports.ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION = "assistant_mcp_discovery_plan_v1"; +exports.ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION = "assistant_mcp_discovery_evidence_v1"; +exports.ASSISTANT_MCP_DISCOVERY_PRIMITIVES = [ + "search_business_entity", + "inspect_1c_metadata", + "resolve_entity_reference", + "query_movements", + "query_documents", + "aggregate_by_axis", + "drilldown_related_objects", + "probe_coverage", + "explain_evidence_basis" +]; +const DEFAULT_DISCOVERY_BUDGET = { + max_probe_count: 3, + max_rows_per_probe: 100 +}; +const MAX_PROBE_COUNT = 6; +const MAX_ROWS_PER_PROBE = 500; +const ALLOWED_PRIMITIVE_SET = new Set(exports.ASSISTANT_MCP_DISCOVERY_PRIMITIVES); +function toNonEmptyString(value) { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} +function toStringList(value) { + if (!Array.isArray(value)) { + return []; + } + const result = []; + for (const item of value) { + const text = toNonEmptyString(item); + if (text && !result.includes(text)) { + result.push(text); + } + } + return result; +} +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 normalized = normalizeReasonCode(value); + if (normalized && !target.includes(normalized)) { + target.push(normalized); + } +} +function clampInteger(value, fallback, min, max) { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.trunc(Number(value)))); +} +function isAllowedPrimitive(value) { + return ALLOWED_PRIMITIVE_SET.has(value); +} +function normalizeTurnMeaning(value) { + if (!value) { + return null; + } + const result = {}; + const domain = toNonEmptyString(value.asked_domain_family); + const action = toNonEmptyString(value.asked_action_family); + const organization = toNonEmptyString(value.explicit_organization_scope); + const dateScope = toNonEmptyString(value.explicit_date_scope); + const unsupported = toNonEmptyString(value.unsupported_but_understood_family); + const entities = toStringList(value.explicit_entity_candidates); + if (domain) { + result.asked_domain_family = domain; + } + if (action) { + result.asked_action_family = action; + } + if (entities.length > 0) { + result.explicit_entity_candidates = entities; + } + if (organization) { + result.explicit_organization_scope = organization; + } + if (dateScope) { + result.explicit_date_scope = dateScope; + } + if (Number.isFinite(value.meaning_confidence)) { + result.meaning_confidence = Math.max(0, Math.min(1, Number(value.meaning_confidence))); + } + if (unsupported) { + result.unsupported_but_understood_family = unsupported; + } + if (value.stale_replay_forbidden !== null && value.stale_replay_forbidden !== undefined) { + result.stale_replay_forbidden = Boolean(value.stale_replay_forbidden); + } + return Object.keys(result).length > 0 ? result : null; +} +function hasGroundingAxis(input) { + if (input.requiredAxes.length > 0) { + return true; + } + const meaning = input.turnMeaning; + return Boolean(meaning?.asked_domain_family || + meaning?.asked_action_family || + meaning?.explicit_organization_scope || + meaning?.explicit_date_scope || + (meaning?.explicit_entity_candidates?.length ?? 0) > 0); +} +function isAssistantMcpDiscoveryPrimitive(value) { + return isAllowedPrimitive(value); +} +function buildAssistantMcpDiscoveryPlan(input) { + const semanticDataNeed = toNonEmptyString(input.semanticDataNeed); + const turnMeaning = normalizeTurnMeaning(input.turnMeaning); + const requiredAxes = toStringList(input.requiredAxes); + const proposed = toStringList(input.proposedPrimitives); + const reasonCodes = []; + const allowedPrimitives = []; + const rejectedPrimitives = []; + for (const primitive of proposed) { + if (isAllowedPrimitive(primitive)) { + if (!allowedPrimitives.includes(primitive)) { + allowedPrimitives.push(primitive); + } + } + else { + rejectedPrimitives.push(primitive); + } + } + if (rejectedPrimitives.length > 0) { + pushReason(reasonCodes, "model_proposed_unregistered_mcp_primitive"); + } + if (!semanticDataNeed) { + pushReason(reasonCodes, "semantic_data_need_missing"); + } + if (!turnMeaning) { + pushReason(reasonCodes, "turn_meaning_ref_missing"); + } + if (!hasGroundingAxis({ turnMeaning, requiredAxes })) { + pushReason(reasonCodes, "grounding_axis_missing"); + } + if (allowedPrimitives.length === 0 && proposed.length > 0) { + pushReason(reasonCodes, "no_allowed_mcp_primitives_after_runtime_filter"); + } + if (allowedPrimitives.length === 0 && proposed.length === 0) { + pushReason(reasonCodes, "mcp_primitives_not_proposed"); + } + let planStatus = "allowed"; + if (allowedPrimitives.length === 0 && proposed.length > 0) { + planStatus = "blocked"; + } + else if (!semanticDataNeed || !turnMeaning || !hasGroundingAxis({ turnMeaning, requiredAxes })) { + planStatus = "needs_clarification"; + } + else if (allowedPrimitives.length === 0) { + planStatus = "needs_clarification"; + } + if (planStatus === "allowed") { + pushReason(reasonCodes, "guarded_mcp_discovery_plan_allowed"); + } + else if (planStatus === "blocked") { + pushReason(reasonCodes, "guarded_mcp_discovery_plan_blocked"); + } + else { + pushReason(reasonCodes, "guarded_mcp_discovery_plan_needs_clarification"); + } + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPolicy", + plan_status: planStatus, + semantic_data_need: semanticDataNeed, + turn_meaning_ref: turnMeaning, + allowed_primitives: allowedPrimitives, + rejected_primitives: rejectedPrimitives, + required_axes: requiredAxes, + execution_budget: { + max_probe_count: clampInteger(input.maxProbeCount, DEFAULT_DISCOVERY_BUDGET.max_probe_count, 1, MAX_PROBE_COUNT), + max_rows_per_probe: clampInteger(input.maxRowsPerProbe, DEFAULT_DISCOVERY_BUDGET.max_rows_per_probe, 1, MAX_ROWS_PER_PROBE) + }, + requires_evidence_gate: true, + answer_may_use_raw_model_claims: false, + reason_codes: reasonCodes + }; +} +function collectProbeLimitations(probeResults) { + const limitations = []; + for (const probe of probeResults) { + const limitation = toNonEmptyString(probe.limitation); + if (limitation && !limitations.includes(limitation)) { + limitations.push(limitation); + } + } + return limitations; +} +function probeRowsMatched(probeResults) { + return probeResults.reduce((sum, probe) => { + const rows = Number(probe.rows_matched ?? 0); + return sum + (Number.isFinite(rows) && rows > 0 ? rows : 0); + }, 0); +} +function probeRowsReceived(probeResults) { + return probeResults.reduce((sum, probe) => { + const rows = Number(probe.rows_received ?? 0); + return sum + (Number.isFinite(rows) && rows > 0 ? rows : 0); + }, 0); +} +function hasProbeBypass(plan, probeResults) { + const allowed = new Set(plan.allowed_primitives); + return probeResults.some((probe) => !allowed.has(probe.primitive_id)); +} +function confidenceReasonFor(status) { + if (status === "confirmed") { + return "confirmed_facts_backed_by_allowed_mcp_probe_rows"; + } + if (status === "inferred_only") { + return "only_inferred_facts_available_from_allowed_mcp_probe_rows"; + } + if (status === "blocked") { + return "runtime_evidence_gate_blocked_discovery_answer"; + } + return "allowed_mcp_probes_did_not_produce_sufficient_evidence"; +} +function resolveAssistantMcpDiscoveryEvidence(input) { + const probeResults = Array.isArray(input.probeResults) ? input.probeResults : []; + const confirmedFacts = toStringList(input.confirmedFacts); + const inferredFacts = toStringList(input.inferredFacts); + const unknownFacts = toStringList(input.unknownFacts); + const sourceRowsSummary = toNonEmptyString(input.sourceRowsSummary); + const queryLimitations = [ + ...toStringList(input.queryLimitations), + ...collectProbeLimitations(probeResults) + ].filter((item, index, all) => all.indexOf(item) === index); + const reasonCodes = [...input.plan.reason_codes]; + const rowsMatched = probeRowsMatched(probeResults); + const rowsReceived = probeRowsReceived(probeResults); + const bypassDetected = hasProbeBypass(input.plan, probeResults); + if (bypassDetected) { + pushReason(reasonCodes, "probe_result_used_primitive_outside_runtime_plan"); + } + if (input.plan.plan_status !== "allowed") { + pushReason(reasonCodes, "plan_not_allowed_by_runtime"); + } + if (confirmedFacts.length > 0 && rowsMatched <= 0) { + pushReason(reasonCodes, "confirmed_facts_without_matched_probe_rows"); + } + if (!sourceRowsSummary && rowsReceived > 0) { + pushReason(reasonCodes, "source_rows_summary_missing"); + } + let evidenceStatus = "insufficient"; + let coverageStatus = "blocked"; + let answerPermission = "checked_sources_only"; + if (bypassDetected || input.plan.plan_status !== "allowed") { + evidenceStatus = "blocked"; + coverageStatus = "blocked"; + answerPermission = "checked_sources_only"; + } + else if (confirmedFacts.length > 0 && rowsMatched > 0 && sourceRowsSummary) { + evidenceStatus = "confirmed"; + coverageStatus = "full"; + answerPermission = "confirmed_answer"; + pushReason(reasonCodes, "confirmed_facts_with_allowed_mcp_evidence"); + } + else if (inferredFacts.length > 0 && rowsReceived > 0) { + evidenceStatus = "inferred_only"; + coverageStatus = "partial"; + answerPermission = "bounded_inference"; + pushReason(reasonCodes, "inferred_facts_require_bounded_answer"); + } + else { + pushReason(reasonCodes, "mcp_discovery_evidence_insufficient"); + } + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPolicy", + evidence_status: evidenceStatus, + coverage_status: coverageStatus, + answer_permission: answerPermission, + confirmed_facts: confirmedFacts, + inferred_facts: inferredFacts, + unknown_facts: unknownFacts, + source_rows_summary: sourceRowsSummary, + query_plan: input.plan, + query_limitations: queryLimitations, + confidence_reason: confidenceReasonFor(evidenceStatus), + recommended_next_probe: toNonEmptyString(input.recommendedNextProbe), + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts new file mode 100644 index 0000000..c0325b4 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -0,0 +1,411 @@ +export const ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION = "assistant_mcp_discovery_plan_v1" as const; +export const ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION = "assistant_mcp_discovery_evidence_v1" as const; + +export const ASSISTANT_MCP_DISCOVERY_PRIMITIVES = [ + "search_business_entity", + "inspect_1c_metadata", + "resolve_entity_reference", + "query_movements", + "query_documents", + "aggregate_by_axis", + "drilldown_related_objects", + "probe_coverage", + "explain_evidence_basis" +] as const; + +export type AssistantMcpDiscoveryPrimitive = (typeof ASSISTANT_MCP_DISCOVERY_PRIMITIVES)[number]; + +export type AssistantMcpDiscoveryPlanStatus = "allowed" | "needs_clarification" | "blocked"; +export type AssistantMcpDiscoveryCoverageStatus = "full" | "partial" | "blocked"; +export type AssistantMcpDiscoveryEvidenceStatus = "confirmed" | "inferred_only" | "insufficient" | "blocked"; +export type AssistantMcpDiscoveryAnswerPermission = "confirmed_answer" | "bounded_inference" | "checked_sources_only"; + +export interface AssistantMcpDiscoveryTurnMeaningRef { + asked_domain_family?: string | null; + asked_action_family?: string | null; + explicit_entity_candidates?: string[]; + explicit_organization_scope?: string | null; + explicit_date_scope?: string | null; + meaning_confidence?: number | null; + unsupported_but_understood_family?: string | null; + stale_replay_forbidden?: boolean | null; +} + +export interface AssistantMcpDiscoveryExecutionBudget { + max_probe_count: number; + max_rows_per_probe: number; +} + +export interface AssistantMcpDiscoveryPlanContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryPolicy"; + plan_status: AssistantMcpDiscoveryPlanStatus; + semantic_data_need: string | null; + turn_meaning_ref: AssistantMcpDiscoveryTurnMeaningRef | null; + allowed_primitives: AssistantMcpDiscoveryPrimitive[]; + rejected_primitives: string[]; + required_axes: string[]; + execution_budget: AssistantMcpDiscoveryExecutionBudget; + requires_evidence_gate: true; + answer_may_use_raw_model_claims: false; + reason_codes: string[]; +} + +export interface BuildAssistantMcpDiscoveryPlanInput { + semanticDataNeed?: string | null; + turnMeaning?: AssistantMcpDiscoveryTurnMeaningRef | null; + proposedPrimitives?: string[] | null; + requiredAxes?: string[] | null; + maxProbeCount?: number | null; + maxRowsPerProbe?: number | null; +} + +export interface AssistantMcpDiscoveryProbeResult { + primitive_id: string; + status: "ok" | "error" | "skipped"; + rows_received?: number | null; + rows_matched?: number | null; + limitation?: string | null; +} + +export interface ResolveAssistantMcpDiscoveryEvidenceInput { + plan: AssistantMcpDiscoveryPlanContract; + probeResults?: AssistantMcpDiscoveryProbeResult[] | null; + confirmedFacts?: string[] | null; + inferredFacts?: string[] | null; + unknownFacts?: string[] | null; + sourceRowsSummary?: string | null; + queryLimitations?: string[] | null; + recommendedNextProbe?: string | null; +} + +export interface AssistantMcpDiscoveryEvidenceContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryPolicy"; + evidence_status: AssistantMcpDiscoveryEvidenceStatus; + coverage_status: AssistantMcpDiscoveryCoverageStatus; + answer_permission: AssistantMcpDiscoveryAnswerPermission; + confirmed_facts: string[]; + inferred_facts: string[]; + unknown_facts: string[]; + source_rows_summary: string | null; + query_plan: AssistantMcpDiscoveryPlanContract; + query_limitations: string[]; + confidence_reason: string; + recommended_next_probe: string | null; + reason_codes: string[]; +} + +const DEFAULT_DISCOVERY_BUDGET: AssistantMcpDiscoveryExecutionBudget = { + max_probe_count: 3, + max_rows_per_probe: 100 +}; + +const MAX_PROBE_COUNT = 6; +const MAX_ROWS_PER_PROBE = 500; + +const ALLOWED_PRIMITIVE_SET = new Set(ASSISTANT_MCP_DISCOVERY_PRIMITIVES); + +function toNonEmptyString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + +function toStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const result: string[] = []; + for (const item of value) { + const text = toNonEmptyString(item); + if (text && !result.includes(text)) { + result.push(text); + } + } + return result; +} + +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: string): void { + const normalized = normalizeReasonCode(value); + if (normalized && !target.includes(normalized)) { + target.push(normalized); + } +} + +function clampInteger(value: number | null | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.trunc(Number(value)))); +} + +function isAllowedPrimitive(value: string): value is AssistantMcpDiscoveryPrimitive { + return ALLOWED_PRIMITIVE_SET.has(value); +} + +function normalizeTurnMeaning( + value: AssistantMcpDiscoveryTurnMeaningRef | null | undefined +): AssistantMcpDiscoveryTurnMeaningRef | null { + if (!value) { + return null; + } + const result: AssistantMcpDiscoveryTurnMeaningRef = {}; + const domain = toNonEmptyString(value.asked_domain_family); + const action = toNonEmptyString(value.asked_action_family); + const organization = toNonEmptyString(value.explicit_organization_scope); + const dateScope = toNonEmptyString(value.explicit_date_scope); + const unsupported = toNonEmptyString(value.unsupported_but_understood_family); + const entities = toStringList(value.explicit_entity_candidates); + if (domain) { + result.asked_domain_family = domain; + } + if (action) { + result.asked_action_family = action; + } + if (entities.length > 0) { + result.explicit_entity_candidates = entities; + } + if (organization) { + result.explicit_organization_scope = organization; + } + if (dateScope) { + result.explicit_date_scope = dateScope; + } + if (Number.isFinite(value.meaning_confidence)) { + result.meaning_confidence = Math.max(0, Math.min(1, Number(value.meaning_confidence))); + } + if (unsupported) { + result.unsupported_but_understood_family = unsupported; + } + if (value.stale_replay_forbidden !== null && value.stale_replay_forbidden !== undefined) { + result.stale_replay_forbidden = Boolean(value.stale_replay_forbidden); + } + return Object.keys(result).length > 0 ? result : null; +} + +function hasGroundingAxis(input: { + turnMeaning: AssistantMcpDiscoveryTurnMeaningRef | null; + requiredAxes: string[]; +}): boolean { + if (input.requiredAxes.length > 0) { + return true; + } + const meaning = input.turnMeaning; + return Boolean( + meaning?.asked_domain_family || + meaning?.asked_action_family || + meaning?.explicit_organization_scope || + meaning?.explicit_date_scope || + (meaning?.explicit_entity_candidates?.length ?? 0) > 0 + ); +} + +export function isAssistantMcpDiscoveryPrimitive(value: string): value is AssistantMcpDiscoveryPrimitive { + return isAllowedPrimitive(value); +} + +export function buildAssistantMcpDiscoveryPlan( + input: BuildAssistantMcpDiscoveryPlanInput +): AssistantMcpDiscoveryPlanContract { + const semanticDataNeed = toNonEmptyString(input.semanticDataNeed); + const turnMeaning = normalizeTurnMeaning(input.turnMeaning); + const requiredAxes = toStringList(input.requiredAxes); + const proposed = toStringList(input.proposedPrimitives); + const reasonCodes: string[] = []; + const allowedPrimitives: AssistantMcpDiscoveryPrimitive[] = []; + const rejectedPrimitives: string[] = []; + + for (const primitive of proposed) { + if (isAllowedPrimitive(primitive)) { + if (!allowedPrimitives.includes(primitive)) { + allowedPrimitives.push(primitive); + } + } else { + rejectedPrimitives.push(primitive); + } + } + + if (rejectedPrimitives.length > 0) { + pushReason(reasonCodes, "model_proposed_unregistered_mcp_primitive"); + } + if (!semanticDataNeed) { + pushReason(reasonCodes, "semantic_data_need_missing"); + } + if (!turnMeaning) { + pushReason(reasonCodes, "turn_meaning_ref_missing"); + } + if (!hasGroundingAxis({ turnMeaning, requiredAxes })) { + pushReason(reasonCodes, "grounding_axis_missing"); + } + if (allowedPrimitives.length === 0 && proposed.length > 0) { + pushReason(reasonCodes, "no_allowed_mcp_primitives_after_runtime_filter"); + } + if (allowedPrimitives.length === 0 && proposed.length === 0) { + pushReason(reasonCodes, "mcp_primitives_not_proposed"); + } + + let planStatus: AssistantMcpDiscoveryPlanStatus = "allowed"; + if (allowedPrimitives.length === 0 && proposed.length > 0) { + planStatus = "blocked"; + } else if (!semanticDataNeed || !turnMeaning || !hasGroundingAxis({ turnMeaning, requiredAxes })) { + planStatus = "needs_clarification"; + } else if (allowedPrimitives.length === 0) { + planStatus = "needs_clarification"; + } + + if (planStatus === "allowed") { + pushReason(reasonCodes, "guarded_mcp_discovery_plan_allowed"); + } else if (planStatus === "blocked") { + pushReason(reasonCodes, "guarded_mcp_discovery_plan_blocked"); + } else { + pushReason(reasonCodes, "guarded_mcp_discovery_plan_needs_clarification"); + } + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPolicy", + plan_status: planStatus, + semantic_data_need: semanticDataNeed, + turn_meaning_ref: turnMeaning, + allowed_primitives: allowedPrimitives, + rejected_primitives: rejectedPrimitives, + required_axes: requiredAxes, + execution_budget: { + max_probe_count: clampInteger(input.maxProbeCount, DEFAULT_DISCOVERY_BUDGET.max_probe_count, 1, MAX_PROBE_COUNT), + max_rows_per_probe: clampInteger( + input.maxRowsPerProbe, + DEFAULT_DISCOVERY_BUDGET.max_rows_per_probe, + 1, + MAX_ROWS_PER_PROBE + ) + }, + requires_evidence_gate: true, + answer_may_use_raw_model_claims: false, + reason_codes: reasonCodes + }; +} + +function collectProbeLimitations(probeResults: AssistantMcpDiscoveryProbeResult[]): string[] { + const limitations: string[] = []; + for (const probe of probeResults) { + const limitation = toNonEmptyString(probe.limitation); + if (limitation && !limitations.includes(limitation)) { + limitations.push(limitation); + } + } + return limitations; +} + +function probeRowsMatched(probeResults: AssistantMcpDiscoveryProbeResult[]): number { + return probeResults.reduce((sum, probe) => { + const rows = Number(probe.rows_matched ?? 0); + return sum + (Number.isFinite(rows) && rows > 0 ? rows : 0); + }, 0); +} + +function probeRowsReceived(probeResults: AssistantMcpDiscoveryProbeResult[]): number { + return probeResults.reduce((sum, probe) => { + const rows = Number(probe.rows_received ?? 0); + return sum + (Number.isFinite(rows) && rows > 0 ? rows : 0); + }, 0); +} + +function hasProbeBypass(plan: AssistantMcpDiscoveryPlanContract, probeResults: AssistantMcpDiscoveryProbeResult[]): boolean { + const allowed = new Set(plan.allowed_primitives); + return probeResults.some((probe) => !allowed.has(probe.primitive_id)); +} + +function confidenceReasonFor(status: AssistantMcpDiscoveryEvidenceStatus): string { + if (status === "confirmed") { + return "confirmed_facts_backed_by_allowed_mcp_probe_rows"; + } + if (status === "inferred_only") { + return "only_inferred_facts_available_from_allowed_mcp_probe_rows"; + } + if (status === "blocked") { + return "runtime_evidence_gate_blocked_discovery_answer"; + } + return "allowed_mcp_probes_did_not_produce_sufficient_evidence"; +} + +export function resolveAssistantMcpDiscoveryEvidence( + input: ResolveAssistantMcpDiscoveryEvidenceInput +): AssistantMcpDiscoveryEvidenceContract { + const probeResults = Array.isArray(input.probeResults) ? input.probeResults : []; + const confirmedFacts = toStringList(input.confirmedFacts); + const inferredFacts = toStringList(input.inferredFacts); + const unknownFacts = toStringList(input.unknownFacts); + const sourceRowsSummary = toNonEmptyString(input.sourceRowsSummary); + const queryLimitations = [ + ...toStringList(input.queryLimitations), + ...collectProbeLimitations(probeResults) + ].filter((item, index, all) => all.indexOf(item) === index); + const reasonCodes: string[] = [...input.plan.reason_codes]; + const rowsMatched = probeRowsMatched(probeResults); + const rowsReceived = probeRowsReceived(probeResults); + const bypassDetected = hasProbeBypass(input.plan, probeResults); + + if (bypassDetected) { + pushReason(reasonCodes, "probe_result_used_primitive_outside_runtime_plan"); + } + if (input.plan.plan_status !== "allowed") { + pushReason(reasonCodes, "plan_not_allowed_by_runtime"); + } + if (confirmedFacts.length > 0 && rowsMatched <= 0) { + pushReason(reasonCodes, "confirmed_facts_without_matched_probe_rows"); + } + if (!sourceRowsSummary && rowsReceived > 0) { + pushReason(reasonCodes, "source_rows_summary_missing"); + } + + let evidenceStatus: AssistantMcpDiscoveryEvidenceStatus = "insufficient"; + let coverageStatus: AssistantMcpDiscoveryCoverageStatus = "blocked"; + let answerPermission: AssistantMcpDiscoveryAnswerPermission = "checked_sources_only"; + + if (bypassDetected || input.plan.plan_status !== "allowed") { + evidenceStatus = "blocked"; + coverageStatus = "blocked"; + answerPermission = "checked_sources_only"; + } else if (confirmedFacts.length > 0 && rowsMatched > 0 && sourceRowsSummary) { + evidenceStatus = "confirmed"; + coverageStatus = "full"; + answerPermission = "confirmed_answer"; + pushReason(reasonCodes, "confirmed_facts_with_allowed_mcp_evidence"); + } else if (inferredFacts.length > 0 && rowsReceived > 0) { + evidenceStatus = "inferred_only"; + coverageStatus = "partial"; + answerPermission = "bounded_inference"; + pushReason(reasonCodes, "inferred_facts_require_bounded_answer"); + } else { + pushReason(reasonCodes, "mcp_discovery_evidence_insufficient"); + } + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPolicy", + evidence_status: evidenceStatus, + coverage_status: coverageStatus, + answer_permission: answerPermission, + confirmed_facts: confirmedFacts, + inferred_facts: inferredFacts, + unknown_facts: unknownFacts, + source_rows_summary: sourceRowsSummary, + query_plan: input.plan, + query_limitations: queryLimitations, + confidence_reason: confidenceReasonFor(evidenceStatus), + recommended_next_probe: toNonEmptyString(input.recommendedNextProbe), + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPolicy.test.ts new file mode 100644 index 0000000..324f475 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPolicy.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + buildAssistantMcpDiscoveryPlan, + isAssistantMcpDiscoveryPrimitive, + resolveAssistantMcpDiscoveryEvidence +} from "../src/services/assistantMcpDiscoveryPolicy"; + +describe("assistant MCP discovery policy", () => { + it("allows guarded MCP primitives and keeps raw model claims outside the answer path", () => { + const plan = buildAssistantMcpDiscoveryPlan({ + semanticDataNeed: "counterparty turnover evidence", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020" + }, + proposedPrimitives: ["resolve_entity_reference", "query_movements", "drop_database"], + requiredAxes: ["counterparty", "period", "amount"], + maxProbeCount: 99, + maxRowsPerProbe: 9999 + }); + + expect(plan.plan_status).toBe("allowed"); + expect(plan.allowed_primitives).toEqual(["resolve_entity_reference", "query_movements"]); + expect(plan.rejected_primitives).toEqual(["drop_database"]); + expect(plan.requires_evidence_gate).toBe(true); + expect(plan.answer_may_use_raw_model_claims).toBe(false); + expect(plan.execution_budget).toEqual({ max_probe_count: 6, max_rows_per_probe: 500 }); + expect(plan.reason_codes).toContain("model_proposed_unregistered_mcp_primitive"); + }); + + it("blocks model-planned probes when no proposed primitive survives the runtime allowlist", () => { + const plan = buildAssistantMcpDiscoveryPlan({ + semanticDataNeed: "direct SQL from model", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["СВК"] + }, + proposedPrimitives: ["raw_sql", "filesystem_read"], + requiredAxes: ["counterparty"] + }); + + expect(plan.plan_status).toBe("blocked"); + expect(plan.allowed_primitives).toEqual([]); + expect(plan.rejected_primitives).toEqual(["raw_sql", "filesystem_read"]); + expect(plan.reason_codes).toContain("no_allowed_mcp_primitives_after_runtime_filter"); + }); + + it("separates confirmed, inferred and unknown facts before answer composition", () => { + const plan = buildAssistantMcpDiscoveryPlan({ + semanticDataNeed: "activity duration from 1C evidence", + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["СВК"] + }, + proposedPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage"], + requiredAxes: ["counterparty", "document_date"] + }); + + const evidence = resolveAssistantMcpDiscoveryEvidence({ + plan, + probeResults: [ + { primitive_id: "resolve_entity_reference", status: "ok", rows_received: 1, rows_matched: 1 }, + { primitive_id: "query_documents", status: "ok", rows_received: 4, rows_matched: 4 } + ], + confirmedFacts: ["first confirmed 1C activity is 2020-01-15"], + inferredFacts: ["activity duration can be estimated from first and latest 1C activity"], + unknownFacts: ["legal registration date is not proven by these rows"], + sourceRowsSummary: "5 allowed MCP rows: 1 entity match, 4 documents" + }); + + expect(evidence.evidence_status).toBe("confirmed"); + expect(evidence.coverage_status).toBe("full"); + expect(evidence.answer_permission).toBe("confirmed_answer"); + expect(evidence.confirmed_facts).toHaveLength(1); + expect(evidence.inferred_facts).toHaveLength(1); + expect(evidence.unknown_facts).toHaveLength(1); + expect(evidence.reason_codes).toContain("confirmed_facts_with_allowed_mcp_evidence"); + }); + + it("permits only bounded inference when probes found rows but no confirmed fact", () => { + const plan = buildAssistantMcpDiscoveryPlan({ + semanticDataNeed: "counterparty business age inference", + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "age_or_activity_duration", + explicit_entity_candidates: ["СВК"] + }, + proposedPrimitives: ["query_documents"], + requiredAxes: ["counterparty", "document_date"] + }); + + const evidence = resolveAssistantMcpDiscoveryEvidence({ + plan, + probeResults: [{ primitive_id: "query_documents", status: "ok", rows_received: 3, rows_matched: 0 }], + inferredFacts: ["activity is visible in 1C documents for 2020"], + unknownFacts: ["legal age remains unknown"], + sourceRowsSummary: "3 document rows checked" + }); + + expect(evidence.evidence_status).toBe("inferred_only"); + expect(evidence.coverage_status).toBe("partial"); + expect(evidence.answer_permission).toBe("bounded_inference"); + expect(evidence.confidence_reason).toBe("only_inferred_facts_available_from_allowed_mcp_probe_rows"); + }); + + it("blocks evidence when execution reports a primitive outside the runtime plan", () => { + const plan = buildAssistantMcpDiscoveryPlan({ + semanticDataNeed: "counterparty turnover evidence", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["СВК"] + }, + proposedPrimitives: ["query_movements"], + requiredAxes: ["counterparty"] + }); + + const evidence = resolveAssistantMcpDiscoveryEvidence({ + plan, + probeResults: [ + { primitive_id: "query_movements", status: "ok", rows_received: 2, rows_matched: 2 }, + { primitive_id: "raw_sql", status: "ok", rows_received: 10, rows_matched: 10 } + ], + confirmedFacts: ["turnover is 100"], + sourceRowsSummary: "12 rows" + }); + + expect(evidence.evidence_status).toBe("blocked"); + expect(evidence.answer_permission).toBe("checked_sources_only"); + expect(evidence.reason_codes).toContain("probe_result_used_primitive_outside_runtime_plan"); + }); + + it("exports the reviewed primitive predicate for future runtime adapters", () => { + expect(isAssistantMcpDiscoveryPrimitive("query_movements")).toBe(true); + expect(isAssistantMcpDiscoveryPrimitive("raw_sql")).toBe(false); + }); +});