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 881a8f6..43245a8 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 @@ -788,6 +788,36 @@ Validation: - `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts` passed 25/25; - `npm run build` passed. +## Progress Update - 2026-04-20 MCP Discovery Answer Adapter + +The sixth implementation slice of Big Block 5 added a human-safe answer draft adapter: + +- `assistantMcpDiscoveryAnswerAdapter.ts` +- `assistantMcpDiscoveryAnswerAdapter.test.ts` + +This adapter is still not wired into the hot assistant runtime. + +It converts pilot evidence into an answer draft that can later be consumed by the final answer layer: + +- confirmed lines; +- bounded inference lines; +- unknown fact boundaries; +- user-facing limitations; +- next-step guidance; +- `must_not_claim` constraints. + +The adapter explicitly blocks internal mechanics from user-facing lines: + +- MCP primitive names; +- query text; +- debug/reason mechanics; +- raw runtime/planner/catalog codes. + +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. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js new file mode 100644 index 0000000..2f78692 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -0,0 +1,124 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = void 0; +exports.buildAssistantMcpDiscoveryAnswerDraft = buildAssistantMcpDiscoveryAnswerDraft; +exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = "assistant_mcp_discovery_answer_draft_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 isInternalMechanicsLine(value) { + const text = value.toLowerCase(); + return (text.includes("primitive") || + text.includes("query_documents") || + text.includes("query_movements") || + text.includes("resolve_entity_reference") || + text.includes("probe_coverage") || + text.includes("explain_evidence_basis") || + text.includes("pilot_only_executes") || + text.includes("runtime_") || + text.includes("planner_") || + text.includes("catalog_")); +} +function userFacingLimitations(values) { + return uniqueStrings(values).filter((value) => !isInternalMechanicsLine(value)); +} +function modeFor(pilot) { + if (pilot.pilot_status === "blocked") { + return "blocked"; + } + if (pilot.pilot_status === "skipped_needs_clarification") { + return "needs_clarification"; + } + if (pilot.evidence.answer_permission === "confirmed_answer") { + return "confirmed_with_bounded_inference"; + } + if (pilot.evidence.answer_permission === "bounded_inference") { + return "bounded_inference_only"; + } + return "checked_sources_only"; +} +function headlineFor(mode) { + if (mode === "confirmed_with_bounded_inference") { + return "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод из этих строк."; + } + if (mode === "bounded_inference_only") { + return "Точный факт не подтвержден, но есть ограниченная оценка по найденной активности в 1С."; + } + if (mode === "needs_clarification") { + return "Нужно уточнить контекст перед поиском в 1С."; + } + if (mode === "blocked") { + return "Поиск в 1С заблокирован runtime-политикой до выполнения."; + } + return "Я проверил доступный контур, но подтвержденного факта для ответа не получил."; +} +function nextStepFor(mode, pilot) { + if (mode === "needs_clarification") { + return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С."; + } + if (mode === "checked_sources_only" && pilot.query_limitations.length > 0) { + return "Можно повторить проверку после восстановления MCP-доступа или сузить вопрос до конкретного контрагента/периода."; + } + if (mode === "blocked") { + return "Нужно сначала снять policy/blocking причину, иначе данные 1С использовать нельзя."; + } + return null; +} +function buildMustNotClaim(pilot) { + const claims = [ + "Do not claim legal registration age unless a legal registration source is confirmed.", + "Do not present inferred activity duration as a formally confirmed legal fact.", + "Do not expose MCP primitive names, query text, debug ids, or internal execution mechanics in the user answer.", + "Do not claim rows were checked when mcp_execution_performed=false." + ]; + if (pilot.evidence.confirmed_facts.length === 0) { + claims.push("Do not claim a confirmed business fact when confirmed_facts is empty."); + } + return claims; +} +function buildAssistantMcpDiscoveryAnswerDraft(pilot) { + const mode = modeFor(pilot); + const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes]; + pushReason(reasonCodes, `answer_mode_${mode}`); + if (pilot.evidence.unknown_facts.length > 0) { + pushReason(reasonCodes, "answer_contains_unknown_fact_boundary"); + } + if (pilot.evidence.inferred_facts.length > 0) { + pushReason(reasonCodes, "answer_contains_bounded_inference"); + } + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryAnswerAdapter", + answer_mode: mode, + headline: headlineFor(mode), + confirmed_lines: uniqueStrings(pilot.evidence.confirmed_facts), + inference_lines: uniqueStrings(pilot.evidence.inferred_facts), + unknown_lines: uniqueStrings(pilot.evidence.unknown_facts), + limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]), + next_step_line: nextStepFor(mode, pilot), + internal_mechanics_allowed: false, + must_not_claim: buildMustNotClaim(pilot), + reason_codes: uniqueStrings(reasonCodes) + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts new file mode 100644 index 0000000..5c2fe2f --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -0,0 +1,160 @@ +import type { AssistantMcpDiscoveryPilotExecutionContract } from "./assistantMcpDiscoveryPilotExecutor"; + +export const ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = + "assistant_mcp_discovery_answer_draft_v1" as const; + +export type AssistantMcpDiscoveryAnswerMode = + | "confirmed_with_bounded_inference" + | "bounded_inference_only" + | "checked_sources_only" + | "needs_clarification" + | "blocked"; + +export interface AssistantMcpDiscoveryAnswerDraftContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryAnswerAdapter"; + answer_mode: AssistantMcpDiscoveryAnswerMode; + headline: string; + confirmed_lines: string[]; + inference_lines: string[]; + unknown_lines: string[]; + limitation_lines: string[]; + next_step_line: string | null; + internal_mechanics_allowed: false; + must_not_claim: string[]; + 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 isInternalMechanicsLine(value: string): boolean { + const text = value.toLowerCase(); + return ( + text.includes("primitive") || + text.includes("query_documents") || + text.includes("query_movements") || + text.includes("resolve_entity_reference") || + text.includes("probe_coverage") || + text.includes("explain_evidence_basis") || + text.includes("pilot_only_executes") || + text.includes("runtime_") || + text.includes("planner_") || + text.includes("catalog_") + ); +} + +function userFacingLimitations(values: string[]): string[] { + return uniqueStrings(values).filter((value) => !isInternalMechanicsLine(value)); +} + +function modeFor(pilot: AssistantMcpDiscoveryPilotExecutionContract): AssistantMcpDiscoveryAnswerMode { + if (pilot.pilot_status === "blocked") { + return "blocked"; + } + if (pilot.pilot_status === "skipped_needs_clarification") { + return "needs_clarification"; + } + if (pilot.evidence.answer_permission === "confirmed_answer") { + return "confirmed_with_bounded_inference"; + } + if (pilot.evidence.answer_permission === "bounded_inference") { + return "bounded_inference_only"; + } + return "checked_sources_only"; +} + +function headlineFor(mode: AssistantMcpDiscoveryAnswerMode): string { + if (mode === "confirmed_with_bounded_inference") { + return "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод из этих строк."; + } + if (mode === "bounded_inference_only") { + return "Точный факт не подтвержден, но есть ограниченная оценка по найденной активности в 1С."; + } + if (mode === "needs_clarification") { + return "Нужно уточнить контекст перед поиском в 1С."; + } + if (mode === "blocked") { + return "Поиск в 1С заблокирован runtime-политикой до выполнения."; + } + return "Я проверил доступный контур, но подтвержденного факта для ответа не получил."; +} + +function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { + if (mode === "needs_clarification") { + return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С."; + } + if (mode === "checked_sources_only" && pilot.query_limitations.length > 0) { + return "Можно повторить проверку после восстановления MCP-доступа или сузить вопрос до конкретного контрагента/периода."; + } + if (mode === "blocked") { + return "Нужно сначала снять policy/blocking причину, иначе данные 1С использовать нельзя."; + } + return null; +} + +function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] { + const claims = [ + "Do not claim legal registration age unless a legal registration source is confirmed.", + "Do not present inferred activity duration as a formally confirmed legal fact.", + "Do not expose MCP primitive names, query text, debug ids, or internal execution mechanics in the user answer.", + "Do not claim rows were checked when mcp_execution_performed=false." + ]; + if (pilot.evidence.confirmed_facts.length === 0) { + claims.push("Do not claim a confirmed business fact when confirmed_facts is empty."); + } + return claims; +} + +export function buildAssistantMcpDiscoveryAnswerDraft( + pilot: AssistantMcpDiscoveryPilotExecutionContract +): AssistantMcpDiscoveryAnswerDraftContract { + const mode = modeFor(pilot); + const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes]; + pushReason(reasonCodes, `answer_mode_${mode}`); + if (pilot.evidence.unknown_facts.length > 0) { + pushReason(reasonCodes, "answer_contains_unknown_fact_boundary"); + } + if (pilot.evidence.inferred_facts.length > 0) { + pushReason(reasonCodes, "answer_contains_bounded_inference"); + } + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryAnswerAdapter", + answer_mode: mode, + headline: headlineFor(mode), + confirmed_lines: uniqueStrings(pilot.evidence.confirmed_facts), + inference_lines: uniqueStrings(pilot.evidence.inferred_facts), + unknown_lines: uniqueStrings(pilot.evidence.unknown_facts), + limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]), + next_step_line: nextStepFor(mode, pilot), + internal_mechanics_allowed: false, + must_not_claim: buildMustNotClaim(pilot), + reason_codes: uniqueStrings(reasonCodes) + }; +} diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts new file mode 100644 index 0000000..3f3f534 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildAssistantMcpDiscoveryAnswerDraft } from "../src/services/assistantMcpDiscoveryAnswerAdapter"; +import { executeAssistantMcpDiscoveryPilot } from "../src/services/assistantMcpDiscoveryPilotExecutor"; +import { planAssistantMcpDiscovery } from "../src/services/assistantMcpDiscoveryPlanner"; + +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 answer adapter", () => { + it("turns confirmed lifecycle evidence into a human-safe bounded answer draft", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["SVK"] + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildDeps([{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST", Контрагент: "SVK" }]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); + expect(draft.internal_mechanics_allowed).toBe(false); + expect(draft.headline).toContain("подтвержденная активность"); + expect(draft.confirmed_lines[0]).toContain("SVK"); + expect(draft.inference_lines[0]).toContain("may be inferred"); + expect(draft.unknown_lines).toContain("Legal registration date is not proven by this MCP discovery pilot"); + expect(draft.must_not_claim).toContain("Do not present inferred activity duration as a formally confirmed legal fact."); + expect(draft.reason_codes).toContain("answer_contains_unknown_fact_boundary"); + }); + + it("uses checked-sources mode when MCP failed and avoids confirmed facts", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["SVK"] + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([], "MCP fetch failed: timeout")); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.answer_mode).toBe("checked_sources_only"); + expect(draft.confirmed_lines).toEqual([]); + expect(draft.limitation_lines).toContain("MCP fetch failed: timeout"); + expect(draft.next_step_line).toContain("MCP"); + expect(draft.must_not_claim).toContain("Do not claim a confirmed business fact when confirmed_facts is empty."); + }); + + it("asks for clarification when discovery did not execute due to missing scope", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["SVK"] + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([])); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.answer_mode).toBe("needs_clarification"); + expect(draft.headline).toBe("Нужно уточнить контекст перед поиском в 1С."); + expect(draft.next_step_line).toContain("Уточните контрагента"); + expect(draft.must_not_claim).toContain("Do not claim rows were checked when mcp_execution_performed=false."); + }); + + it("does not leak primitive names or query text into user-facing lines", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["SVK"] + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildDeps([{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST", Контрагент: "SVK" }]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + const userFacing = [ + draft.headline, + ...draft.confirmed_lines, + ...draft.inference_lines, + ...draft.unknown_lines, + ...draft.limitation_lines, + draft.next_step_line ?? "" + ].join("\n"); + + expect(userFacing).not.toContain("query_documents"); + expect(userFacing).not.toContain("SELECT"); + expect(userFacing).not.toContain("ВЫБРАТЬ"); + expect(userFacing).not.toContain("primitive"); + }); +});