From 9d6e7066e0598752a0de44e7ef78a0cc8c5caa13 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 20 Apr 2026 10:09:37 +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=20runtime=20bridge=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 ++++ .../assistantMcpDiscoveryRuntimeBridge.js | 75 +++++++++++ .../assistantMcpDiscoveryRuntimeBridge.ts | 124 ++++++++++++++++++ ...assistantMcpDiscoveryRuntimeBridge.test.ts | 93 +++++++++++++ 4 files changed, 318 insertions(+) create mode 100644 llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js create mode 100644 llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts create mode 100644 llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.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 43245a8..9e2822c 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 @@ -818,6 +818,32 @@ Validation: - `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts` passed 29/29; - `npm run build` passed. +## Progress Update - 2026-04-20 MCP Discovery Runtime Bridge + +The seventh implementation slice of Big Block 5 added a runtime bridge contract: + +- `assistantMcpDiscoveryRuntimeBridge.ts` +- `assistantMcpDiscoveryRuntimeBridge.test.ts` + +This bridge is still not wired into the hot assistant answer path. + +It composes the already separated discovery layers into one orchestration-facing contract: + +- planner; +- isolated pilot executor; +- human-safe answer draft; +- bridge status; +- explicit `hot_runtime_wired=false`; +- business-fact answer authorization flags; +- clarification and unsupported-state flags. + +This gives future runtime integration a single object to attach to debug/evidence flow without letting discovery silently become a user-facing answer path. + +Validation: + +- `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts` passed 33/33; +- `npm run build` passed. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js new file mode 100644 index 0000000..dd47380 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js @@ -0,0 +1,75 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = void 0; +exports.runAssistantMcpDiscoveryRuntimeBridge = runAssistantMcpDiscoveryRuntimeBridge; +const assistantMcpDiscoveryAnswerAdapter_1 = require("./assistantMcpDiscoveryAnswerAdapter"); +const assistantMcpDiscoveryPilotExecutor_1 = require("./assistantMcpDiscoveryPilotExecutor"); +const assistantMcpDiscoveryPlanner_1 = require("./assistantMcpDiscoveryPlanner"); +exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_bridge_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 = String(value ?? "").trim(); + if (text && !result.includes(text)) { + result.push(text); + } + } + return result; +} +function bridgeStatusFor(pilot, draft) { + if (draft.answer_mode === "blocked" || pilot.pilot_status === "blocked") { + return "blocked"; + } + if (draft.answer_mode === "needs_clarification" || pilot.pilot_status === "skipped_needs_clarification") { + return "needs_clarification"; + } + if (pilot.pilot_status === "unsupported") { + return "unsupported"; + } + if (draft.answer_mode === "checked_sources_only") { + return "checked_sources_only"; + } + return "answer_draft_ready"; +} +function businessFactAnswerAllowed(draft) { + return draft.answer_mode === "confirmed_with_bounded_inference" || draft.answer_mode === "bounded_inference_only"; +} +async function runAssistantMcpDiscoveryRuntimeBridge(input) { + const planner = (0, assistantMcpDiscoveryPlanner_1.planAssistantMcpDiscovery)({ + semanticDataNeed: input.semanticDataNeed, + turnMeaning: input.turnMeaning + }); + const pilot = await (0, assistantMcpDiscoveryPilotExecutor_1.executeAssistantMcpDiscoveryPilot)(planner, input.deps); + const answerDraft = (0, assistantMcpDiscoveryAnswerAdapter_1.buildAssistantMcpDiscoveryAnswerDraft)(pilot); + const bridgeStatus = bridgeStatusFor(pilot, answerDraft); + const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]); + pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`); + pushReason(reasonCodes, "runtime_bridge_not_wired_to_hot_assistant_answer"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + bridge_status: bridgeStatus, + hot_runtime_wired: false, + planner, + pilot, + answer_draft: answerDraft, + user_facing_response_allowed: bridgeStatus !== "blocked", + business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft), + requires_user_clarification: bridgeStatus === "needs_clarification", + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts new file mode 100644 index 0000000..edc0d15 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts @@ -0,0 +1,124 @@ +import { + buildAssistantMcpDiscoveryAnswerDraft, + type AssistantMcpDiscoveryAnswerDraftContract +} from "./assistantMcpDiscoveryAnswerAdapter"; +import { + executeAssistantMcpDiscoveryPilot, + type AssistantMcpDiscoveryPilotExecutionContract, + type AssistantMcpDiscoveryPilotExecutorDeps +} from "./assistantMcpDiscoveryPilotExecutor"; +import { + planAssistantMcpDiscovery, + type AssistantMcpDiscoveryPlannerContract +} from "./assistantMcpDiscoveryPlanner"; +import type { AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscoveryPolicy"; + +export const ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = + "assistant_mcp_discovery_runtime_bridge_v1" as const; + +export type AssistantMcpDiscoveryRuntimeBridgeStatus = + | "answer_draft_ready" + | "checked_sources_only" + | "needs_clarification" + | "blocked" + | "unsupported"; + +export interface AssistantMcpDiscoveryRuntimeBridgeInput { + semanticDataNeed?: string | null; + turnMeaning?: AssistantMcpDiscoveryTurnMeaningRef | null; + deps?: AssistantMcpDiscoveryPilotExecutorDeps; +} + +export interface AssistantMcpDiscoveryRuntimeBridgeContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryRuntimeBridge"; + bridge_status: AssistantMcpDiscoveryRuntimeBridgeStatus; + hot_runtime_wired: false; + planner: AssistantMcpDiscoveryPlannerContract; + pilot: AssistantMcpDiscoveryPilotExecutionContract; + answer_draft: AssistantMcpDiscoveryAnswerDraftContract; + user_facing_response_allowed: boolean; + business_fact_answer_allowed: boolean; + requires_user_clarification: boolean; + 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 = String(value ?? "").trim(); + if (text && !result.includes(text)) { + result.push(text); + } + } + return result; +} + +function bridgeStatusFor( + pilot: AssistantMcpDiscoveryPilotExecutionContract, + draft: AssistantMcpDiscoveryAnswerDraftContract +): AssistantMcpDiscoveryRuntimeBridgeStatus { + if (draft.answer_mode === "blocked" || pilot.pilot_status === "blocked") { + return "blocked"; + } + if (draft.answer_mode === "needs_clarification" || pilot.pilot_status === "skipped_needs_clarification") { + return "needs_clarification"; + } + if (pilot.pilot_status === "unsupported") { + return "unsupported"; + } + if (draft.answer_mode === "checked_sources_only") { + return "checked_sources_only"; + } + return "answer_draft_ready"; +} + +function businessFactAnswerAllowed(draft: AssistantMcpDiscoveryAnswerDraftContract): boolean { + return draft.answer_mode === "confirmed_with_bounded_inference" || draft.answer_mode === "bounded_inference_only"; +} + +export async function runAssistantMcpDiscoveryRuntimeBridge( + input: AssistantMcpDiscoveryRuntimeBridgeInput +): Promise { + const planner = planAssistantMcpDiscovery({ + semanticDataNeed: input.semanticDataNeed, + turnMeaning: input.turnMeaning + }); + const pilot = await executeAssistantMcpDiscoveryPilot(planner, input.deps); + const answerDraft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + const bridgeStatus = bridgeStatusFor(pilot, answerDraft); + const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]); + + pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`); + pushReason(reasonCodes, "runtime_bridge_not_wired_to_hot_assistant_answer"); + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + bridge_status: bridgeStatus, + hot_runtime_wired: false, + planner, + pilot, + answer_draft: answerDraft, + user_facing_response_allowed: bridgeStatus !== "blocked", + business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft), + requires_user_clarification: bridgeStatus === "needs_clarification", + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts new file mode 100644 index 0000000..4bb2769 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; +import { runAssistantMcpDiscoveryRuntimeBridge } from "../src/services/assistantMcpDiscoveryRuntimeBridge"; + +function buildDeps(rows: Array>, error: string | null = null) { + return { + executeAddressMcpQuery: vi.fn(async () => ({ + fetched_rows: rows.length, + matched_rows: error ? 0 : rows.length, + raw_rows: rows, + rows: error ? [] : rows, + error + })) + }; +} + +describe("assistant MCP discovery runtime bridge", () => { + it("composes planner, pilot executor, and answer draft without wiring the hot runtime", async () => { + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["SVK"] + }, + deps: buildDeps([{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST" }]) + }); + + expect(result.schema_version).toBe("assistant_mcp_discovery_runtime_bridge_v1"); + expect(result.bridge_status).toBe("answer_draft_ready"); + expect(result.hot_runtime_wired).toBe(false); + expect(result.pilot.mcp_execution_performed).toBe(true); + expect(result.answer_draft.answer_mode).toBe("confirmed_with_bounded_inference"); + expect(result.business_fact_answer_allowed).toBe(true); + expect(result.user_facing_response_allowed).toBe(true); + expect(result.reason_codes).toContain("runtime_bridge_not_wired_to_hot_assistant_answer"); + }); + + it("keeps missing scope as clarification and does not authorize a business fact answer", async () => { + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["SVK"] + }, + deps: buildDeps([]) + }); + + expect(result.bridge_status).toBe("needs_clarification"); + expect(result.requires_user_clarification).toBe(true); + expect(result.pilot.mcp_execution_performed).toBe(false); + expect(result.business_fact_answer_allowed).toBe(false); + expect(result.answer_draft.next_step_line).toContain("Уточните контрагента"); + }); + + it("keeps unsupported ready plans outside the hot answer path", async () => { + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + turnMeaning: { + asked_domain_family: "document", + asked_action_family: "documents", + explicit_entity_candidates: ["SVK"] + }, + deps: buildDeps([]) + }); + + expect(result.bridge_status).toBe("unsupported"); + expect(result.hot_runtime_wired).toBe(false); + expect(result.pilot.mcp_execution_performed).toBe(false); + expect(result.business_fact_answer_allowed).toBe(false); + expect(result.reason_codes).toContain("runtime_bridge_status_unsupported"); + }); + + it("preserves the answer adapter boundary against internal mechanics leakage", async () => { + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["SVK"] + }, + deps: buildDeps([{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST" }]) + }); + const userFacing = [ + result.answer_draft.headline, + ...result.answer_draft.confirmed_lines, + ...result.answer_draft.inference_lines, + ...result.answer_draft.unknown_lines, + ...result.answer_draft.limitation_lines, + result.answer_draft.next_step_line ?? "" + ].join("\n"); + + expect(userFacing).not.toContain("query_documents"); + expect(userFacing).not.toContain("runtime_bridge"); + expect(userFacing).not.toContain("primitive"); + }); +});