From 9d5ae69953da5240bfb5481865e9766e19e656a3 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 20 Apr 2026 09:39:52 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20dry-run=20adapter=20MCP=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...alog_authority_recovery_plan_2026-04-19.md | 26 +++ .../assistantMcpDiscoveryRuntimeAdapter.js | 129 ++++++++++++ .../assistantMcpDiscoveryRuntimeAdapter.ts | 194 ++++++++++++++++++ ...ssistantMcpDiscoveryRuntimeAdapter.test.ts | 97 +++++++++ 4 files changed, 446 insertions(+) create mode 100644 llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeAdapter.js create mode 100644 llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeAdapter.ts create mode 100644 llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeAdapter.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 f79b6c0..85639c8 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 @@ -733,6 +733,32 @@ Validation: - `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts` passed 17/17; - `npm run build` passed. +## Progress Update - 2026-04-20 MCP Discovery Runtime Dry-Run Adapter + +The fourth implementation slice of Big Block 5 added a dry-run runtime adapter: + +- `assistantMcpDiscoveryRuntimeAdapter.ts` +- `assistantMcpDiscoveryRuntimeAdapter.test.ts` + +This adapter still does not execute live MCP calls. + +It turns planner output into an execution package that future live wiring can consume safely: + +- ordered execution steps; +- primitive purpose and expected fact kinds; +- provided and missing grounding axes; +- evidence floor and stop condition per primitive; +- execution budget; +- mandatory evidence gate inputs; +- user-facing fallback for missing scope or policy blocks. + +The contract explicitly records `mcp_execution_performed=false`, so this block cannot accidentally query 1C. + +Validation: + +- `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts` passed 21/21; +- `npm run build` passed. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeAdapter.js new file mode 100644 index 0000000..5afbbd4 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeAdapter.js @@ -0,0 +1,129 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION = void 0; +exports.buildAssistantMcpDiscoveryRuntimeDryRun = buildAssistantMcpDiscoveryRuntimeDryRun; +const assistantMcpCatalogIndex_1 = require("./assistantMcpCatalogIndex"); +exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_dry_run_v1"; +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 uniqueStrings(values) { + const result = []; + for (const value of values) { + const text = value.trim(); + if (text && !result.includes(text)) { + result.push(text); + } + } + return result; +} +function stepStatusFor(input) { + if (input.adapterStatus === "blocked") { + return "blocked"; + } + if (input.missingAxisOptions.length > 0) { + return "missing_axes"; + } + return "ready"; +} +function stopConditionFor(evidenceFloor) { + if (evidenceFloor === "rows_matched") { + return "stop_after_allowed_probe_returns_matched_rows_or_reports_no_match"; + } + if (evidenceFloor === "rows_received") { + return "stop_after_allowed_probe_returns_rows_or_reports_empty_source"; + } + if (evidenceFloor === "source_summary") { + return "stop_after_allowed_probe_returns_source_summary_or_limitation"; + } + return "stop_without_fact_claim"; +} +function adapterStatusFor(planner) { + if (planner.planner_status === "blocked" || planner.catalog_review.review_status === "catalog_blocked") { + return "blocked"; + } + if (planner.planner_status !== "ready_for_execution" || planner.catalog_review.review_status !== "catalog_compatible") { + return "needs_clarification"; + } + return "dry_run_ready"; +} +function fallbackFor(status) { + if (status === "dry_run_ready") { + return null; + } + if (status === "blocked") { + return "explain_that_runtime_policy_blocked_mcp_discovery"; + } + return "ask_for_missing_scope_before_mcp_discovery"; +} +function buildAssistantMcpDiscoveryRuntimeDryRun(planner) { + const adapterStatus = adapterStatusFor(planner); + const reasonCodes = uniqueStrings([ + ...planner.reason_codes, + ...planner.discovery_plan.reason_codes, + ...planner.catalog_review.reason_codes + ]); + const providedAxes = uniqueStrings(planner.required_axes); + const executionSteps = planner.discovery_plan.allowed_primitives.map((primitiveId, index) => { + const catalog = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogPrimitive)(primitiveId); + const missingAxisOptions = planner.catalog_review.missing_axes_by_primitive[primitiveId] ?? []; + return { + sequence: index + 1, + primitive_id: primitiveId, + step_status: stepStatusFor({ adapterStatus, missingAxisOptions }), + purpose: catalog.purpose, + provided_axes: providedAxes, + required_axis_options: catalog.required_axes_any_of, + missing_axis_options: missingAxisOptions, + optional_axes: catalog.optional_axes, + expected_fact_kinds: catalog.output_fact_kinds, + evidence_floor: catalog.evidence_floor, + runtime_must_execute: true, + dry_run_only: true, + stop_condition: stopConditionFor(catalog.evidence_floor) + }; + }); + if (adapterStatus === "dry_run_ready") { + pushReason(reasonCodes, "runtime_dry_run_ready_without_mcp_execution"); + } + else if (adapterStatus === "blocked") { + pushReason(reasonCodes, "runtime_dry_run_blocked_before_mcp_execution"); + } + else { + pushReason(reasonCodes, "runtime_dry_run_needs_clarification_before_mcp_execution"); + } + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeAdapter", + adapter_status: adapterStatus, + planner_status: planner.planner_status, + mcp_execution_performed: false, + execution_steps: executionSteps, + execution_budget: planner.discovery_plan.execution_budget, + evidence_gate: { + required: true, + expected_inputs: [ + "probe_results", + "confirmed_facts", + "inferred_facts", + "unknown_facts", + "source_rows_summary", + "query_limitations" + ], + answer_may_use_raw_model_claims: false + }, + user_facing_fallback: fallbackFor(adapterStatus), + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeAdapter.ts new file mode 100644 index 0000000..617efba --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeAdapter.ts @@ -0,0 +1,194 @@ +import { + getAssistantMcpCatalogPrimitive, + type AssistantMcpCatalogEvidenceFloor +} from "./assistantMcpCatalogIndex"; +import { + type AssistantMcpDiscoveryPlannerContract, + type AssistantMcpDiscoveryPlannerStatus +} from "./assistantMcpDiscoveryPlanner"; +import type { AssistantMcpDiscoveryPrimitive } from "./assistantMcpDiscoveryPolicy"; + +export const ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION = + "assistant_mcp_discovery_runtime_dry_run_v1" as const; + +export type AssistantMcpDiscoveryRuntimeAdapterStatus = + | "dry_run_ready" + | "needs_clarification" + | "blocked"; + +export type AssistantMcpDiscoveryRuntimeStepStatus = "ready" | "missing_axes" | "blocked"; + +export interface AssistantMcpDiscoveryRuntimeStepContract { + sequence: number; + primitive_id: AssistantMcpDiscoveryPrimitive; + step_status: AssistantMcpDiscoveryRuntimeStepStatus; + purpose: string; + provided_axes: string[]; + required_axis_options: string[][]; + missing_axis_options: string[][]; + optional_axes: string[]; + expected_fact_kinds: string[]; + evidence_floor: AssistantMcpCatalogEvidenceFloor; + runtime_must_execute: true; + dry_run_only: true; + stop_condition: string; +} + +export interface AssistantMcpDiscoveryEvidenceGateRequirement { + required: true; + expected_inputs: string[]; + answer_may_use_raw_model_claims: false; +} + +export interface AssistantMcpDiscoveryRuntimeDryRunContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryRuntimeAdapter"; + adapter_status: AssistantMcpDiscoveryRuntimeAdapterStatus; + planner_status: AssistantMcpDiscoveryPlannerStatus; + mcp_execution_performed: false; + execution_steps: AssistantMcpDiscoveryRuntimeStepContract[]; + execution_budget: { + max_probe_count: number; + max_rows_per_probe: number; + }; + evidence_gate: AssistantMcpDiscoveryEvidenceGateRequirement; + user_facing_fallback: string | null; + reason_codes: string[]; +} + +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 uniqueStrings(values: string[]): string[] { + const result: string[] = []; + for (const value of values) { + const text = value.trim(); + if (text && !result.includes(text)) { + result.push(text); + } + } + return result; +} + +function stepStatusFor(input: { + adapterStatus: AssistantMcpDiscoveryRuntimeAdapterStatus; + missingAxisOptions: string[][]; +}): AssistantMcpDiscoveryRuntimeStepStatus { + if (input.adapterStatus === "blocked") { + return "blocked"; + } + if (input.missingAxisOptions.length > 0) { + return "missing_axes"; + } + return "ready"; +} + +function stopConditionFor(evidenceFloor: AssistantMcpCatalogEvidenceFloor): string { + if (evidenceFloor === "rows_matched") { + return "stop_after_allowed_probe_returns_matched_rows_or_reports_no_match"; + } + if (evidenceFloor === "rows_received") { + return "stop_after_allowed_probe_returns_rows_or_reports_empty_source"; + } + if (evidenceFloor === "source_summary") { + return "stop_after_allowed_probe_returns_source_summary_or_limitation"; + } + return "stop_without_fact_claim"; +} + +function adapterStatusFor(planner: AssistantMcpDiscoveryPlannerContract): AssistantMcpDiscoveryRuntimeAdapterStatus { + if (planner.planner_status === "blocked" || planner.catalog_review.review_status === "catalog_blocked") { + return "blocked"; + } + if (planner.planner_status !== "ready_for_execution" || planner.catalog_review.review_status !== "catalog_compatible") { + return "needs_clarification"; + } + return "dry_run_ready"; +} + +function fallbackFor(status: AssistantMcpDiscoveryRuntimeAdapterStatus): string | null { + if (status === "dry_run_ready") { + return null; + } + if (status === "blocked") { + return "explain_that_runtime_policy_blocked_mcp_discovery"; + } + return "ask_for_missing_scope_before_mcp_discovery"; +} + +export function buildAssistantMcpDiscoveryRuntimeDryRun( + planner: AssistantMcpDiscoveryPlannerContract +): AssistantMcpDiscoveryRuntimeDryRunContract { + const adapterStatus = adapterStatusFor(planner); + const reasonCodes = uniqueStrings([ + ...planner.reason_codes, + ...planner.discovery_plan.reason_codes, + ...planner.catalog_review.reason_codes + ]); + + const providedAxes = uniqueStrings(planner.required_axes); + const executionSteps = planner.discovery_plan.allowed_primitives.map((primitiveId, index) => { + const catalog = getAssistantMcpCatalogPrimitive(primitiveId); + const missingAxisOptions = planner.catalog_review.missing_axes_by_primitive[primitiveId] ?? []; + return { + sequence: index + 1, + primitive_id: primitiveId, + step_status: stepStatusFor({ adapterStatus, missingAxisOptions }), + purpose: catalog.purpose, + provided_axes: providedAxes, + required_axis_options: catalog.required_axes_any_of, + missing_axis_options: missingAxisOptions, + optional_axes: catalog.optional_axes, + expected_fact_kinds: catalog.output_fact_kinds, + evidence_floor: catalog.evidence_floor, + runtime_must_execute: true, + dry_run_only: true, + stop_condition: stopConditionFor(catalog.evidence_floor) + } satisfies AssistantMcpDiscoveryRuntimeStepContract; + }); + + if (adapterStatus === "dry_run_ready") { + pushReason(reasonCodes, "runtime_dry_run_ready_without_mcp_execution"); + } else if (adapterStatus === "blocked") { + pushReason(reasonCodes, "runtime_dry_run_blocked_before_mcp_execution"); + } else { + pushReason(reasonCodes, "runtime_dry_run_needs_clarification_before_mcp_execution"); + } + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeAdapter", + adapter_status: adapterStatus, + planner_status: planner.planner_status, + mcp_execution_performed: false, + execution_steps: executionSteps, + execution_budget: planner.discovery_plan.execution_budget, + evidence_gate: { + required: true, + expected_inputs: [ + "probe_results", + "confirmed_facts", + "inferred_facts", + "unknown_facts", + "source_rows_summary", + "query_limitations" + ], + answer_may_use_raw_model_claims: false + }, + user_facing_fallback: fallbackFor(adapterStatus), + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeAdapter.test.ts new file mode 100644 index 0000000..fc6824b --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeAdapter.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { planAssistantMcpDiscovery } from "../src/services/assistantMcpDiscoveryPlanner"; +import { buildAssistantMcpDiscoveryRuntimeDryRun } from "../src/services/assistantMcpDiscoveryRuntimeAdapter"; + +describe("assistant MCP discovery runtime adapter", () => { + it("turns a catalog-compatible value-flow plan into an execution-ready dry-run package", () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020" + } + }); + + const dryRun = buildAssistantMcpDiscoveryRuntimeDryRun(planner); + + expect(dryRun.adapter_status).toBe("dry_run_ready"); + expect(dryRun.mcp_execution_performed).toBe(false); + expect(dryRun.user_facing_fallback).toBeNull(); + expect(dryRun.execution_steps.map((step) => step.primitive_id)).toEqual([ + "resolve_entity_reference", + "query_movements", + "aggregate_by_axis", + "probe_coverage" + ]); + expect(dryRun.execution_steps.every((step) => step.step_status === "ready")).toBe(true); + expect(dryRun.execution_steps.every((step) => step.dry_run_only)).toBe(true); + expect(dryRun.evidence_gate).toEqual({ + required: true, + expected_inputs: [ + "probe_results", + "confirmed_facts", + "inferred_facts", + "unknown_facts", + "source_rows_summary", + "query_limitations" + ], + answer_may_use_raw_model_claims: false + }); + expect(dryRun.reason_codes).toContain("runtime_dry_run_ready_without_mcp_execution"); + }); + + it("keeps execution in clarification state when catalog axes are missing", () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["SVK"] + } + }); + + const dryRun = buildAssistantMcpDiscoveryRuntimeDryRun(planner); + const movementStep = dryRun.execution_steps.find((step) => step.primitive_id === "query_movements"); + + expect(dryRun.adapter_status).toBe("needs_clarification"); + expect(dryRun.mcp_execution_performed).toBe(false); + expect(dryRun.user_facing_fallback).toBe("ask_for_missing_scope_before_mcp_discovery"); + expect(movementStep?.step_status).toBe("missing_axes"); + expect(movementStep?.missing_axis_options).toContainEqual(["period", "counterparty"]); + expect(dryRun.reason_codes).toContain("runtime_dry_run_needs_clarification_before_mcp_execution"); + }); + + it("keeps blocked planner output blocked before any MCP execution", () => { + const planner = planAssistantMcpDiscovery({}); + planner.planner_status = "blocked"; + planner.discovery_plan.plan_status = "blocked"; + planner.catalog_review.review_status = "catalog_blocked"; + + const dryRun = buildAssistantMcpDiscoveryRuntimeDryRun(planner); + + expect(dryRun.adapter_status).toBe("blocked"); + expect(dryRun.mcp_execution_performed).toBe(false); + expect(dryRun.user_facing_fallback).toBe("explain_that_runtime_policy_blocked_mcp_discovery"); + expect(dryRun.execution_steps.every((step) => step.step_status === "blocked")).toBe(true); + expect(dryRun.reason_codes).toContain("runtime_dry_run_blocked_before_mcp_execution"); + }); + + it("uses source-summary stop conditions for metadata-only dry runs", () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "metadata", + asked_action_family: "inspect_catalog" + } + }); + + const dryRun = buildAssistantMcpDiscoveryRuntimeDryRun(planner); + + expect(dryRun.adapter_status).toBe("dry_run_ready"); + expect(dryRun.execution_steps).toHaveLength(1); + expect(dryRun.execution_steps[0]).toMatchObject({ + primitive_id: "inspect_1c_metadata", + evidence_floor: "source_summary", + stop_condition: "stop_after_allowed_probe_returns_source_summary_or_limitation" + }); + }); +});