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 a6bf60a..951bdf0 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 @@ -867,6 +867,30 @@ Validation: - `npm test -- assistantMcpDiscoveryTurnInputAdapter.test.ts assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts` passed 37/37; - `npm run build` passed. +## Progress Update - 2026-04-20 MCP Discovery Runtime Entry Point + +The ninth implementation slice of Big Block 5 added a runtime entry point: + +- `assistantMcpDiscoveryRuntimeEntryPoint.ts` +- `assistantMcpDiscoveryRuntimeEntryPoint.test.ts` + +This entry point is still not wired into the hot assistant answer path. + +It gives runtime integration a single safe call boundary: + +- builds discovery turn input from assistant turn meaning, predecompose contract, and raw wording; +- skips supported exact turns before any discovery execution; +- executes the runtime bridge only for discovery-eligible turns; +- keeps `hot_runtime_wired=false`; +- exposes `discovery_attempted`, `entry_status`, `turn_input`, and optional `bridge`. + +This is the first complete non-hot pipeline from current-turn context to guarded MCP discovery result. + +Validation: + +- `npm test -- assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryTurnInputAdapter.test.ts assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts` passed 40/40; +- `npm run build` passed. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeEntryPoint.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeEntryPoint.js new file mode 100644 index 0000000..85aeca2 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeEntryPoint.js @@ -0,0 +1,81 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION = void 0; +exports.runAssistantMcpDiscoveryRuntimeEntryPoint = runAssistantMcpDiscoveryRuntimeEntryPoint; +const assistantMcpDiscoveryRuntimeBridge_1 = require("./assistantMcpDiscoveryRuntimeBridge"); +const assistantMcpDiscoveryTurnInputAdapter_1 = require("./assistantMcpDiscoveryTurnInputAdapter"); +exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_entry_point_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 skippedContract(input) { + const reasonCodes = uniqueStrings(input.turnInput.reason_codes); + pushReason(reasonCodes, input.reason); + pushReason(reasonCodes, "runtime_entry_point_not_wired_to_hot_assistant_answer"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: input.status, + hot_runtime_wired: false, + discovery_attempted: false, + turn_input: input.turnInput, + bridge: null, + reason_codes: reasonCodes + }; +} +async function runAssistantMcpDiscoveryRuntimeEntryPoint(input) { + const turnInput = (0, assistantMcpDiscoveryTurnInputAdapter_1.buildAssistantMcpDiscoveryTurnInput)(input); + if (!turnInput.should_run_discovery) { + return skippedContract({ + status: "skipped_not_applicable", + turnInput, + reason: "runtime_entry_point_skipped_supported_exact_turn" + }); + } + if (!turnInput.turn_meaning_ref) { + return skippedContract({ + status: "skipped_needs_more_context", + turnInput, + reason: "runtime_entry_point_skipped_missing_discovery_turn_meaning" + }); + } + const bridge = await (0, assistantMcpDiscoveryRuntimeBridge_1.runAssistantMcpDiscoveryRuntimeBridge)({ + semanticDataNeed: turnInput.semantic_data_need, + turnMeaning: turnInput.turn_meaning_ref, + deps: input.deps + }); + const reasonCodes = uniqueStrings([...turnInput.reason_codes, ...bridge.reason_codes]); + pushReason(reasonCodes, "runtime_entry_point_bridge_executed"); + pushReason(reasonCodes, "runtime_entry_point_not_wired_to_hot_assistant_answer"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: "bridge_executed", + hot_runtime_wired: false, + discovery_attempted: true, + turn_input: turnInput, + bridge, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeEntryPoint.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeEntryPoint.ts new file mode 100644 index 0000000..e8b0be8 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeEntryPoint.ts @@ -0,0 +1,121 @@ +import { + runAssistantMcpDiscoveryRuntimeBridge, + type AssistantMcpDiscoveryRuntimeBridgeContract +} from "./assistantMcpDiscoveryRuntimeBridge"; +import { + buildAssistantMcpDiscoveryTurnInput, + type AssistantMcpDiscoveryTurnInputContract, + type BuildAssistantMcpDiscoveryTurnInputAdapterInput +} from "./assistantMcpDiscoveryTurnInputAdapter"; +import type { AssistantMcpDiscoveryPilotExecutorDeps } from "./assistantMcpDiscoveryPilotExecutor"; + +export const ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION = + "assistant_mcp_discovery_runtime_entry_point_v1" as const; + +export type AssistantMcpDiscoveryRuntimeEntryPointStatus = + | "bridge_executed" + | "skipped_not_applicable" + | "skipped_needs_more_context"; + +export interface RunAssistantMcpDiscoveryRuntimeEntryPointInput + extends BuildAssistantMcpDiscoveryTurnInputAdapterInput { + deps?: AssistantMcpDiscoveryPilotExecutorDeps; +} + +export interface AssistantMcpDiscoveryRuntimeEntryPointContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint"; + entry_status: AssistantMcpDiscoveryRuntimeEntryPointStatus; + hot_runtime_wired: false; + discovery_attempted: boolean; + turn_input: AssistantMcpDiscoveryTurnInputContract; + bridge: AssistantMcpDiscoveryRuntimeBridgeContract | 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 = String(value ?? "").trim(); + if (text && !result.includes(text)) { + result.push(text); + } + } + return result; +} + +function skippedContract(input: { + status: Exclude; + turnInput: AssistantMcpDiscoveryTurnInputContract; + reason: string; +}): AssistantMcpDiscoveryRuntimeEntryPointContract { + const reasonCodes = uniqueStrings(input.turnInput.reason_codes); + pushReason(reasonCodes, input.reason); + pushReason(reasonCodes, "runtime_entry_point_not_wired_to_hot_assistant_answer"); + return { + schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: input.status, + hot_runtime_wired: false, + discovery_attempted: false, + turn_input: input.turnInput, + bridge: null, + reason_codes: reasonCodes + }; +} + +export async function runAssistantMcpDiscoveryRuntimeEntryPoint( + input: RunAssistantMcpDiscoveryRuntimeEntryPointInput +): Promise { + const turnInput = buildAssistantMcpDiscoveryTurnInput(input); + if (!turnInput.should_run_discovery) { + return skippedContract({ + status: "skipped_not_applicable", + turnInput, + reason: "runtime_entry_point_skipped_supported_exact_turn" + }); + } + if (!turnInput.turn_meaning_ref) { + return skippedContract({ + status: "skipped_needs_more_context", + turnInput, + reason: "runtime_entry_point_skipped_missing_discovery_turn_meaning" + }); + } + + const bridge = await runAssistantMcpDiscoveryRuntimeBridge({ + semanticDataNeed: turnInput.semantic_data_need, + turnMeaning: turnInput.turn_meaning_ref, + deps: input.deps + }); + const reasonCodes = uniqueStrings([...turnInput.reason_codes, ...bridge.reason_codes]); + pushReason(reasonCodes, "runtime_entry_point_bridge_executed"); + pushReason(reasonCodes, "runtime_entry_point_not_wired_to_hot_assistant_answer"); + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: "bridge_executed", + hot_runtime_wired: false, + discovery_attempted: true, + turn_input: turnInput, + bridge, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts new file mode 100644 index 0000000..c9698b4 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { runAssistantMcpDiscoveryRuntimeEntryPoint } from "../src/services/assistantMcpDiscoveryRuntimeEntryPoint"; + +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 entry point", () => { + it("runs the bridge for discovery-eligible lifecycle turn context", async () => { + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + userMessage: "Сколько лет мы работаем с Группа СВК?", + predecomposeContract: { + entities: { counterparty: "Группа СВК" }, + period: {} + }, + deps: buildDeps([{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST" }]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.hot_runtime_wired).toBe(false); + expect(result.discovery_attempted).toBe(true); + expect(result.turn_input.semantic_data_need).toBe("counterparty lifecycle evidence"); + expect(result.bridge?.bridge_status).toBe("answer_draft_ready"); + expect(result.bridge?.answer_draft.answer_mode).toBe("confirmed_with_bounded_inference"); + expect(result.reason_codes).toContain("runtime_entry_point_bridge_executed"); + }); + + it("skips supported exact turns before any discovery execution", async () => { + const deps = buildDeps([]); + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "list_documents", + explicit_intent_candidate: "list_documents_by_counterparty", + explicit_entity_candidates: [{ value: "SVK" }] + }, + deps + }); + + expect(result.entry_status).toBe("skipped_not_applicable"); + expect(result.discovery_attempted).toBe(false); + expect(result.bridge).toBeNull(); + expect(deps.executeAddressMcpQuery).not.toHaveBeenCalled(); + expect(result.reason_codes).toContain("runtime_entry_point_skipped_supported_exact_turn"); + }); + + it("passes unsupported-but-understood value turns into bridge with normalized entity scope", async () => { + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "counterparty_value_or_turnover", + unsupported_but_understood_family: "counterparty_value_or_turnover", + explicit_entity_candidates: [{ value: "SVK" }] + }, + predecomposeContract: { + entities: { counterparty: "Группа СВК" }, + period: { period_from: "2020-01-01", period_to: "2020-12-31" } + }, + deps: buildDeps([]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.turn_input.turn_meaning_ref?.explicit_entity_candidates).toEqual(["SVK", "Группа СВК"]); + expect(result.bridge?.bridge_status).toBe("unsupported"); + expect(result.bridge?.hot_runtime_wired).toBe(false); + expect(result.reason_codes).toContain("mcp_discovery_unsupported_but_understood_turn"); + }); +});