From 58c1358960edd9e80011f53de50da1d5006e335e Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 20 Apr 2026 11:58:12 +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=20candidate=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=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 | 25 +++ .../assistantMcpDiscoveryResponseCandidate.js | 159 ++++++++++++++ .../assistantMcpDiscoveryResponseCandidate.ts | 204 ++++++++++++++++++ ...stantMcpDiscoveryResponseCandidate.test.ts | 109 ++++++++++ 4 files changed, 497 insertions(+) create mode 100644 llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js create mode 100644 llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts create mode 100644 llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.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 fb451b8..aac2426 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 @@ -945,6 +945,31 @@ 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 assistantMcpDiscoveryDebugAttachment.test.ts assistantAddressOrchestrationRuntimeAdapter.test.ts assistantAddressLaneResponseRuntimeAdapter.test.ts assistantDebugPayloadAssembler.test.ts` passed 61/61; - `npm run build` passed. +## Progress Update - 2026-04-20 MCP Discovery Response Candidate + +The twelfth implementation slice of Big Block 5 added a guarded response-candidate layer: + +- `assistantMcpDiscoveryResponseCandidate.ts` +- `assistantMcpDiscoveryResponseCandidate.test.ts` + +This layer still does not replace the final user-facing assistant answer. + +It converts a validated MCP discovery entry point into a future-use answer candidate: + +- Russian reply text for confirmed, inferred, unknown, limitation, and clarification sections; +- `hot_runtime_wired=false`; +- `eligible_for_future_hot_runtime`; +- `must_keep_internal_mechanics_hidden=true`; +- internal primitive/query/runtime mechanics are filtered out; +- unsupported discovery output is not exposed as a future hot candidate. + +This creates the final guarded buffer needed before any explicit answer replacement policy can be introduced. + +Validation: + +- `npm test -- assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryDebugAttachment.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts` passed 17/17; +- `npm run build` passed. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js new file mode 100644 index 0000000..fae3e00 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -0,0 +1,159 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = void 0; +exports.buildAssistantMcpDiscoveryResponseCandidate = buildAssistantMcpDiscoveryResponseCandidate; +exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = "assistant_mcp_discovery_response_candidate_v1"; +function toRecordObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} +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 []; + } + return value.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item)); +} +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 hasInternalMechanics(value) { + const text = value.toLowerCase(); + return (text.includes("query_documents") || + text.includes("query_movements") || + text.includes("primitive") || + text.includes("runtime_") || + text.includes("planner_") || + text.includes("catalog_") || + text.includes("select ")); +} +function userFacingLines(values) { + return uniqueStrings(values).filter((line) => !hasInternalMechanics(line)); +} +function localizeLine(value) { + const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i); + if (counterpartyMatch) { + return `В 1С найдены строки активности по контрагенту ${counterpartyMatch[1]}.`; + } + if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) { + return "В 1С найдены строки активности по запрошенному контрагентскому контуру."; + } + if (/^Business activity duration may be inferred from first and latest confirmed 1C activity rows$/i.test(value)) { + return "Длительность деловой активности можно оценивать только как вывод по первой и последней подтвержденной строке активности в 1С."; + } + if (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) { + return "Юридическая дата регистрации этим поиском не подтверждена."; + } + return value; +} +function section(title, lines) { + const clean = userFacingLines(lines.map(localizeLine)); + if (clean.length === 0) { + return null; + } + return `${title}\n${clean.map((line) => `- ${line}`).join("\n")}`; +} +function statusFrom(entryPoint) { + if (!entryPoint || entryPoint.entry_status === "skipped_not_applicable") { + return "not_applicable"; + } + if (entryPoint.entry_status === "skipped_needs_more_context") { + return "clarification_candidate"; + } + const bridgeStatus = toNonEmptyString(toRecordObject(entryPoint.bridge)?.bridge_status); + if (bridgeStatus === "answer_draft_ready") { + return "ready_for_guarded_use"; + } + if (bridgeStatus === "needs_clarification") { + return "clarification_candidate"; + } + if (bridgeStatus === "checked_sources_only") { + return "checked_sources_only_candidate"; + } + if (bridgeStatus === "blocked") { + return "blocked"; + } + if (bridgeStatus === "unsupported") { + return "unsupported"; + } + return "not_applicable"; +} +function replyTypeFor(status) { + if (status === "clarification_candidate") { + return "clarification_required"; + } + if (status === "blocked" || status === "unsupported" || status === "not_applicable") { + return "no_grounded_answer"; + } + return "partial_coverage"; +} +function buildReplyText(entryPoint, status) { + const bridge = toRecordObject(entryPoint.bridge); + const draft = toRecordObject(bridge?.answer_draft); + if (!draft) { + if (status === "clarification_candidate") { + return "Нужно уточнить контекст перед поиском в 1С: контрагента, период или организацию."; + } + return null; + } + const blocks = [ + toNonEmptyString(draft.headline) ? `Коротко: ${localizeLine(String(draft.headline))}` : null, + section("Что подтверждено:", toStringList(draft.confirmed_lines)), + section("Что можно сказать только как вывод:", toStringList(draft.inference_lines)), + section("Что не подтверждено:", toStringList(draft.unknown_lines)), + section("Ограничения проверки:", toStringList(draft.limitation_lines)), + toNonEmptyString(draft.next_step_line) ? `Следующий шаг: ${localizeLine(String(draft.next_step_line))}` : null + ].filter((item) => Boolean(item)); + const reply = blocks.join("\n\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; +} +function buildAssistantMcpDiscoveryResponseCandidate(entryPoint) { + const entry = entryPoint ?? null; + const status = statusFrom(entry); + const reasonCodes = uniqueStrings(entry?.reason_codes ?? []); + pushReason(reasonCodes, `mcp_discovery_response_candidate_${status}`); + pushReason(reasonCodes, "mcp_discovery_response_candidate_not_hot_wired"); + const replyText = entry && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate") + ? buildReplyText(entry, status) + : null; + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryResponseCandidate", + candidate_status: replyText ? status : status === "clarification_candidate" ? status : status, + hot_runtime_wired: false, + reply_type: replyTypeFor(status), + reply_text: replyText, + eligible_for_future_hot_runtime: Boolean(replyText) && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate"), + must_keep_internal_mechanics_hidden: true, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts new file mode 100644 index 0000000..4e3ebc9 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -0,0 +1,204 @@ +import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint"; + +export const ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = + "assistant_mcp_discovery_response_candidate_v1" as const; + +export type AssistantMcpDiscoveryResponseCandidateStatus = + | "ready_for_guarded_use" + | "clarification_candidate" + | "checked_sources_only_candidate" + | "not_applicable" + | "blocked" + | "unsupported"; + +export interface AssistantMcpDiscoveryResponseCandidateContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryResponseCandidate"; + candidate_status: AssistantMcpDiscoveryResponseCandidateStatus; + hot_runtime_wired: false; + reply_type: "partial_coverage" | "clarification_required" | "no_grounded_answer"; + reply_text: string | null; + eligible_for_future_hot_runtime: boolean; + must_keep_internal_mechanics_hidden: true; + reason_codes: string[]; +} + +function toRecordObject(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +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 []; + } + return value.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)); +} + +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 hasInternalMechanics(value: string): boolean { + const text = value.toLowerCase(); + return ( + text.includes("query_documents") || + text.includes("query_movements") || + text.includes("primitive") || + text.includes("runtime_") || + text.includes("planner_") || + text.includes("catalog_") || + text.includes("select ") + ); +} + +function userFacingLines(values: string[]): string[] { + return uniqueStrings(values).filter((line) => !hasInternalMechanics(line)); +} + +function localizeLine(value: string): string { + const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i); + if (counterpartyMatch) { + return `В 1С найдены строки активности по контрагенту ${counterpartyMatch[1]}.`; + } + if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) { + return "В 1С найдены строки активности по запрошенному контрагентскому контуру."; + } + if (/^Business activity duration may be inferred from first and latest confirmed 1C activity rows$/i.test(value)) { + return "Длительность деловой активности можно оценивать только как вывод по первой и последней подтвержденной строке активности в 1С."; + } + if (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) { + return "Юридическая дата регистрации этим поиском не подтверждена."; + } + return value; +} + +function section(title: string, lines: string[]): string | null { + const clean = userFacingLines(lines.map(localizeLine)); + if (clean.length === 0) { + return null; + } + return `${title}\n${clean.map((line) => `- ${line}`).join("\n")}`; +} + +function statusFrom(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null): AssistantMcpDiscoveryResponseCandidateStatus { + if (!entryPoint || entryPoint.entry_status === "skipped_not_applicable") { + return "not_applicable"; + } + if (entryPoint.entry_status === "skipped_needs_more_context") { + return "clarification_candidate"; + } + const bridgeStatus = toNonEmptyString(toRecordObject(entryPoint.bridge)?.bridge_status); + if (bridgeStatus === "answer_draft_ready") { + return "ready_for_guarded_use"; + } + if (bridgeStatus === "needs_clarification") { + return "clarification_candidate"; + } + if (bridgeStatus === "checked_sources_only") { + return "checked_sources_only_candidate"; + } + if (bridgeStatus === "blocked") { + return "blocked"; + } + if (bridgeStatus === "unsupported") { + return "unsupported"; + } + return "not_applicable"; +} + +function replyTypeFor( + status: AssistantMcpDiscoveryResponseCandidateStatus +): AssistantMcpDiscoveryResponseCandidateContract["reply_type"] { + if (status === "clarification_candidate") { + return "clarification_required"; + } + if (status === "blocked" || status === "unsupported" || status === "not_applicable") { + return "no_grounded_answer"; + } + return "partial_coverage"; +} + +function buildReplyText(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract, status: AssistantMcpDiscoveryResponseCandidateStatus): string | null { + const bridge = toRecordObject(entryPoint.bridge); + const draft = toRecordObject(bridge?.answer_draft); + if (!draft) { + if (status === "clarification_candidate") { + return "Нужно уточнить контекст перед поиском в 1С: контрагента, период или организацию."; + } + return null; + } + + const blocks = [ + toNonEmptyString(draft.headline) ? `Коротко: ${localizeLine(String(draft.headline))}` : null, + section("Что подтверждено:", toStringList(draft.confirmed_lines)), + section("Что можно сказать только как вывод:", toStringList(draft.inference_lines)), + section("Что не подтверждено:", toStringList(draft.unknown_lines)), + section("Ограничения проверки:", toStringList(draft.limitation_lines)), + toNonEmptyString(draft.next_step_line) ? `Следующий шаг: ${localizeLine(String(draft.next_step_line))}` : null + ].filter((item): item is string => Boolean(item)); + + const reply = blocks.join("\n\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; +} + +export function buildAssistantMcpDiscoveryResponseCandidate( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null | undefined +): AssistantMcpDiscoveryResponseCandidateContract { + const entry = entryPoint ?? null; + const status = statusFrom(entry); + const reasonCodes = uniqueStrings(entry?.reason_codes ?? []); + pushReason(reasonCodes, `mcp_discovery_response_candidate_${status}`); + pushReason(reasonCodes, "mcp_discovery_response_candidate_not_hot_wired"); + + const replyText = + entry && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate") + ? buildReplyText(entry, status) + : null; + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryResponseCandidate", + candidate_status: replyText ? status : status === "clarification_candidate" ? status : status, + hot_runtime_wired: false, + reply_type: replyTypeFor(status), + reply_text: replyText, + eligible_for_future_hot_runtime: + Boolean(replyText) && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate"), + must_keep_internal_mechanics_hidden: true, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts new file mode 100644 index 0000000..03762b0 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { buildAssistantMcpDiscoveryResponseCandidate } from "../src/services/assistantMcpDiscoveryResponseCandidate"; + +function entryPoint(overrides: Record = {}) { + return { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: "bridge_executed", + hot_runtime_wired: false, + discovery_attempted: true, + turn_input: { adapter_status: "ready" }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод.", + confirmed_lines: ["1C activity rows were found for counterparty SVK"], + inference_lines: ["Business activity duration may be inferred from first and latest confirmed 1C activity rows"], + unknown_lines: ["Legal registration date is not proven by this MCP discovery pilot"], + limitation_lines: ["query_documents was skipped internally", "MCP fetch window was limited"], + next_step_line: null + } + }, + reason_codes: ["runtime_entry_point_bridge_executed"], + ...overrides + } as any; +} + +describe("assistant MCP discovery response candidate", () => { + it("builds a Russian guarded candidate from a confirmed discovery draft", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate(entryPoint()); + + expect(candidate.candidate_status).toBe("ready_for_guarded_use"); + expect(candidate.hot_runtime_wired).toBe(false); + expect(candidate.reply_type).toBe("partial_coverage"); + expect(candidate.eligible_for_future_hot_runtime).toBe(true); + expect(candidate.reply_text).toContain("Коротко:"); + expect(candidate.reply_text).toContain("В 1С найдены строки активности по контрагенту SVK."); + expect(candidate.reply_text).toContain("Юридическая дата регистрации этим поиском не подтверждена."); + expect(candidate.reply_text).not.toContain("query_documents"); + expect(candidate.reply_text).not.toContain("primitive"); + }); + + it("returns not applicable when discovery was skipped for an exact supported route", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate({ + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: "skipped_not_applicable", + hot_runtime_wired: false, + discovery_attempted: false, + turn_input: { adapter_status: "not_applicable" }, + bridge: null, + reason_codes: [] + } as any); + + expect(candidate.candidate_status).toBe("not_applicable"); + expect(candidate.reply_text).toBeNull(); + expect(candidate.eligible_for_future_hot_runtime).toBe(false); + }); + + it("creates a clarification candidate from a clarification bridge", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + bridge: { + bridge_status: "needs_clarification", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "needs_clarification", + headline: "Нужно уточнить контекст перед поиском в 1С.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: "Уточните контрагента, период или организацию." + } + } + }) + ); + + expect(candidate.candidate_status).toBe("clarification_candidate"); + expect(candidate.reply_type).toBe("clarification_required"); + expect(candidate.reply_text).toContain("Нужно уточнить контекст"); + expect(candidate.eligible_for_future_hot_runtime).toBe(true); + }); + + it("does not expose unsupported bridge output as a future hot candidate", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + bridge: { + bridge_status: "unsupported", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: false, + answer_draft: null + } + }) + ); + + expect(candidate.candidate_status).toBe("unsupported"); + expect(candidate.reply_type).toBe("no_grounded_answer"); + expect(candidate.reply_text).toBeNull(); + expect(candidate.eligible_for_future_hot_runtime).toBe(false); + }); +});