ARCH: заложить контракт MCP semantic discovery
This commit is contained in:
parent
05aad66dc4
commit
a75da40178
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string>(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<string>(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
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue