From 1752d1babf2914a420b25977c4cb263c92e27bcb Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 20 Apr 2026 09:06:40 +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=20MCP=20catalog=20index=20=D0=B4=D0=BB=D1=8F=20dis?= =?UTF-8?q?covery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...alog_authority_recovery_plan_2026-04-19.md | 29 ++ .../dist/services/assistantMcpCatalogIndex.js | 203 ++++++++++++++ .../src/services/assistantMcpCatalogIndex.ts | 251 ++++++++++++++++++ .../tests/assistantMcpCatalogIndex.test.ts | 92 +++++++ 4 files changed, 575 insertions(+) create mode 100644 llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js create mode 100644 llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts create mode 100644 llm_normalizer/backend/tests/assistantMcpCatalogIndex.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 c5a583d..6717607 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 @@ -682,6 +682,35 @@ Validation: - `npm test -- assistantMcpDiscoveryPolicy.test.ts` passed 6/6; - `npm run build` passed. +## Progress Update - 2026-04-20 MCP Catalog Index Seed + +The second implementation slice of Big Block 5 added the first machine-readable catalog brain for guarded MCP discovery: + +- `assistantMcpCatalogIndex.ts` +- `assistantMcpCatalogIndex.test.ts` + +The catalog does not execute 1C/MCP calls yet. + +It records what each reviewed discovery primitive is allowed to mean: + +- business purpose; +- required grounding axes; +- optional axes; +- output fact kinds; +- minimum evidence floor. + +This gives future planner/executor wiring a deterministic policy surface: + +- every discovery primitive must have a catalog contract; +- a discovery plan can be reviewed against catalog-required axes before execution; +- missing axes degrade to `needs_more_axes` instead of a blind query; +- blocked discovery plans remain blocked at catalog review level. + +Validation: + +- `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts` passed 11/11; +- `npm run build` passed. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js b/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js new file mode 100644 index 0000000..7abb71a --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js @@ -0,0 +1,203 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_CATALOG_PLAN_REVIEW_SCHEMA_VERSION = exports.ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION = void 0; +exports.buildAssistantMcpCatalogIndex = buildAssistantMcpCatalogIndex; +exports.getAssistantMcpCatalogPrimitive = getAssistantMcpCatalogPrimitive; +exports.reviewAssistantMcpDiscoveryPlanAgainstCatalog = reviewAssistantMcpDiscoveryPlanAgainstCatalog; +const assistantMcpDiscoveryPolicy_1 = require("./assistantMcpDiscoveryPolicy"); +exports.ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION = "assistant_mcp_catalog_index_v1"; +exports.ASSISTANT_MCP_CATALOG_PLAN_REVIEW_SCHEMA_VERSION = "assistant_mcp_catalog_plan_review_v1"; +const PRIMITIVE_CONTRACTS = [ + { + primitive_id: "search_business_entity", + purpose: "Find candidate 1C business entities by user wording before a fact query is executed.", + required_axes_any_of: [["business_entity"], ["counterparty"], ["organization"], ["contract"], ["item"]], + optional_axes: ["period", "document", "account"], + output_fact_kinds: ["entity_candidates", "entity_ambiguity"], + evidence_floor: "rows_received", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "inspect_1c_metadata", + purpose: "Inspect available 1C schema/catalog/document/register surface before selecting a query lane.", + required_axes_any_of: [["metadata_scope"], ["domain_family"], ["document"], ["register"]], + optional_axes: ["business_entity", "account", "counterparty"], + output_fact_kinds: ["available_fields", "available_entity_sets", "known_limitations"], + evidence_floor: "source_summary", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "resolve_entity_reference", + purpose: "Resolve a user-visible entity name to a concrete 1C reference candidate.", + required_axes_any_of: [["business_entity"], ["counterparty"], ["organization"], ["contract"], ["item"]], + optional_axes: ["period", "inn", "document"], + output_fact_kinds: ["resolved_entity_ref", "entity_conflict"], + evidence_floor: "rows_matched", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "query_movements", + purpose: "Fetch or aggregate accounting/register movements for a scoped business question.", + required_axes_any_of: [["period", "account"], ["period", "counterparty"], ["period", "organization"]], + optional_axes: ["contract", "document", "amount", "item", "warehouse"], + output_fact_kinds: ["movement_rows", "turnover", "balance_delta"], + evidence_floor: "rows_matched", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "query_documents", + purpose: "Fetch documents related to a scoped entity, period, contract, or movement explanation.", + required_axes_any_of: [["document"], ["counterparty"], ["contract"], ["period", "organization"]], + optional_axes: ["account", "amount", "item", "warehouse"], + output_fact_kinds: ["document_rows", "document_dates", "document_amounts"], + evidence_floor: "rows_matched", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "aggregate_by_axis", + purpose: "Aggregate already-scoped 1C evidence by a business axis such as counterparty, contract, or period.", + required_axes_any_of: [["aggregate_axis", "period"], ["aggregate_axis", "counterparty"], ["aggregate_axis", "account"]], + optional_axes: ["organization", "contract", "document", "amount"], + output_fact_kinds: ["aggregate_totals", "ranked_axis_values"], + evidence_floor: "rows_matched", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "drilldown_related_objects", + purpose: "Drill from a known entity or document into related contracts, documents, movements, or payments.", + required_axes_any_of: [["business_entity"], ["document"], ["contract"], ["counterparty"]], + optional_axes: ["period", "account", "amount"], + output_fact_kinds: ["related_objects", "relationship_edges"], + evidence_floor: "rows_received", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "probe_coverage", + purpose: "Check whether the selected MCP/schema route can prove the requested fact or only support a bounded inference.", + required_axes_any_of: [["coverage_target"], ["domain_family"], ["primitive_id"]], + optional_axes: ["period", "organization", "counterparty", "document", "account"], + output_fact_kinds: ["coverage_status", "known_gaps"], + evidence_floor: "source_summary", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "explain_evidence_basis", + purpose: "Produce a machine-readable explanation of which checked MCP evidence supports, limits, or fails the answer.", + required_axes_any_of: [["evidence_basis"], ["primitive_id"], ["source_rows_summary"]], + optional_axes: ["coverage_target", "domain_family"], + output_fact_kinds: ["confirmed_facts", "inferred_facts", "unknown_facts"], + evidence_floor: "source_summary", + safe_for_model_planning: true, + runtime_must_execute: true + } +]; +const PRIMITIVE_CONTRACT_MAP = new Map(PRIMITIVE_CONTRACTS.map((contract) => [contract.primitive_id, contract])); +function toStringSet(values) { + return new Set(values.map((item) => item.trim()).filter((item) => item.length > 0)); +} +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 hasAnyAxisGroup(axisSet, groups) { + return groups.some((group) => group.every((axis) => axisSet.has(axis))); +} +function missingAxisGroups(axisSet, groups) { + return groups.filter((group) => !group.every((axis) => axisSet.has(axis))); +} +function buildAssistantMcpCatalogIndex() { + const reasonCodes = []; + const missingContracts = assistantMcpDiscoveryPolicy_1.ASSISTANT_MCP_DISCOVERY_PRIMITIVES.filter((primitive) => !PRIMITIVE_CONTRACT_MAP.has(primitive)); + if (missingContracts.length > 0) { + pushReason(reasonCodes, "catalog_missing_discovery_primitive_contract"); + } + else { + pushReason(reasonCodes, "catalog_covers_all_discovery_primitives"); + } + return { + schema_version: exports.ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION, + policy_owner: "assistantMcpCatalogIndex", + primitives: PRIMITIVE_CONTRACTS, + reason_codes: reasonCodes + }; +} +function getAssistantMcpCatalogPrimitive(primitive) { + const contract = PRIMITIVE_CONTRACT_MAP.get(primitive); + if (!contract) { + throw new Error(`Missing MCP catalog primitive contract: ${primitive}`); + } + return contract; +} +function reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan) { + const reasonCodes = []; + const axisSet = toStringSet(plan.required_axes); + const reviewedPrimitives = []; + const missingAxesByPrimitive = {}; + const unknownPrimitives = []; + const evidenceFloors = {}; + for (const primitive of plan.allowed_primitives) { + const contract = PRIMITIVE_CONTRACT_MAP.get(primitive); + if (!contract) { + unknownPrimitives.push(primitive); + continue; + } + reviewedPrimitives.push(primitive); + evidenceFloors[primitive] = contract.evidence_floor; + if (!hasAnyAxisGroup(axisSet, contract.required_axes_any_of)) { + missingAxesByPrimitive[primitive] = missingAxisGroups(axisSet, contract.required_axes_any_of); + } + } + if (unknownPrimitives.length > 0) { + pushReason(reasonCodes, "catalog_unknown_primitive_in_discovery_plan"); + } + if (Object.keys(missingAxesByPrimitive).length > 0) { + pushReason(reasonCodes, "catalog_required_axes_missing_for_primitive"); + } + if (plan.plan_status !== "allowed") { + pushReason(reasonCodes, "catalog_review_received_non_allowed_plan"); + } + let reviewStatus = "catalog_compatible"; + if (unknownPrimitives.length > 0 || plan.plan_status === "blocked") { + reviewStatus = "catalog_blocked"; + } + else if (plan.plan_status !== "allowed" || Object.keys(missingAxesByPrimitive).length > 0) { + reviewStatus = "needs_more_axes"; + } + if (reviewStatus === "catalog_compatible") { + pushReason(reasonCodes, "catalog_plan_compatible"); + } + else if (reviewStatus === "catalog_blocked") { + pushReason(reasonCodes, "catalog_plan_blocked"); + } + else { + pushReason(reasonCodes, "catalog_plan_needs_more_axes"); + } + return { + schema_version: exports.ASSISTANT_MCP_CATALOG_PLAN_REVIEW_SCHEMA_VERSION, + policy_owner: "assistantMcpCatalogIndex", + review_status: reviewStatus, + reviewed_primitives: reviewedPrimitives, + missing_axes_by_primitive: missingAxesByPrimitive, + unknown_primitives: unknownPrimitives, + evidence_floors: evidenceFloors, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts b/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts new file mode 100644 index 0000000..0efce0d --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts @@ -0,0 +1,251 @@ +import { + ASSISTANT_MCP_DISCOVERY_PRIMITIVES, + type AssistantMcpDiscoveryPlanContract, + type AssistantMcpDiscoveryPrimitive +} from "./assistantMcpDiscoveryPolicy"; + +export const ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION = "assistant_mcp_catalog_index_v1" as const; +export const ASSISTANT_MCP_CATALOG_PLAN_REVIEW_SCHEMA_VERSION = "assistant_mcp_catalog_plan_review_v1" as const; + +export type AssistantMcpCatalogEvidenceFloor = "none" | "rows_received" | "rows_matched" | "source_summary"; +export type AssistantMcpCatalogPlanReviewStatus = "catalog_compatible" | "needs_more_axes" | "catalog_blocked"; + +export interface AssistantMcpCatalogPrimitiveContract { + primitive_id: AssistantMcpDiscoveryPrimitive; + purpose: string; + required_axes_any_of: string[][]; + optional_axes: string[]; + output_fact_kinds: string[]; + evidence_floor: AssistantMcpCatalogEvidenceFloor; + safe_for_model_planning: true; + runtime_must_execute: true; +} + +export interface AssistantMcpCatalogIndexContract { + schema_version: typeof ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION; + policy_owner: "assistantMcpCatalogIndex"; + primitives: AssistantMcpCatalogPrimitiveContract[]; + reason_codes: string[]; +} + +export interface AssistantMcpCatalogPlanReview { + schema_version: typeof ASSISTANT_MCP_CATALOG_PLAN_REVIEW_SCHEMA_VERSION; + policy_owner: "assistantMcpCatalogIndex"; + review_status: AssistantMcpCatalogPlanReviewStatus; + reviewed_primitives: AssistantMcpDiscoveryPrimitive[]; + missing_axes_by_primitive: Record; + unknown_primitives: string[]; + evidence_floors: Record; + reason_codes: string[]; +} + +const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ + { + primitive_id: "search_business_entity", + purpose: "Find candidate 1C business entities by user wording before a fact query is executed.", + required_axes_any_of: [["business_entity"], ["counterparty"], ["organization"], ["contract"], ["item"]], + optional_axes: ["period", "document", "account"], + output_fact_kinds: ["entity_candidates", "entity_ambiguity"], + evidence_floor: "rows_received", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "inspect_1c_metadata", + purpose: "Inspect available 1C schema/catalog/document/register surface before selecting a query lane.", + required_axes_any_of: [["metadata_scope"], ["domain_family"], ["document"], ["register"]], + optional_axes: ["business_entity", "account", "counterparty"], + output_fact_kinds: ["available_fields", "available_entity_sets", "known_limitations"], + evidence_floor: "source_summary", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "resolve_entity_reference", + purpose: "Resolve a user-visible entity name to a concrete 1C reference candidate.", + required_axes_any_of: [["business_entity"], ["counterparty"], ["organization"], ["contract"], ["item"]], + optional_axes: ["period", "inn", "document"], + output_fact_kinds: ["resolved_entity_ref", "entity_conflict"], + evidence_floor: "rows_matched", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "query_movements", + purpose: "Fetch or aggregate accounting/register movements for a scoped business question.", + required_axes_any_of: [["period", "account"], ["period", "counterparty"], ["period", "organization"]], + optional_axes: ["contract", "document", "amount", "item", "warehouse"], + output_fact_kinds: ["movement_rows", "turnover", "balance_delta"], + evidence_floor: "rows_matched", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "query_documents", + purpose: "Fetch documents related to a scoped entity, period, contract, or movement explanation.", + required_axes_any_of: [["document"], ["counterparty"], ["contract"], ["period", "organization"]], + optional_axes: ["account", "amount", "item", "warehouse"], + output_fact_kinds: ["document_rows", "document_dates", "document_amounts"], + evidence_floor: "rows_matched", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "aggregate_by_axis", + purpose: "Aggregate already-scoped 1C evidence by a business axis such as counterparty, contract, or period.", + required_axes_any_of: [["aggregate_axis", "period"], ["aggregate_axis", "counterparty"], ["aggregate_axis", "account"]], + optional_axes: ["organization", "contract", "document", "amount"], + output_fact_kinds: ["aggregate_totals", "ranked_axis_values"], + evidence_floor: "rows_matched", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "drilldown_related_objects", + purpose: "Drill from a known entity or document into related contracts, documents, movements, or payments.", + required_axes_any_of: [["business_entity"], ["document"], ["contract"], ["counterparty"]], + optional_axes: ["period", "account", "amount"], + output_fact_kinds: ["related_objects", "relationship_edges"], + evidence_floor: "rows_received", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "probe_coverage", + purpose: "Check whether the selected MCP/schema route can prove the requested fact or only support a bounded inference.", + required_axes_any_of: [["coverage_target"], ["domain_family"], ["primitive_id"]], + optional_axes: ["period", "organization", "counterparty", "document", "account"], + output_fact_kinds: ["coverage_status", "known_gaps"], + evidence_floor: "source_summary", + safe_for_model_planning: true, + runtime_must_execute: true + }, + { + primitive_id: "explain_evidence_basis", + purpose: "Produce a machine-readable explanation of which checked MCP evidence supports, limits, or fails the answer.", + required_axes_any_of: [["evidence_basis"], ["primitive_id"], ["source_rows_summary"]], + optional_axes: ["coverage_target", "domain_family"], + output_fact_kinds: ["confirmed_facts", "inferred_facts", "unknown_facts"], + evidence_floor: "source_summary", + safe_for_model_planning: true, + runtime_must_execute: true + } +]; + +const PRIMITIVE_CONTRACT_MAP = new Map( + PRIMITIVE_CONTRACTS.map((contract) => [contract.primitive_id, contract]) +); + +function toStringSet(values: string[]): Set { + return new Set(values.map((item) => item.trim()).filter((item) => item.length > 0)); +} + +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 hasAnyAxisGroup(axisSet: Set, groups: string[][]): boolean { + return groups.some((group) => group.every((axis) => axisSet.has(axis))); +} + +function missingAxisGroups(axisSet: Set, groups: string[][]): string[][] { + return groups.filter((group) => !group.every((axis) => axisSet.has(axis))); +} + +export function buildAssistantMcpCatalogIndex(): AssistantMcpCatalogIndexContract { + const reasonCodes: string[] = []; + const missingContracts = ASSISTANT_MCP_DISCOVERY_PRIMITIVES.filter((primitive) => !PRIMITIVE_CONTRACT_MAP.has(primitive)); + if (missingContracts.length > 0) { + pushReason(reasonCodes, "catalog_missing_discovery_primitive_contract"); + } else { + pushReason(reasonCodes, "catalog_covers_all_discovery_primitives"); + } + return { + schema_version: ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION, + policy_owner: "assistantMcpCatalogIndex", + primitives: PRIMITIVE_CONTRACTS, + reason_codes: reasonCodes + }; +} + +export function getAssistantMcpCatalogPrimitive( + primitive: AssistantMcpDiscoveryPrimitive +): AssistantMcpCatalogPrimitiveContract { + const contract = PRIMITIVE_CONTRACT_MAP.get(primitive); + if (!contract) { + throw new Error(`Missing MCP catalog primitive contract: ${primitive}`); + } + return contract; +} + +export function reviewAssistantMcpDiscoveryPlanAgainstCatalog( + plan: AssistantMcpDiscoveryPlanContract +): AssistantMcpCatalogPlanReview { + const reasonCodes: string[] = []; + const axisSet = toStringSet(plan.required_axes); + const reviewedPrimitives: AssistantMcpDiscoveryPrimitive[] = []; + const missingAxesByPrimitive: Record = {}; + const unknownPrimitives: string[] = []; + const evidenceFloors: Record = {}; + + for (const primitive of plan.allowed_primitives) { + const contract = PRIMITIVE_CONTRACT_MAP.get(primitive); + if (!contract) { + unknownPrimitives.push(primitive); + continue; + } + reviewedPrimitives.push(primitive); + evidenceFloors[primitive] = contract.evidence_floor; + if (!hasAnyAxisGroup(axisSet, contract.required_axes_any_of)) { + missingAxesByPrimitive[primitive] = missingAxisGroups(axisSet, contract.required_axes_any_of); + } + } + + if (unknownPrimitives.length > 0) { + pushReason(reasonCodes, "catalog_unknown_primitive_in_discovery_plan"); + } + if (Object.keys(missingAxesByPrimitive).length > 0) { + pushReason(reasonCodes, "catalog_required_axes_missing_for_primitive"); + } + if (plan.plan_status !== "allowed") { + pushReason(reasonCodes, "catalog_review_received_non_allowed_plan"); + } + + let reviewStatus: AssistantMcpCatalogPlanReviewStatus = "catalog_compatible"; + if (unknownPrimitives.length > 0 || plan.plan_status === "blocked") { + reviewStatus = "catalog_blocked"; + } else if (plan.plan_status !== "allowed" || Object.keys(missingAxesByPrimitive).length > 0) { + reviewStatus = "needs_more_axes"; + } + + if (reviewStatus === "catalog_compatible") { + pushReason(reasonCodes, "catalog_plan_compatible"); + } else if (reviewStatus === "catalog_blocked") { + pushReason(reasonCodes, "catalog_plan_blocked"); + } else { + pushReason(reasonCodes, "catalog_plan_needs_more_axes"); + } + + return { + schema_version: ASSISTANT_MCP_CATALOG_PLAN_REVIEW_SCHEMA_VERSION, + policy_owner: "assistantMcpCatalogIndex", + review_status: reviewStatus, + reviewed_primitives: reviewedPrimitives, + missing_axes_by_primitive: missingAxesByPrimitive, + unknown_primitives: unknownPrimitives, + evidence_floors: evidenceFloors, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts b/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts new file mode 100644 index 0000000..4d21b92 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { ASSISTANT_MCP_DISCOVERY_PRIMITIVES, buildAssistantMcpDiscoveryPlan } from "../src/services/assistantMcpDiscoveryPolicy"; +import { + buildAssistantMcpCatalogIndex, + getAssistantMcpCatalogPrimitive, + reviewAssistantMcpDiscoveryPlanAgainstCatalog +} from "../src/services/assistantMcpCatalogIndex"; + +describe("assistant MCP catalog index", () => { + it("declares a catalog contract for every reviewed discovery primitive", () => { + const index = buildAssistantMcpCatalogIndex(); + const primitiveIds = index.primitives.map((entry) => entry.primitive_id); + + expect(index.reason_codes).toContain("catalog_covers_all_discovery_primitives"); + expect(primitiveIds).toEqual([...ASSISTANT_MCP_DISCOVERY_PRIMITIVES]); + for (const entry of index.primitives) { + expect(entry.safe_for_model_planning).toBe(true); + expect(entry.runtime_must_execute).toBe(true); + expect(entry.required_axes_any_of.length).toBeGreaterThan(0); + expect(entry.output_fact_kinds.length).toBeGreaterThan(0); + } + }); + + it("marks a counterparty turnover discovery plan as catalog-compatible when required axes exist", () => { + const plan = buildAssistantMcpDiscoveryPlan({ + semanticDataNeed: "counterparty turnover evidence", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["SVK"] + }, + proposedPrimitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis"], + requiredAxes: ["counterparty", "period", "aggregate_axis", "amount"] + }); + + const review = reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan); + + expect(review.review_status).toBe("catalog_compatible"); + expect(review.reason_codes).toContain("catalog_plan_compatible"); + expect(review.missing_axes_by_primitive).toEqual({}); + expect(review.evidence_floors.query_movements).toBe("rows_matched"); + }); + + it("asks for more axes before document discovery can run safely", () => { + const plan = buildAssistantMcpDiscoveryPlan({ + semanticDataNeed: "document evidence", + turnMeaning: { + asked_domain_family: "counterparty_documents", + asked_action_family: "list_documents" + }, + proposedPrimitives: ["query_documents"], + requiredAxes: ["amount"] + }); + + const review = reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan); + + expect(review.review_status).toBe("needs_more_axes"); + expect(review.reason_codes).toContain("catalog_required_axes_missing_for_primitive"); + expect(review.missing_axes_by_primitive.query_documents).toEqual([ + ["document"], + ["counterparty"], + ["contract"], + ["period", "organization"] + ]); + }); + + it("preserves source-summary evidence floors for metadata and coverage primitives", () => { + expect(getAssistantMcpCatalogPrimitive("inspect_1c_metadata").evidence_floor).toBe("source_summary"); + expect(getAssistantMcpCatalogPrimitive("probe_coverage").evidence_floor).toBe("source_summary"); + expect(getAssistantMcpCatalogPrimitive("resolve_entity_reference").evidence_floor).toBe("rows_matched"); + }); + + it("turns a non-allowed discovery plan into a catalog-level blocked review", () => { + const plan = buildAssistantMcpDiscoveryPlan({ + semanticDataNeed: "raw model sql", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["SVK"] + }, + proposedPrimitives: ["raw_sql"], + requiredAxes: ["counterparty"] + }); + + const review = reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan); + + expect(plan.plan_status).toBe("blocked"); + expect(review.review_status).toBe("catalog_blocked"); + expect(review.reason_codes).toContain("catalog_review_received_non_allowed_plan"); + expect(review.reason_codes).toContain("catalog_plan_blocked"); + }); +});