From 4181bb9c9b908071829fe5ec74d676d41a949e3e Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 20 Apr 2026 09:48:11 +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=20pilot=20executor=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 | 29 ++ .../assistantMcpDiscoveryPilotExecutor.js | 286 ++++++++++++++ .../assistantMcpDiscoveryPilotExecutor.ts | 363 ++++++++++++++++++ ...assistantMcpDiscoveryPilotExecutor.test.ts | 106 +++++ 4 files changed, 784 insertions(+) create mode 100644 llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js create mode 100644 llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts create mode 100644 llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.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 85639c8..881a8f6 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 @@ -759,6 +759,35 @@ Validation: - `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts` passed 21/21; - `npm run build` passed. +## Progress Update - 2026-04-20 MCP Discovery Pilot Executor + +The fifth implementation slice of Big Block 5 added the first isolated execution pilot: + +- `assistantMcpDiscoveryPilotExecutor.ts` +- `assistantMcpDiscoveryPilotExecutor.test.ts` + +This pilot is still not wired into the hot user-facing assistant runtime. + +It can execute only one intentionally narrow primitive path: + +- `counterparty_lifecycle_query_documents_v1` +- lifecycle/activity-duration turn meaning; +- `query_documents` through injected MCP dependencies; +- all other planned primitives remain skipped in the pilot until dedicated executors exist. + +The pilot preserves the safety rules from the previous layers: + +- it will not execute when dry-run status needs clarification; +- it will not execute unsupported ready plans; +- MCP errors become limitations, not facts; +- legal registration age remains an unknown fact; +- business activity duration may only be treated as a bounded inference from confirmed 1C activity rows. + +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. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js new file mode 100644 index 0000000..975cfa4 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -0,0 +1,286 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION = void 0; +exports.executeAssistantMcpDiscoveryPilot = executeAssistantMcpDiscoveryPilot; +const addressMcpClient_1 = require("./addressMcpClient"); +const assistantMcpDiscoveryRuntimeAdapter_1 = require("./assistantMcpDiscoveryRuntimeAdapter"); +const assistantMcpDiscoveryPolicy_1 = require("./assistantMcpDiscoveryPolicy"); +const addressRecipeCatalog_1 = require("./addressRecipeCatalog"); +exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION = "assistant_mcp_discovery_pilot_executor_v1"; +const DEFAULT_DEPS = { + executeAddressMcpQuery: addressMcpClient_1.executeAddressMcpQuery +}; +function toNonEmptyString(value) { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} +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 pushUnique(target, value) { + const text = value.trim(); + if (text && !target.includes(text)) { + target.push(text); + } +} +function firstEntityCandidate(planner) { + const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; + for (const candidate of candidates) { + const text = toNonEmptyString(candidate); + if (text) { + return text; + } + } + return null; +} +function dateScopeToFilters(dateScope) { + if (!dateScope) { + return {}; + } + const yearMatch = dateScope.match(/^(\d{4})$/); + if (yearMatch) { + return { + period_from: `${yearMatch[1]}-01-01`, + period_to: `${yearMatch[1]}-12-31` + }; + } + const dateMatch = dateScope.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (dateMatch) { + const date = `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`; + return { + period_from: date, + period_to: date + }; + } + return {}; +} +function buildLifecycleFilters(planner) { + const meaning = planner.discovery_plan.turn_meaning_ref; + const counterparty = firstEntityCandidate(planner); + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + return { + ...dateScopeToFilters(dateScope), + ...(counterparty ? { counterparty } : {}), + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} +function isLifecyclePilotEligible(planner) { + const meaning = planner.discovery_plan.turn_meaning_ref; + const domain = String(meaning?.asked_domain_family ?? "").toLowerCase(); + const action = String(meaning?.asked_action_family ?? "").toLowerCase(); + const combined = `${domain} ${action}`; + return (planner.proposed_primitives.includes("query_documents") && + (combined.includes("lifecycle") || combined.includes("activity") || combined.includes("duration") || combined.includes("age"))); +} +function skippedProbeResult(step, limitation) { + return { + primitive_id: step.primitive_id, + status: "skipped", + rows_received: 0, + rows_matched: 0, + limitation + }; +} +function queryResultToProbeResult(primitiveId, result) { + return { + primitive_id: primitiveId, + status: result.error ? "error" : "ok", + rows_received: result.fetched_rows, + rows_matched: result.matched_rows, + limitation: result.error + }; +} +function summarizeRows(result) { + if (result.error) { + return null; + } + if (result.fetched_rows <= 0) { + return "0 MCP document rows fetched"; + } + return `${result.fetched_rows} MCP document rows fetched, ${result.matched_rows} matched lifecycle scope`; +} +function buildConfirmedFacts(result, counterparty) { + if (result.error || result.matched_rows <= 0) { + return []; + } + return [ + counterparty + ? `1C activity rows were found for counterparty ${counterparty}` + : "1C activity rows were found for the requested counterparty scope" + ]; +} +function buildInferredFacts(result) { + if (result.error || result.fetched_rows <= 0) { + return []; + } + return ["Business activity duration may be inferred from first and latest confirmed 1C activity rows"]; +} +function buildUnknownFacts() { + return ["Legal registration date is not proven by this MCP discovery pilot"]; +} +function buildEmptyEvidence(planner, dryRun, probeResults, reason) { + return (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ + plan: planner.discovery_plan, + probeResults, + unknownFacts: [reason], + queryLimitations: [reason], + recommendedNextProbe: dryRun.user_facing_fallback + }); +} +async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { + const dryRun = (0, assistantMcpDiscoveryRuntimeAdapter_1.buildAssistantMcpDiscoveryRuntimeDryRun)(planner); + const reasonCodes = [...dryRun.reason_codes]; + const executedPrimitives = []; + const skippedPrimitives = []; + const probeResults = []; + const queryLimitations = []; + if (dryRun.adapter_status === "blocked") { + pushReason(reasonCodes, "pilot_blocked_before_mcp_execution"); + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "MCP discovery pilot was blocked before execution"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "blocked", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + query_limitations: ["MCP discovery pilot was blocked before execution"], + reason_codes: reasonCodes + }; + } + if (dryRun.adapter_status !== "dry_run_ready") { + pushReason(reasonCodes, "pilot_needs_clarification_before_mcp_execution"); + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "MCP discovery pilot needs more scope before execution"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "skipped_needs_clarification", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + query_limitations: ["MCP discovery pilot needs more scope before execution"], + reason_codes: reasonCodes + }; + } + if (!isLifecyclePilotEligible(planner)) { + pushReason(reasonCodes, "pilot_scope_unsupported_for_live_execution"); + for (const step of dryRun.execution_steps) { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_scope_unsupported_for_live_execution")); + } + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "MCP discovery pilot scope is not implemented yet"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "unsupported", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + query_limitations: ["MCP discovery pilot scope is not implemented yet"], + reason_codes: reasonCodes + }; + } + let queryResult = null; + const counterparty = firstEntityCandidate(planner); + const filters = buildLifecycleFilters(planner); + const selection = (0, addressRecipeCatalog_1.selectAddressRecipe)("counterparty_activity_lifecycle", filters); + if (!selection.selected_recipe) { + pushReason(reasonCodes, "pilot_lifecycle_recipe_not_available"); + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "Lifecycle recipe is not available"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "unsupported", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + query_limitations: ["Lifecycle recipe is not available"], + reason_codes: reasonCodes + }; + } + const recipePlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(selection.selected_recipe, filters); + for (const step of dryRun.execution_steps) { + if (step.primitive_id !== "query_documents") { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_only_executes_query_documents")); + continue; + } + queryResult = await deps.executeAddressMcpQuery({ + query: recipePlan.query, + limit: recipePlan.limit, + account_scope: recipePlan.account_scope + }); + executedPrimitives.push(step.primitive_id); + probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); + if (queryResult.error) { + pushUnique(queryLimitations, queryResult.error); + pushReason(reasonCodes, "pilot_query_documents_mcp_error"); + } + else { + pushReason(reasonCodes, "pilot_query_documents_mcp_executed"); + } + } + const sourceRowsSummary = queryResult ? summarizeRows(queryResult) : null; + const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ + plan: planner.discovery_plan, + probeResults, + confirmedFacts: queryResult ? buildConfirmedFacts(queryResult, counterparty) : [], + inferredFacts: queryResult ? buildInferredFacts(queryResult) : [], + unknownFacts: buildUnknownFacts(), + sourceRowsSummary, + queryLimitations, + recommendedNextProbe: "explain_evidence_basis" + }); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "executed", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: executedPrimitives.length > 0, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: sourceRowsSummary, + query_limitations: queryLimitations, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts new file mode 100644 index 0000000..d28cc14 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -0,0 +1,363 @@ +import { + executeAddressMcpQuery, + type AddressMcpMetadataRowsResult +} from "./addressMcpClient"; +import { + buildAssistantMcpDiscoveryRuntimeDryRun, + type AssistantMcpDiscoveryRuntimeDryRunContract, + type AssistantMcpDiscoveryRuntimeStepContract +} from "./assistantMcpDiscoveryRuntimeAdapter"; +import type { AssistantMcpDiscoveryPlannerContract } from "./assistantMcpDiscoveryPlanner"; +import { + resolveAssistantMcpDiscoveryEvidence, + type AssistantMcpDiscoveryEvidenceContract, + type AssistantMcpDiscoveryProbeResult +} from "./assistantMcpDiscoveryPolicy"; +import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog"; +import type { AddressFilterSet } from "../types/addressQuery"; + +export const ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION = + "assistant_mcp_discovery_pilot_executor_v1" as const; + +export type AssistantMcpDiscoveryPilotStatus = + | "executed" + | "skipped_needs_clarification" + | "blocked" + | "unsupported"; + +export interface AssistantMcpDiscoveryPilotExecutorDeps { + executeAddressMcpQuery: typeof executeAddressMcpQuery; +} + +export interface AssistantMcpDiscoveryPilotExecutionContract { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryPilotExecutor"; + pilot_status: AssistantMcpDiscoveryPilotStatus; + pilot_scope: "counterparty_lifecycle_query_documents_v1"; + dry_run: AssistantMcpDiscoveryRuntimeDryRunContract; + mcp_execution_performed: boolean; + executed_primitives: string[]; + skipped_primitives: string[]; + probe_results: AssistantMcpDiscoveryProbeResult[]; + evidence: AssistantMcpDiscoveryEvidenceContract; + source_rows_summary: string | null; + query_limitations: string[]; + reason_codes: string[]; +} + +type AddressMcpQueryExecutorResult = Awaited>; + +const DEFAULT_DEPS: AssistantMcpDiscoveryPilotExecutorDeps = { + executeAddressMcpQuery +}; + +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 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 pushUnique(target: string[], value: string): void { + const text = value.trim(); + if (text && !target.includes(text)) { + target.push(text); + } +} + +function firstEntityCandidate(planner: AssistantMcpDiscoveryPlannerContract): string | null { + const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; + for (const candidate of candidates) { + const text = toNonEmptyString(candidate); + if (text) { + return text; + } + } + return null; +} + +function dateScopeToFilters(dateScope: string | null): Pick { + if (!dateScope) { + return {}; + } + const yearMatch = dateScope.match(/^(\d{4})$/); + if (yearMatch) { + return { + period_from: `${yearMatch[1]}-01-01`, + period_to: `${yearMatch[1]}-12-31` + }; + } + const dateMatch = dateScope.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (dateMatch) { + const date = `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`; + return { + period_from: date, + period_to: date + }; + } + return {}; +} + +function buildLifecycleFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet { + const meaning = planner.discovery_plan.turn_meaning_ref; + const counterparty = firstEntityCandidate(planner); + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + return { + ...dateScopeToFilters(dateScope), + ...(counterparty ? { counterparty } : {}), + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} + +function isLifecyclePilotEligible(planner: AssistantMcpDiscoveryPlannerContract): boolean { + const meaning = planner.discovery_plan.turn_meaning_ref; + const domain = String(meaning?.asked_domain_family ?? "").toLowerCase(); + const action = String(meaning?.asked_action_family ?? "").toLowerCase(); + const combined = `${domain} ${action}`; + return ( + planner.proposed_primitives.includes("query_documents") && + (combined.includes("lifecycle") || combined.includes("activity") || combined.includes("duration") || combined.includes("age")) + ); +} + +function skippedProbeResult(step: AssistantMcpDiscoveryRuntimeStepContract, limitation: string): AssistantMcpDiscoveryProbeResult { + return { + primitive_id: step.primitive_id, + status: "skipped", + rows_received: 0, + rows_matched: 0, + limitation + }; +} + +function queryResultToProbeResult( + primitiveId: string, + result: AddressMcpQueryExecutorResult +): AssistantMcpDiscoveryProbeResult { + return { + primitive_id: primitiveId, + status: result.error ? "error" : "ok", + rows_received: result.fetched_rows, + rows_matched: result.matched_rows, + limitation: result.error + }; +} + +function summarizeRows(result: AddressMcpQueryExecutorResult): string | null { + if (result.error) { + return null; + } + if (result.fetched_rows <= 0) { + return "0 MCP document rows fetched"; + } + return `${result.fetched_rows} MCP document rows fetched, ${result.matched_rows} matched lifecycle scope`; +} + +function buildConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] { + if (result.error || result.matched_rows <= 0) { + return []; + } + return [ + counterparty + ? `1C activity rows were found for counterparty ${counterparty}` + : "1C activity rows were found for the requested counterparty scope" + ]; +} + +function buildInferredFacts(result: AddressMcpQueryExecutorResult): string[] { + if (result.error || result.fetched_rows <= 0) { + return []; + } + return ["Business activity duration may be inferred from first and latest confirmed 1C activity rows"]; +} + +function buildUnknownFacts(): string[] { + return ["Legal registration date is not proven by this MCP discovery pilot"]; +} + +function buildEmptyEvidence( + planner: AssistantMcpDiscoveryPlannerContract, + dryRun: AssistantMcpDiscoveryRuntimeDryRunContract, + probeResults: AssistantMcpDiscoveryProbeResult[], + reason: string +): AssistantMcpDiscoveryEvidenceContract { + return resolveAssistantMcpDiscoveryEvidence({ + plan: planner.discovery_plan, + probeResults, + unknownFacts: [reason], + queryLimitations: [reason], + recommendedNextProbe: dryRun.user_facing_fallback + }); +} + +export async function executeAssistantMcpDiscoveryPilot( + planner: AssistantMcpDiscoveryPlannerContract, + deps: AssistantMcpDiscoveryPilotExecutorDeps = DEFAULT_DEPS +): Promise { + const dryRun = buildAssistantMcpDiscoveryRuntimeDryRun(planner); + const reasonCodes = [...dryRun.reason_codes]; + const executedPrimitives: string[] = []; + const skippedPrimitives: string[] = []; + const probeResults: AssistantMcpDiscoveryProbeResult[] = []; + const queryLimitations: string[] = []; + + if (dryRun.adapter_status === "blocked") { + pushReason(reasonCodes, "pilot_blocked_before_mcp_execution"); + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "MCP discovery pilot was blocked before execution"); + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "blocked", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + query_limitations: ["MCP discovery pilot was blocked before execution"], + reason_codes: reasonCodes + }; + } + + if (dryRun.adapter_status !== "dry_run_ready") { + pushReason(reasonCodes, "pilot_needs_clarification_before_mcp_execution"); + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "MCP discovery pilot needs more scope before execution"); + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "skipped_needs_clarification", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + query_limitations: ["MCP discovery pilot needs more scope before execution"], + reason_codes: reasonCodes + }; + } + + if (!isLifecyclePilotEligible(planner)) { + pushReason(reasonCodes, "pilot_scope_unsupported_for_live_execution"); + for (const step of dryRun.execution_steps) { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_scope_unsupported_for_live_execution")); + } + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "MCP discovery pilot scope is not implemented yet"); + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "unsupported", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + query_limitations: ["MCP discovery pilot scope is not implemented yet"], + reason_codes: reasonCodes + }; + } + + let queryResult: AddressMcpQueryExecutorResult | null = null; + const counterparty = firstEntityCandidate(planner); + const filters = buildLifecycleFilters(planner); + const selection = selectAddressRecipe("counterparty_activity_lifecycle", filters); + if (!selection.selected_recipe) { + pushReason(reasonCodes, "pilot_lifecycle_recipe_not_available"); + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "Lifecycle recipe is not available"); + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "unsupported", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + query_limitations: ["Lifecycle recipe is not available"], + reason_codes: reasonCodes + }; + } + + const recipePlan = buildAddressRecipePlan(selection.selected_recipe, filters); + for (const step of dryRun.execution_steps) { + if (step.primitive_id !== "query_documents") { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_only_executes_query_documents")); + continue; + } + queryResult = await deps.executeAddressMcpQuery({ + query: recipePlan.query, + limit: recipePlan.limit, + account_scope: recipePlan.account_scope + }); + executedPrimitives.push(step.primitive_id); + probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); + if (queryResult.error) { + pushUnique(queryLimitations, queryResult.error); + pushReason(reasonCodes, "pilot_query_documents_mcp_error"); + } else { + pushReason(reasonCodes, "pilot_query_documents_mcp_executed"); + } + } + + const sourceRowsSummary = queryResult ? summarizeRows(queryResult) : null; + const evidence = resolveAssistantMcpDiscoveryEvidence({ + plan: planner.discovery_plan, + probeResults, + confirmedFacts: queryResult ? buildConfirmedFacts(queryResult, counterparty) : [], + inferredFacts: queryResult ? buildInferredFacts(queryResult) : [], + unknownFacts: buildUnknownFacts(), + sourceRowsSummary, + queryLimitations, + recommendedNextProbe: "explain_evidence_basis" + }); + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "executed", + pilot_scope: "counterparty_lifecycle_query_documents_v1", + dry_run: dryRun, + mcp_execution_performed: executedPrimitives.length > 0, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: sourceRowsSummary, + query_limitations: queryLimitations, + reason_codes: reasonCodes + }; +} + +export type AssistantMcpDiscoveryPilotMetadataResult = AddressMcpMetadataRowsResult; diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts new file mode 100644 index 0000000..7e952f6 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { planAssistantMcpDiscovery } from "../src/services/assistantMcpDiscoveryPlanner"; +import { executeAssistantMcpDiscoveryPilot } from "../src/services/assistantMcpDiscoveryPilotExecutor"; + +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 pilot executor", () => { + it("executes only the lifecycle query_documents primitive through injected MCP deps", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["SVK"] + } + }); + const deps = buildDeps([ + { Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST", Контрагент: "SVK" }, + { Период: "2023-12-20T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY", Контрагент: "SVK" } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("executed"); + expect(result.mcp_execution_performed).toBe(true); + expect(result.executed_primitives).toEqual(["query_documents"]); + expect(result.skipped_primitives).toEqual(["resolve_entity_reference", "probe_coverage", "explain_evidence_basis"]); + expect(result.evidence.evidence_status).toBe("confirmed"); + expect(result.evidence.confirmed_facts[0]).toContain("SVK"); + expect(result.evidence.inferred_facts[0]).toContain("may be inferred"); + expect(result.evidence.unknown_facts).toContain("Legal registration date is not proven by this MCP discovery pilot"); + expect(result.source_rows_summary).toBe("2 MCP document rows fetched, 2 matched lifecycle scope"); + expect(result.reason_codes).toContain("pilot_query_documents_mcp_executed"); + + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(1); + const call = deps.executeAddressMcpQuery.mock.calls[0]?.[0]; + expect(String(call?.query ?? "")).toContain("Документ.ПоступлениеНаРасчетныйСчет"); + expect(call?.limit).toBeGreaterThan(0); + }); + + it("does not execute MCP when dry-run still needs clarification", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["SVK"] + } + }); + const deps = buildDeps([]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("skipped_needs_clarification"); + expect(result.mcp_execution_performed).toBe(false); + expect(result.evidence.evidence_status).toBe("insufficient"); + expect(deps.executeAddressMcpQuery).not.toHaveBeenCalled(); + }); + + it("keeps non-lifecycle ready plans unsupported until a dedicated pilot exists", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_documents", + asked_action_family: "list_documents", + explicit_entity_candidates: ["SVK"] + } + }); + const deps = buildDeps([]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("unsupported"); + expect(result.mcp_execution_performed).toBe(false); + expect(result.skipped_primitives).toEqual(["resolve_entity_reference", "query_documents", "probe_coverage"]); + expect(result.reason_codes).toContain("pilot_scope_unsupported_for_live_execution"); + expect(deps.executeAddressMcpQuery).not.toHaveBeenCalled(); + }); + + it("records MCP errors as limitations without converting them into facts", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["SVK"] + } + }); + const deps = buildDeps([], "MCP fetch failed: timeout"); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("executed"); + expect(result.mcp_execution_performed).toBe(true); + expect(result.evidence.evidence_status).toBe("insufficient"); + expect(result.evidence.confirmed_facts).toEqual([]); + expect(result.query_limitations).toContain("MCP fetch failed: timeout"); + expect(result.reason_codes).toContain("pilot_query_documents_mcp_error"); + }); +});