From 5cd4d459feef3f2e55c573fb01693a501164f09a Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 1 May 2026 11:37:50 +0300 Subject: [PATCH] =?UTF-8?q?Planner=20Autonomy:=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20MCP=20chain=20templates=20=D0=B2=20route?= =?UTF-8?q?=20fabric?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/assistantMcpCatalogIndex.js | 127 +++++++++ .../services/assistantMcpDiscoveryPlanner.js | 227 ++++++++-------- .../src/services/assistantMcpCatalogIndex.ts | 164 ++++++++++++ .../services/assistantMcpDiscoveryPlanner.ts | 253 +++++++++--------- .../tests/assistantMcpCatalogIndex.test.ts | 49 ++++ .../assistantMcpDiscoveryPlanner.test.ts | 11 + 6 files changed, 593 insertions(+), 238 deletions(-) diff --git a/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js b/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js index 6901132..48c24f9 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js +++ b/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js @@ -6,6 +6,7 @@ exports.searchAssistantMcpCatalogPrimitivesByFactAxis = searchAssistantMcpCatalo exports.searchAssistantMcpCatalogPrimitivesByMetadataSurface = searchAssistantMcpCatalogPrimitivesByMetadataSurface; exports.buildAssistantMcpCatalogIndex = buildAssistantMcpCatalogIndex; exports.getAssistantMcpCatalogPrimitive = getAssistantMcpCatalogPrimitive; +exports.getAssistantMcpCatalogChainTemplate = getAssistantMcpCatalogChainTemplate; exports.reviewAssistantMcpDiscoveryPlanAgainstCatalog = reviewAssistantMcpDiscoveryPlanAgainstCatalog; const assistantMcpDiscoveryPolicy_1 = require("./assistantMcpDiscoveryPolicy"); exports.ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION = "assistant_mcp_catalog_index_v1"; @@ -183,6 +184,117 @@ const PRIMITIVE_CONTRACTS = [ } ]; const PRIMITIVE_CONTRACT_MAP = new Map(PRIMITIVE_CONTRACTS.map((contract) => [contract.primitive_id, contract])); +const CHAIN_TEMPLATES = [ + { + chain_id: "metadata_inspection", + semantic_data_need: "1C metadata evidence", + chain_summary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.", + fallback_primitives: ["inspect_1c_metadata"], + base_required_axes: ["metadata_scope"], + supported_fact_families: ["schema_surface"], + supported_action_families: ["inspect_catalog", "inspect_documents", "inspect_registers", "inspect_fields", "inspect_surface"], + planning_tags: ["metadata", "surface_inspection"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "catalog_drilldown", + semantic_data_need: "catalog drilldown metadata evidence", + chain_summary: "Drill deeper into the confirmed catalog-oriented metadata surface, inspect related metadata objects, and keep the next safe lane grounded in checked schema evidence.", + fallback_primitives: ["inspect_1c_metadata"], + base_required_axes: ["metadata_scope"], + supported_fact_families: ["schema_surface"], + supported_action_families: ["inspect_catalog", "inspect_surface"], + planning_tags: ["metadata", "surface_inspection", "drilldown"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "entity_resolution", + semantic_data_need: "entity discovery evidence", + chain_summary: "Search candidate business entities, resolve the most relevant 1C reference, and prove whether the entity grounding is stable enough for the next probe.", + fallback_primitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"], + base_required_axes: ["business_entity", "coverage_target"], + supported_fact_families: ["entity_grounding"], + supported_action_families: ["search_business_entity"], + planning_tags: ["subject_resolution", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "document_evidence", + semantic_data_need: "document evidence", + chain_summary: "Resolve the business entity, fetch scoped document rows, and probe coverage before stating the checked document evidence.", + fallback_primitives: ["resolve_entity_reference", "query_documents", "probe_coverage"], + base_required_axes: ["coverage_target"], + supported_fact_families: ["document_evidence", "activity_lifecycle"], + supported_action_families: ["list_documents", "activity_duration"], + planning_tags: ["document", "subject_resolution", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "movement_evidence", + semantic_data_need: "movement evidence", + chain_summary: "Resolve the business entity, fetch scoped movement rows, and probe coverage without pretending to have a full movement universe.", + fallback_primitives: ["resolve_entity_reference", "query_movements", "probe_coverage"], + base_required_axes: ["coverage_target"], + supported_fact_families: ["movement_evidence", "value_flow"], + supported_action_families: ["list_movements", "turnover", "payout", "net_value_flow"], + planning_tags: ["movement", "subject_resolution", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "value_flow", + semantic_data_need: "counterparty value-flow evidence", + chain_summary: "Resolve the business entity, query scoped movements, aggregate checked amounts, then probe coverage before answering.", + fallback_primitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], + base_required_axes: ["aggregate_axis", "amount", "coverage_target"], + supported_fact_families: ["value_flow"], + supported_action_families: ["turnover", "payout", "net_value_flow"], + planning_tags: ["movement", "aggregation", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "value_flow_comparison", + semantic_data_need: "bidirectional value-flow comparison evidence", + chain_summary: "Query incoming and outgoing movements for the checked period and organization, compare the checked sides, and probe coverage before answering a bounded comparison.", + fallback_primitives: ["query_movements", "probe_coverage"], + base_required_axes: ["amount", "coverage_target"], + supported_fact_families: ["value_flow"], + supported_action_families: ["net_value_flow"], + planning_tags: ["movement", "comparison", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "value_flow_ranking", + semantic_data_need: "ranked value-flow evidence", + chain_summary: "Query scoped movements for the checked period and organization, aggregate checked amounts by counterparty, then probe coverage before answering a bounded ranking.", + fallback_primitives: ["query_movements", "aggregate_by_axis", "probe_coverage"], + base_required_axes: ["aggregate_axis", "amount", "coverage_target"], + supported_fact_families: ["value_flow"], + supported_action_families: ["turnover", "payout"], + planning_tags: ["movement", "ranking", "aggregation", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "lifecycle", + semantic_data_need: "counterparty lifecycle evidence", + chain_summary: "Resolve the business entity, query supporting documents, probe coverage, then explain the evidence basis for the inferred activity window.", + fallback_primitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"], + base_required_axes: ["document_date", "coverage_target", "evidence_basis"], + supported_fact_families: ["activity_lifecycle"], + supported_action_families: ["activity_duration"], + planning_tags: ["document", "explanation", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + } +]; +const CHAIN_TEMPLATE_MAP = new Map(CHAIN_TEMPLATES.map((template) => [template.chain_id, template])); function toStringSet(values) { return new Set(values.map((item) => item.trim()).filter((item) => item.length > 0)); } @@ -516,10 +628,18 @@ function buildAssistantMcpCatalogIndex() { else { pushReason(reasonCodes, "catalog_covers_all_discovery_primitives"); } + const unknownChainPrimitives = CHAIN_TEMPLATES.flatMap((template) => template.fallback_primitives.filter((primitive) => !PRIMITIVE_CONTRACT_MAP.has(primitive))); + if (unknownChainPrimitives.length > 0) { + pushReason(reasonCodes, "catalog_chain_template_references_unknown_primitive"); + } + else { + pushReason(reasonCodes, "catalog_chain_templates_reference_reviewed_primitives"); + } return { schema_version: exports.ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION, policy_owner: "assistantMcpCatalogIndex", primitives: PRIMITIVE_CONTRACTS, + chain_templates: CHAIN_TEMPLATES, reason_codes: reasonCodes }; } @@ -530,6 +650,13 @@ function getAssistantMcpCatalogPrimitive(primitive) { } return contract; } +function getAssistantMcpCatalogChainTemplate(chainId) { + const template = CHAIN_TEMPLATE_MAP.get(chainId); + if (!template) { + throw new Error(`Missing MCP catalog chain template: ${chainId}`); + } + return template; +} function reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan) { const reasonCodes = []; const axisSet = toStringSet(plan.required_axes); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index 894270e..dc72eeb 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -35,6 +35,28 @@ function pushUnique(target, value) { target.push(text); } } +function pushAllUnique(target, values) { + for (const value of values) { + pushUnique(target, value); + } +} +function recipeFromCatalogChainTemplate(input) { + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)(input.chainId); + const axes = [...input.axes]; + pushAllUnique(axes, template.base_required_axes); + return { + semanticDataNeed: input.semanticDataNeed ?? template.semantic_data_need, + chainId: template.chain_id, + chainSummary: input.chainSummary ?? template.chain_summary, + primitives: input.primitives ?? template.fallback_primitives, + axes, + reason: input.reason, + extraReasons: [ + `planner_instantiated_catalog_chain_template_${template.chain_id}`, + ...(input.extraReasons ?? []) + ] + }; +} function hasEntity(meaning) { return (meaning?.explicit_entity_candidates?.length ?? 0) > 0; } @@ -306,60 +328,60 @@ function recipeFor(input) { const thinSurfaceRouteFamily = routeFamilyFromThinMetadataSurfaceInput(input); if (thinSurfaceRouteFamily === "document_evidence") { pushUnique(axes, "coverage_target"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("document_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "document evidence", + return recipeFromCatalogChainTemplate({ chainId: "document_evidence", - chainSummary: "Ground the next checked document lane from the confirmed metadata surface, then fetch scoped document rows and probe coverage before answering.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_document_from_confirmed_metadata_surface_ref", + chainSummary: "Ground the next checked document lane from the confirmed metadata surface, then fetch scoped document rows and probe coverage before answering.", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (thinSurfaceRouteFamily === "movement_evidence") { pushUnique(axes, "coverage_target"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("movement_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_movements", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "movement evidence", + return recipeFromCatalogChainTemplate({ chainId: "movement_evidence", - chainSummary: "Ground the next checked movement lane from the confirmed metadata surface, then fetch scoped movement rows and probe coverage before answering.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_movement_from_confirmed_metadata_surface_ref", + chainSummary: "Ground the next checked movement lane from the confirmed metadata surface, then fetch scoped movement rows and probe coverage before answering.", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (thinSurfaceRouteFamily === "catalog_drilldown") { pushUnique(axes, "metadata_scope"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("catalog_drilldown"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["inspect_1c_metadata"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "catalog drilldown metadata evidence", + return recipeFromCatalogChainTemplate({ chainId: "catalog_drilldown", - chainSummary: "Drill deeper into the confirmed catalog-oriented metadata surface, inspect related metadata objects, and keep the next safe lane grounded in checked schema evidence.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_catalog_drilldown_from_confirmed_metadata_surface_ref", + chainSummary: "Drill deeper into the confirmed catalog-oriented metadata surface, inspect related metadata objects, and keep the next safe lane grounded in checked schema evidence.", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "value_flow") { if (dataNeedGraph?.comparison_need === "incoming_vs_outgoing" && !hasSubjectCandidates(dataNeedGraph)) { @@ -368,47 +390,45 @@ function recipeFor(input) { if (requestedAggregationAxis === "month" || graphAggregation === "by_month") { pushUnique(axes, "calendar_month"); } + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("value_flow_comparison"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["query_movements", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action, allowAggregateByAxis: false }); - return { - semanticDataNeed: "bidirectional value-flow comparison evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow_comparison", - chainSummary: "Query incoming and outgoing movements for the checked period and organization, compare the checked sides, and probe coverage before answering a bounded comparison.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_bidirectional_value_flow_comparison_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (dataNeedGraph?.ranking_need && !hasSubjectCandidates(dataNeedGraph)) { pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("value_flow_ranking"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["query_movements", "aggregate_by_axis", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action, allowAggregateByAxis: true }); - return { - semanticDataNeed: "ranked value-flow evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow_ranking", - chainSummary: "Query scoped movements for the checked period and organization, aggregate checked amounts by counterparty, then probe coverage before answering a bounded ranking.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: dataNeedGraph.ranking_need === "bottom_asc" ? "planner_selected_bottom_ranked_value_flow_from_data_need_graph" : "planner_selected_top_ranked_value_flow_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (openScopeTotalWithoutSubject) { pushUnique(axes, "organization"); @@ -425,17 +445,17 @@ function recipeFor(input) { actionFamily: action, allowAggregateByAxis: true }); - return { - semanticDataNeed: "organization-scoped value-flow evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow", - chainSummary: "Query scoped movements for the checked period and organization without a preselected counterparty, aggregate checked amounts, then probe coverage before answering a bounded total.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, + semanticDataNeed: "organization-scoped value-flow evidence", + chainSummary: "Query scoped movements for the checked period and organization without a preselected counterparty, aggregate checked amounts, then probe coverage before answering a bounded total.", reason: requestedAggregationAxis === "month" || graphAggregation === "by_month" ? "planner_selected_monthly_open_scope_value_flow_total_from_data_need_graph" : "planner_selected_open_scope_value_flow_total_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); @@ -443,70 +463,68 @@ function recipeFor(input) { if (requestedAggregationAxis === "month" || graphAggregation === "by_month") { pushUnique(axes, "calendar_month"); } + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("value_flow"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action, allowAggregateByAxis: true }); - return { - semanticDataNeed: "counterparty value-flow evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow", - chainSummary: "Resolve the business entity, query scoped movements, aggregate checked amounts, then probe coverage before answering.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: requestedAggregationAxis === "month" || graphAggregation === "by_month" ? "planner_selected_monthly_value_flow_from_data_need_graph" : "planner_selected_value_flow_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "activity_lifecycle") { pushUnique(axes, "document_date"); pushUnique(axes, "coverage_target"); pushUnique(axes, "evidence_basis"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("lifecycle"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "counterparty lifecycle evidence", + return recipeFromCatalogChainTemplate({ chainId: "lifecycle", - chainSummary: "Resolve the business entity, query supporting documents, probe coverage, then explain the evidence basis for the inferred activity window.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_lifecycle_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "schema_surface") { pushUnique(axes, "metadata_scope"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("metadata_inspection"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["inspect_1c_metadata"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "1C metadata evidence", + return recipeFromCatalogChainTemplate({ chainId: "metadata_inspection", - chainSummary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_metadata_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "movement_evidence") { if (metadataScopedOpenLane) { pushUnique(axes, "organization"); pushUnique(axes, "coverage_target"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("movement_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, fallbackPrimitives: ["query_movements", "probe_coverage"], @@ -514,38 +532,38 @@ function recipeFor(input) { metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "movement evidence", + return recipeFromCatalogChainTemplate({ chainId: "movement_evidence", - chainSummary: "Keep the metadata-scoped movement lane, ask only for the remaining business scope, then fetch scoped movement rows and probe coverage without pretending there is a grounded counterparty.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_metadata_scoped_movement_from_data_need_graph", + chainSummary: "Keep the metadata-scoped movement lane, ask only for the remaining business scope, then fetch scoped movement rows and probe coverage without pretending there is a grounded counterparty.", + semanticDataNeed: template.semantic_data_need, extraReasons: primitiveSelection.reasonCodes - }; + }); } pushUnique(axes, "coverage_target"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("movement_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_movements", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "movement evidence", + return recipeFromCatalogChainTemplate({ chainId: "movement_evidence", - chainSummary: "Resolve the business entity, fetch scoped movement rows, and probe coverage without pretending to have a full movement universe.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_movement_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "document_evidence") { if (metadataScopedOpenLane) { pushUnique(axes, "organization"); pushUnique(axes, "coverage_target"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("document_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, fallbackPrimitives: ["query_documents", "probe_coverage"], @@ -553,55 +571,53 @@ function recipeFor(input) { metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "document evidence", + return recipeFromCatalogChainTemplate({ chainId: "document_evidence", - chainSummary: "Keep the metadata-scoped document lane, ask only for the remaining business scope, then fetch scoped document rows and probe coverage without pretending there is a grounded counterparty.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_metadata_scoped_document_from_data_need_graph", + chainSummary: "Keep the metadata-scoped document lane, ask only for the remaining business scope, then fetch scoped document rows and probe coverage without pretending there is a grounded counterparty.", + semanticDataNeed: template.semantic_data_need, extraReasons: primitiveSelection.reasonCodes - }; + }); } pushUnique(axes, "coverage_target"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("document_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "document evidence", + return recipeFromCatalogChainTemplate({ chainId: "document_evidence", - chainSummary: "Resolve the business entity, fetch scoped document rows, and probe coverage before stating the checked document evidence.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_document_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "entity_grounding" || (!graphFactFamily && (dataNeedGraph?.subject_candidates.length ?? 0) > 0)) { pushUnique(axes, "business_entity"); pushUnique(axes, "coverage_target"); + const template = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogChainTemplate)("entity_resolution"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "entity discovery evidence", + return recipeFromCatalogChainTemplate({ chainId: "entity_resolution", - chainSummary: "Search candidate business entities, resolve the most relevant 1C reference, and prove whether the entity grounding is stable enough for the next probe.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: graphAction === "search_business_entity" ? "planner_selected_entity_resolution_from_data_need_graph" : "planner_selected_entity_resolution_recipe", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (includesAny(combined, ["metadata_lane_choice_clarification", "resolve_next_lane"])) { pushUnique(axes, "lane_family_choice"); @@ -621,83 +637,64 @@ function recipeFor(input) { if (requestedAggregationAxis === "month") { pushUnique(axes, "calendar_month"); } - return { - semanticDataNeed: "counterparty value-flow evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow", - chainSummary: "Resolve the business entity, query scoped movements, aggregate checked amounts, then probe coverage before answering.", - primitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], axes, reason: requestedAggregationAxis === "month" ? "planner_selected_monthly_value_flow_recipe" : "planner_selected_value_flow_recipe" - }; + }); } if (includesAny(combined, ["lifecycle", "activity", "duration", "age"])) { pushUnique(axes, "document_date"); pushUnique(axes, "coverage_target"); pushUnique(axes, "evidence_basis"); - return { - semanticDataNeed: "counterparty lifecycle evidence", + return recipeFromCatalogChainTemplate({ chainId: "lifecycle", - chainSummary: "Resolve the business entity, query supporting documents, probe coverage, then explain the evidence basis for the inferred activity window.", - primitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"], axes, reason: "planner_selected_lifecycle_recipe" - }; + }); } if (includesAny(combined, ["metadata", "schema", "catalog"])) { pushUnique(axes, "metadata_scope"); - return { - semanticDataNeed: "1C metadata evidence", + return recipeFromCatalogChainTemplate({ chainId: "metadata_inspection", - chainSummary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.", - primitives: ["inspect_1c_metadata"], axes, reason: "planner_selected_metadata_recipe" - }; + }); } if (includesAny(combined, ["movement", "movements", "bank_operations", "movement_evidence", "list_movements"])) { pushUnique(axes, "coverage_target"); - return { - semanticDataNeed: "movement evidence", + return recipeFromCatalogChainTemplate({ chainId: "movement_evidence", - chainSummary: "Resolve the business entity, fetch scoped movement rows, and probe coverage without pretending to have a full movement universe.", - primitives: ["resolve_entity_reference", "query_movements", "probe_coverage"], axes, reason: "planner_selected_movement_recipe" - }; + }); } if (includesAny(combined, ["document", "documents"])) { pushUnique(axes, "coverage_target"); - return { - semanticDataNeed: "document evidence", + return recipeFromCatalogChainTemplate({ chainId: "document_evidence", - chainSummary: "Resolve the business entity, fetch scoped document rows, and probe coverage before stating the checked document evidence.", - primitives: ["resolve_entity_reference", "query_documents", "probe_coverage"], axes, reason: "planner_selected_document_recipe" - }; + }); } if (hasEntity(meaning)) { pushUnique(axes, "business_entity"); pushUnique(axes, "coverage_target"); - return { - semanticDataNeed: "entity discovery evidence", + return recipeFromCatalogChainTemplate({ chainId: "entity_resolution", - chainSummary: "Search candidate business entities, resolve the most relevant 1C reference, and prove whether the entity grounding is stable enough for the next probe.", - primitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"], axes, reason: "planner_selected_entity_resolution_recipe" - }; + }); } - return { - semanticDataNeed: "unclassified 1C discovery need", + return recipeFromCatalogChainTemplate({ chainId: "metadata_inspection", + semanticDataNeed: "unclassified 1C discovery need", chainSummary: "Start with metadata inspection instead of guessing a deeper fact route when the business need is still under-specified.", - primitives: ["inspect_1c_metadata"], axes, reason: "planner_selected_clarification_recipe" - }; + }); } function statusFrom(plan, review) { if (plan.plan_status === "blocked" || review.review_status === "catalog_blocked") { diff --git a/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts b/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts index 609160f..55f4def 100644 --- a/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts +++ b/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts @@ -9,6 +9,16 @@ export const ASSISTANT_MCP_CATALOG_PLAN_REVIEW_SCHEMA_VERSION = "assistant_mcp_c export type AssistantMcpCatalogEvidenceFloor = "none" | "rows_received" | "rows_matched" | "source_summary"; export type AssistantMcpCatalogPlanReviewStatus = "catalog_compatible" | "needs_more_axes" | "catalog_blocked"; +export type AssistantMcpCatalogChainTemplateId = + | "metadata_inspection" + | "catalog_drilldown" + | "value_flow" + | "value_flow_comparison" + | "value_flow_ranking" + | "lifecycle" + | "movement_evidence" + | "document_evidence" + | "entity_resolution"; export interface AssistantMcpCatalogPrimitiveContract { primitive_id: AssistantMcpDiscoveryPrimitive; @@ -29,9 +39,23 @@ export interface AssistantMcpCatalogIndexContract { schema_version: typeof ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION; policy_owner: "assistantMcpCatalogIndex"; primitives: AssistantMcpCatalogPrimitiveContract[]; + chain_templates: AssistantMcpCatalogChainTemplateContract[]; reason_codes: string[]; } +export interface AssistantMcpCatalogChainTemplateContract { + chain_id: AssistantMcpCatalogChainTemplateId; + semantic_data_need: string; + chain_summary: string; + fallback_primitives: AssistantMcpDiscoveryPrimitive[]; + base_required_axes: string[]; + supported_fact_families: string[]; + supported_action_families: string[]; + planning_tags: string[]; + safe_for_model_planning: true; + requires_evidence_gate: true; +} + export interface AssistantMcpCatalogPlanReview { schema_version: typeof ASSISTANT_MCP_CATALOG_PLAN_REVIEW_SCHEMA_VERSION; policy_owner: "assistantMcpCatalogIndex"; @@ -220,6 +244,127 @@ const PRIMITIVE_CONTRACT_MAP = new Map [contract.primitive_id, contract]) ); +const CHAIN_TEMPLATES: AssistantMcpCatalogChainTemplateContract[] = [ + { + chain_id: "metadata_inspection", + semantic_data_need: "1C metadata evidence", + chain_summary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.", + fallback_primitives: ["inspect_1c_metadata"], + base_required_axes: ["metadata_scope"], + supported_fact_families: ["schema_surface"], + supported_action_families: ["inspect_catalog", "inspect_documents", "inspect_registers", "inspect_fields", "inspect_surface"], + planning_tags: ["metadata", "surface_inspection"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "catalog_drilldown", + semantic_data_need: "catalog drilldown metadata evidence", + chain_summary: + "Drill deeper into the confirmed catalog-oriented metadata surface, inspect related metadata objects, and keep the next safe lane grounded in checked schema evidence.", + fallback_primitives: ["inspect_1c_metadata"], + base_required_axes: ["metadata_scope"], + supported_fact_families: ["schema_surface"], + supported_action_families: ["inspect_catalog", "inspect_surface"], + planning_tags: ["metadata", "surface_inspection", "drilldown"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "entity_resolution", + semantic_data_need: "entity discovery evidence", + chain_summary: + "Search candidate business entities, resolve the most relevant 1C reference, and prove whether the entity grounding is stable enough for the next probe.", + fallback_primitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"], + base_required_axes: ["business_entity", "coverage_target"], + supported_fact_families: ["entity_grounding"], + supported_action_families: ["search_business_entity"], + planning_tags: ["subject_resolution", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "document_evidence", + semantic_data_need: "document evidence", + chain_summary: "Resolve the business entity, fetch scoped document rows, and probe coverage before stating the checked document evidence.", + fallback_primitives: ["resolve_entity_reference", "query_documents", "probe_coverage"], + base_required_axes: ["coverage_target"], + supported_fact_families: ["document_evidence", "activity_lifecycle"], + supported_action_families: ["list_documents", "activity_duration"], + planning_tags: ["document", "subject_resolution", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "movement_evidence", + semantic_data_need: "movement evidence", + chain_summary: + "Resolve the business entity, fetch scoped movement rows, and probe coverage without pretending to have a full movement universe.", + fallback_primitives: ["resolve_entity_reference", "query_movements", "probe_coverage"], + base_required_axes: ["coverage_target"], + supported_fact_families: ["movement_evidence", "value_flow"], + supported_action_families: ["list_movements", "turnover", "payout", "net_value_flow"], + planning_tags: ["movement", "subject_resolution", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "value_flow", + semantic_data_need: "counterparty value-flow evidence", + chain_summary: "Resolve the business entity, query scoped movements, aggregate checked amounts, then probe coverage before answering.", + fallback_primitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], + base_required_axes: ["aggregate_axis", "amount", "coverage_target"], + supported_fact_families: ["value_flow"], + supported_action_families: ["turnover", "payout", "net_value_flow"], + planning_tags: ["movement", "aggregation", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "value_flow_comparison", + semantic_data_need: "bidirectional value-flow comparison evidence", + chain_summary: + "Query incoming and outgoing movements for the checked period and organization, compare the checked sides, and probe coverage before answering a bounded comparison.", + fallback_primitives: ["query_movements", "probe_coverage"], + base_required_axes: ["amount", "coverage_target"], + supported_fact_families: ["value_flow"], + supported_action_families: ["net_value_flow"], + planning_tags: ["movement", "comparison", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "value_flow_ranking", + semantic_data_need: "ranked value-flow evidence", + chain_summary: + "Query scoped movements for the checked period and organization, aggregate checked amounts by counterparty, then probe coverage before answering a bounded ranking.", + fallback_primitives: ["query_movements", "aggregate_by_axis", "probe_coverage"], + base_required_axes: ["aggregate_axis", "amount", "coverage_target"], + supported_fact_families: ["value_flow"], + supported_action_families: ["turnover", "payout"], + planning_tags: ["movement", "ranking", "aggregation", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + }, + { + chain_id: "lifecycle", + semantic_data_need: "counterparty lifecycle evidence", + chain_summary: + "Resolve the business entity, query supporting documents, probe coverage, then explain the evidence basis for the inferred activity window.", + fallback_primitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"], + base_required_axes: ["document_date", "coverage_target", "evidence_basis"], + supported_fact_families: ["activity_lifecycle"], + supported_action_families: ["activity_duration"], + planning_tags: ["document", "explanation", "coverage"], + safe_for_model_planning: true, + requires_evidence_gate: true + } +]; + +const CHAIN_TEMPLATE_MAP = new Map( + CHAIN_TEMPLATES.map((template) => [template.chain_id, template]) +); + function toStringSet(values: string[]): Set { return new Set(values.map((item) => item.trim()).filter((item) => item.length > 0)); } @@ -626,10 +771,19 @@ export function buildAssistantMcpCatalogIndex(): AssistantMcpCatalogIndexContrac } else { pushReason(reasonCodes, "catalog_covers_all_discovery_primitives"); } + const unknownChainPrimitives = CHAIN_TEMPLATES.flatMap((template) => + template.fallback_primitives.filter((primitive) => !PRIMITIVE_CONTRACT_MAP.has(primitive)) + ); + if (unknownChainPrimitives.length > 0) { + pushReason(reasonCodes, "catalog_chain_template_references_unknown_primitive"); + } else { + pushReason(reasonCodes, "catalog_chain_templates_reference_reviewed_primitives"); + } return { schema_version: ASSISTANT_MCP_CATALOG_INDEX_SCHEMA_VERSION, policy_owner: "assistantMcpCatalogIndex", primitives: PRIMITIVE_CONTRACTS, + chain_templates: CHAIN_TEMPLATES, reason_codes: reasonCodes }; } @@ -644,6 +798,16 @@ export function getAssistantMcpCatalogPrimitive( return contract; } +export function getAssistantMcpCatalogChainTemplate( + chainId: AssistantMcpCatalogChainTemplateId +): AssistantMcpCatalogChainTemplateContract { + const template = CHAIN_TEMPLATE_MAP.get(chainId); + if (!template) { + throw new Error(`Missing MCP catalog chain template: ${chainId}`); + } + return template; +} + export function reviewAssistantMcpDiscoveryPlanAgainstCatalog( plan: AssistantMcpDiscoveryPlanContract ): AssistantMcpCatalogPlanReview { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index c7e0fac..6276220 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -5,10 +5,12 @@ import { type AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscoveryPolicy"; import { + getAssistantMcpCatalogChainTemplate, searchAssistantMcpCatalogPrimitivesByDecompositionCandidates, searchAssistantMcpCatalogPrimitivesByFactAxis, searchAssistantMcpCatalogPrimitivesByMetadataSurface, reviewAssistantMcpDiscoveryPlanAgainstCatalog, + type AssistantMcpCatalogChainTemplateId, type AssistantMcpCatalogPlanReview } from "./assistantMcpCatalogIndex"; import type { AssistantMcpDiscoveryDataNeedGraphContract } from "./assistantMcpDiscoveryDataNeedGraph"; @@ -119,6 +121,38 @@ function pushUnique(target: string[], value: string): void { } } +function pushAllUnique(target: string[], values: string[]): void { + for (const value of values) { + pushUnique(target, value); + } +} + +function recipeFromCatalogChainTemplate(input: { + chainId: AssistantMcpCatalogChainTemplateId; + axes: string[]; + primitives?: AssistantMcpDiscoveryPrimitive[]; + reason: string; + extraReasons?: string[]; + chainSummary?: string; + semanticDataNeed?: string; +}): PlannerRecipe { + const template = getAssistantMcpCatalogChainTemplate(input.chainId); + const axes = [...input.axes]; + pushAllUnique(axes, template.base_required_axes); + return { + semanticDataNeed: input.semanticDataNeed ?? template.semantic_data_need, + chainId: template.chain_id, + chainSummary: input.chainSummary ?? template.chain_summary, + primitives: input.primitives ?? template.fallback_primitives, + axes, + reason: input.reason, + extraReasons: [ + `planner_instantiated_catalog_chain_template_${template.chain_id}`, + ...(input.extraReasons ?? []) + ] + }; +} + function hasEntity(meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefined): boolean { return (meaning?.explicit_entity_candidates?.length ?? 0) > 0; } @@ -460,63 +494,63 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { const thinSurfaceRouteFamily = routeFamilyFromThinMetadataSurfaceInput(input); if (thinSurfaceRouteFamily === "document_evidence") { pushUnique(axes, "coverage_target"); + const template = getAssistantMcpCatalogChainTemplate("document_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "document evidence", + return recipeFromCatalogChainTemplate({ chainId: "document_evidence", + axes, + primitives: primitiveSelection.primitives, + reason: "planner_selected_document_from_confirmed_metadata_surface_ref", chainSummary: "Ground the next checked document lane from the confirmed metadata surface, then fetch scoped document rows and probe coverage before answering.", - primitives: primitiveSelection.primitives, - axes, - reason: "planner_selected_document_from_confirmed_metadata_surface_ref", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (thinSurfaceRouteFamily === "movement_evidence") { pushUnique(axes, "coverage_target"); + const template = getAssistantMcpCatalogChainTemplate("movement_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_movements", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "movement evidence", + return recipeFromCatalogChainTemplate({ chainId: "movement_evidence", + axes, + primitives: primitiveSelection.primitives, + reason: "planner_selected_movement_from_confirmed_metadata_surface_ref", chainSummary: "Ground the next checked movement lane from the confirmed metadata surface, then fetch scoped movement rows and probe coverage before answering.", - primitives: primitiveSelection.primitives, - axes, - reason: "planner_selected_movement_from_confirmed_metadata_surface_ref", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (thinSurfaceRouteFamily === "catalog_drilldown") { pushUnique(axes, "metadata_scope"); + const template = getAssistantMcpCatalogChainTemplate("catalog_drilldown"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["inspect_1c_metadata"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "catalog drilldown metadata evidence", + return recipeFromCatalogChainTemplate({ chainId: "catalog_drilldown", + axes, + primitives: primitiveSelection.primitives, + reason: "planner_selected_catalog_drilldown_from_confirmed_metadata_surface_ref", chainSummary: "Drill deeper into the confirmed catalog-oriented metadata surface, inspect related metadata objects, and keep the next safe lane grounded in checked schema evidence.", - primitives: primitiveSelection.primitives, - axes, - reason: "planner_selected_catalog_drilldown_from_confirmed_metadata_surface_ref", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "value_flow") { @@ -526,50 +560,46 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { if (requestedAggregationAxis === "month" || graphAggregation === "by_month") { pushUnique(axes, "calendar_month"); } + const template = getAssistantMcpCatalogChainTemplate("value_flow_comparison"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["query_movements", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action, allowAggregateByAxis: false }); - return { - semanticDataNeed: "bidirectional value-flow comparison evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow_comparison", - chainSummary: - "Query incoming and outgoing movements for the checked period and organization, compare the checked sides, and probe coverage before answering a bounded comparison.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_bidirectional_value_flow_comparison_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (dataNeedGraph?.ranking_need && !hasSubjectCandidates(dataNeedGraph)) { pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); + const template = getAssistantMcpCatalogChainTemplate("value_flow_ranking"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["query_movements", "aggregate_by_axis", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action, allowAggregateByAxis: true }); - return { - semanticDataNeed: "ranked value-flow evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow_ranking", - chainSummary: - "Query scoped movements for the checked period and organization, aggregate checked amounts by counterparty, then probe coverage before answering a bounded ranking.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: dataNeedGraph.ranking_need === "bottom_asc" ? "planner_selected_bottom_ranked_value_flow_from_data_need_graph" - : "planner_selected_top_ranked_value_flow_from_data_need_graph", + : "planner_selected_top_ranked_value_flow_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (openScopeTotalWithoutSubject) { pushUnique(axes, "organization"); @@ -586,19 +616,19 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { actionFamily: action, allowAggregateByAxis: true }); - return { - semanticDataNeed: "organization-scoped value-flow evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow", + axes, + primitives: primitiveSelection.primitives, + semanticDataNeed: "organization-scoped value-flow evidence", chainSummary: "Query scoped movements for the checked period and organization without a preselected counterparty, aggregate checked amounts, then probe coverage before answering a bounded total.", - primitives: primitiveSelection.primitives, - axes, reason: requestedAggregationAxis === "month" || graphAggregation === "by_month" ? "planner_selected_monthly_open_scope_value_flow_total_from_data_need_graph" : "planner_selected_open_scope_value_flow_total_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); @@ -606,74 +636,72 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { if (requestedAggregationAxis === "month" || graphAggregation === "by_month") { pushUnique(axes, "calendar_month"); } + const template = getAssistantMcpCatalogChainTemplate("value_flow"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action, allowAggregateByAxis: true }); - return { - semanticDataNeed: "counterparty value-flow evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow", - chainSummary: "Resolve the business entity, query scoped movements, aggregate checked amounts, then probe coverage before answering.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: requestedAggregationAxis === "month" || graphAggregation === "by_month" ? "planner_selected_monthly_value_flow_from_data_need_graph" : "planner_selected_value_flow_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "activity_lifecycle") { pushUnique(axes, "document_date"); pushUnique(axes, "coverage_target"); pushUnique(axes, "evidence_basis"); + const template = getAssistantMcpCatalogChainTemplate("lifecycle"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "counterparty lifecycle evidence", + return recipeFromCatalogChainTemplate({ chainId: "lifecycle", - chainSummary: "Resolve the business entity, query supporting documents, probe coverage, then explain the evidence basis for the inferred activity window.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_lifecycle_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "schema_surface") { pushUnique(axes, "metadata_scope"); + const template = getAssistantMcpCatalogChainTemplate("metadata_inspection"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["inspect_1c_metadata"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "1C metadata evidence", + return recipeFromCatalogChainTemplate({ chainId: "metadata_inspection", - chainSummary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_metadata_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "movement_evidence") { if (metadataScopedOpenLane) { pushUnique(axes, "organization"); pushUnique(axes, "coverage_target"); + const template = getAssistantMcpCatalogChainTemplate("movement_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, fallbackPrimitives: ["query_movements", "probe_coverage"], @@ -681,40 +709,40 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "movement evidence", + return recipeFromCatalogChainTemplate({ chainId: "movement_evidence", + axes, + primitives: primitiveSelection.primitives, + reason: "planner_selected_metadata_scoped_movement_from_data_need_graph", chainSummary: "Keep the metadata-scoped movement lane, ask only for the remaining business scope, then fetch scoped movement rows and probe coverage without pretending there is a grounded counterparty.", - primitives: primitiveSelection.primitives, - axes, - reason: "planner_selected_metadata_scoped_movement_from_data_need_graph", + semanticDataNeed: template.semantic_data_need, extraReasons: primitiveSelection.reasonCodes - }; + }); } pushUnique(axes, "coverage_target"); + const template = getAssistantMcpCatalogChainTemplate("movement_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_movements", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "movement evidence", + return recipeFromCatalogChainTemplate({ chainId: "movement_evidence", - chainSummary: "Resolve the business entity, fetch scoped movement rows, and probe coverage without pretending to have a full movement universe.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_movement_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "document_evidence") { if (metadataScopedOpenLane) { pushUnique(axes, "organization"); pushUnique(axes, "coverage_target"); + const template = getAssistantMcpCatalogChainTemplate("document_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, fallbackPrimitives: ["query_documents", "probe_coverage"], @@ -722,58 +750,56 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "document evidence", + return recipeFromCatalogChainTemplate({ chainId: "document_evidence", + axes, + primitives: primitiveSelection.primitives, + reason: "planner_selected_metadata_scoped_document_from_data_need_graph", chainSummary: "Keep the metadata-scoped document lane, ask only for the remaining business scope, then fetch scoped document rows and probe coverage without pretending there is a grounded counterparty.", - primitives: primitiveSelection.primitives, - axes, - reason: "planner_selected_metadata_scoped_document_from_data_need_graph", + semanticDataNeed: template.semantic_data_need, extraReasons: primitiveSelection.reasonCodes - }; + }); } pushUnique(axes, "coverage_target"); + const template = getAssistantMcpCatalogChainTemplate("document_evidence"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "document evidence", + return recipeFromCatalogChainTemplate({ chainId: "document_evidence", - chainSummary: "Resolve the business entity, fetch scoped document rows, and probe coverage before stating the checked document evidence.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: "planner_selected_document_from_data_need_graph", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (graphFactFamily === "entity_grounding" || (!graphFactFamily && (dataNeedGraph?.subject_candidates.length ?? 0) > 0)) { pushUnique(axes, "business_entity"); pushUnique(axes, "coverage_target"); + const template = getAssistantMcpCatalogChainTemplate("entity_resolution"); const primitiveSelection = selectPrimitivesFromGraphAndCatalog({ dataNeedGraph, - fallbackPrimitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"], + fallbackPrimitives: template.fallback_primitives, requiredAxes: axes, metadataSurface: input.metadataSurface, actionFamily: action }); - return { - semanticDataNeed: "entity discovery evidence", + return recipeFromCatalogChainTemplate({ chainId: "entity_resolution", - chainSummary: "Search candidate business entities, resolve the most relevant 1C reference, and prove whether the entity grounding is stable enough for the next probe.", - primitives: primitiveSelection.primitives, axes, + primitives: primitiveSelection.primitives, reason: graphAction === "search_business_entity" ? "planner_selected_entity_resolution_from_data_need_graph" : "planner_selected_entity_resolution_recipe", extraReasons: primitiveSelection.reasonCodes - }; + }); } if (includesAny(combined, ["metadata_lane_choice_clarification", "resolve_next_lane"])) { @@ -795,89 +821,70 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { if (requestedAggregationAxis === "month") { pushUnique(axes, "calendar_month"); } - return { - semanticDataNeed: "counterparty value-flow evidence", + return recipeFromCatalogChainTemplate({ chainId: "value_flow", - chainSummary: "Resolve the business entity, query scoped movements, aggregate checked amounts, then probe coverage before answering.", - primitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], axes, reason: requestedAggregationAxis === "month" ? "planner_selected_monthly_value_flow_recipe" : "planner_selected_value_flow_recipe" - }; + }); } if (includesAny(combined, ["lifecycle", "activity", "duration", "age"])) { pushUnique(axes, "document_date"); pushUnique(axes, "coverage_target"); pushUnique(axes, "evidence_basis"); - return { - semanticDataNeed: "counterparty lifecycle evidence", + return recipeFromCatalogChainTemplate({ chainId: "lifecycle", - chainSummary: "Resolve the business entity, query supporting documents, probe coverage, then explain the evidence basis for the inferred activity window.", - primitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"], axes, reason: "planner_selected_lifecycle_recipe" - }; + }); } if (includesAny(combined, ["metadata", "schema", "catalog"])) { pushUnique(axes, "metadata_scope"); - return { - semanticDataNeed: "1C metadata evidence", + return recipeFromCatalogChainTemplate({ chainId: "metadata_inspection", - chainSummary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.", - primitives: ["inspect_1c_metadata"], axes, reason: "planner_selected_metadata_recipe" - }; + }); } if (includesAny(combined, ["movement", "movements", "bank_operations", "movement_evidence", "list_movements"])) { pushUnique(axes, "coverage_target"); - return { - semanticDataNeed: "movement evidence", + return recipeFromCatalogChainTemplate({ chainId: "movement_evidence", - chainSummary: "Resolve the business entity, fetch scoped movement rows, and probe coverage without pretending to have a full movement universe.", - primitives: ["resolve_entity_reference", "query_movements", "probe_coverage"], axes, reason: "planner_selected_movement_recipe" - }; + }); } if (includesAny(combined, ["document", "documents"])) { pushUnique(axes, "coverage_target"); - return { - semanticDataNeed: "document evidence", + return recipeFromCatalogChainTemplate({ chainId: "document_evidence", - chainSummary: "Resolve the business entity, fetch scoped document rows, and probe coverage before stating the checked document evidence.", - primitives: ["resolve_entity_reference", "query_documents", "probe_coverage"], axes, reason: "planner_selected_document_recipe" - }; + }); } if (hasEntity(meaning)) { pushUnique(axes, "business_entity"); pushUnique(axes, "coverage_target"); - return { - semanticDataNeed: "entity discovery evidence", + return recipeFromCatalogChainTemplate({ chainId: "entity_resolution", - chainSummary: "Search candidate business entities, resolve the most relevant 1C reference, and prove whether the entity grounding is stable enough for the next probe.", - primitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"], axes, reason: "planner_selected_entity_resolution_recipe" - }; + }); } - return { - semanticDataNeed: "unclassified 1C discovery need", + return recipeFromCatalogChainTemplate({ chainId: "metadata_inspection", + semanticDataNeed: "unclassified 1C discovery need", chainSummary: "Start with metadata inspection instead of guessing a deeper fact route when the business need is still under-specified.", - primitives: ["inspect_1c_metadata"], axes, reason: "planner_selected_clarification_recipe" - }; + }); } function statusFrom( diff --git a/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts b/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts index cf685fa..c8659f4 100644 --- a/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { ASSISTANT_MCP_DISCOVERY_PRIMITIVES, buildAssistantMcpDiscoveryPlan } from "../src/services/assistantMcpDiscoveryPolicy"; import { buildAssistantMcpCatalogIndex, + getAssistantMcpCatalogChainTemplate, getAssistantMcpCatalogPrimitive, reviewAssistantMcpDiscoveryPlanAgainstCatalog, searchAssistantMcpCatalogPrimitivesByDecompositionCandidates, @@ -15,6 +16,7 @@ describe("assistant MCP catalog index", () => { const primitiveIds = index.primitives.map((entry) => entry.primitive_id); expect(index.reason_codes).toContain("catalog_covers_all_discovery_primitives"); + expect(index.reason_codes).toContain("catalog_chain_templates_reference_reviewed_primitives"); expect(primitiveIds).toEqual([...ASSISTANT_MCP_DISCOVERY_PRIMITIVES]); for (const entry of index.primitives) { expect(entry.safe_for_model_planning).toBe(true); @@ -26,6 +28,53 @@ describe("assistant MCP catalog index", () => { expect(entry.required_axes_any_of.length).toBeGreaterThan(0); expect(entry.output_fact_kinds.length).toBeGreaterThan(0); } + expect(index.chain_templates.map((entry) => entry.chain_id)).toEqual([ + "metadata_inspection", + "catalog_drilldown", + "entity_resolution", + "document_evidence", + "movement_evidence", + "value_flow", + "value_flow_comparison", + "value_flow_ranking", + "lifecycle" + ]); + for (const template of index.chain_templates) { + expect(template.safe_for_model_planning).toBe(true); + expect(template.requires_evidence_gate).toBe(true); + expect(template.semantic_data_need.length).toBeGreaterThan(0); + expect(template.chain_summary.length).toBeGreaterThan(0); + expect(template.fallback_primitives.length).toBeGreaterThan(0); + for (const primitive of template.fallback_primitives) { + expect(primitiveIds).toContain(primitive); + } + } + }); + + it("exposes reusable chain templates for planner route-fabric selection", () => { + const documentTemplate = getAssistantMcpCatalogChainTemplate("document_evidence"); + const movementTemplate = getAssistantMcpCatalogChainTemplate("movement_evidence"); + const valueFlowTemplate = getAssistantMcpCatalogChainTemplate("value_flow"); + const valueFlowComparisonTemplate = getAssistantMcpCatalogChainTemplate("value_flow_comparison"); + const valueFlowRankingTemplate = getAssistantMcpCatalogChainTemplate("value_flow_ranking"); + + expect(documentTemplate.fallback_primitives).toEqual([ + "resolve_entity_reference", + "query_documents", + "probe_coverage" + ]); + expect(movementTemplate.fallback_primitives).toEqual([ + "resolve_entity_reference", + "query_movements", + "probe_coverage" + ]); + expect(valueFlowTemplate.base_required_axes).toEqual(["aggregate_axis", "amount", "coverage_target"]); + expect(valueFlowComparisonTemplate.fallback_primitives).toEqual(["query_movements", "probe_coverage"]); + expect(valueFlowRankingTemplate.fallback_primitives).toEqual([ + "query_movements", + "aggregate_by_axis", + "probe_coverage" + ]); }); it("can search reviewed primitives from data-need decomposition candidates", () => { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index 209f802..4088034 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -144,6 +144,7 @@ describe("assistant MCP discovery planner", () => { expect(result.selected_chain_id).toBe("document_evidence"); expect(result.proposed_primitives).toEqual(["resolve_entity_reference", "query_documents", "probe_coverage"]); expect(result.reason_codes).toContain("planner_selected_catalog_primitives_from_fact_axis_search"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_document_evidence"); expect(result.reason_codes).not.toContain("planner_fell_back_to_recipe_primitives_after_empty_catalog_search"); }); @@ -253,6 +254,7 @@ describe("assistant MCP discovery planner", () => { expect(result.required_axes).toEqual(["counterparty", "period", "coverage_target"]); expect(result.reason_codes).toContain("planner_selected_movement_from_data_need_graph"); expect(result.reason_codes).toContain("planner_selected_catalog_primitives_from_decomposition_candidates"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_movement_evidence"); }); it("filters conflicting document-vs-movement primitives when confirmed metadata surface recommends movements", () => { @@ -412,6 +414,7 @@ describe("assistant MCP discovery planner", () => { expect(result.planner_status).toBe("ready_for_execution"); expect(result.selected_chain_id).toBe("entity_resolution"); expect(result.reason_codes).toContain("planner_selected_entity_resolution_recipe"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_entity_resolution"); expect(result.reason_codes).not.toContain("planner_selected_document_from_confirmed_metadata_surface_ref"); expect(result.reason_codes).not.toContain("planner_selected_movement_from_confirmed_metadata_surface_ref"); }); @@ -464,6 +467,7 @@ describe("assistant MCP discovery planner", () => { "calendar_month" ]); expect(result.reason_codes).toContain("planner_selected_monthly_value_flow_from_data_need_graph"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow"); }); it("does not collapse a ranking-shaped value graph into entity-resolution just because no subject is preselected", () => { @@ -497,6 +501,7 @@ describe("assistant MCP discovery planner", () => { expect(result.required_axes).toEqual(["period", "aggregate_axis", "amount", "coverage_target"]); expect(result.catalog_review.review_status).toBe("needs_more_axes"); expect(result.reason_codes).toContain("planner_selected_top_ranked_value_flow_from_data_need_graph"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow_ranking"); expect(result.selected_chain_id).not.toBe("entity_resolution"); }); @@ -532,6 +537,7 @@ describe("assistant MCP discovery planner", () => { expect(result.required_axes).toEqual(["organization", "period", "aggregate_axis", "amount", "coverage_target"]); expect(result.catalog_review.review_status).toBe("catalog_compatible"); expect(result.reason_codes).toContain("planner_selected_top_ranked_value_flow_from_data_need_graph"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow_ranking"); }); it("does not collapse incoming-vs-outgoing comparison into entity-resolution when no counterparty is preselected", () => { @@ -564,6 +570,7 @@ describe("assistant MCP discovery planner", () => { expect(result.proposed_primitives).toEqual(["query_movements", "probe_coverage"]); expect(result.required_axes).toEqual(["period", "amount", "coverage_target"]); expect(result.reason_codes).toContain("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow_comparison"); expect(result.selected_chain_id).not.toBe("entity_resolution"); }); @@ -599,6 +606,7 @@ describe("assistant MCP discovery planner", () => { expect(result.required_axes).toEqual(["organization", "period", "amount", "coverage_target"]); expect(result.catalog_review.review_status).toBe("catalog_compatible"); expect(result.reason_codes).toContain("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow_comparison"); }); it("builds an inference-safe lifecycle plan with evidence explanation", () => { @@ -740,6 +748,7 @@ describe("assistant MCP discovery planner", () => { expect(result.required_axes).toEqual(["organization", "period", "aggregate_axis", "amount", "coverage_target"]); expect(result.catalog_review.review_status).toBe("catalog_compatible"); expect(result.reason_codes).toContain("planner_selected_open_scope_value_flow_total_from_data_need_graph"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow"); }); it("keeps generic one-sided open totals in organization clarification instead of forcing entity resolution", () => { const result = planAssistantMcpDiscovery({ @@ -777,6 +786,7 @@ describe("assistant MCP discovery planner", () => { expect(result.catalog_review.review_status).toBe("needs_more_axes"); expect(result.catalog_review.missing_axes_by_primitive.query_movements).toContainEqual(["organization"]); expect(result.reason_codes).toContain("planner_selected_open_scope_value_flow_total_from_data_need_graph"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow"); expect(result.selected_chain_id).not.toBe("entity_resolution"); }); @@ -822,6 +832,7 @@ describe("assistant MCP discovery planner", () => { ]); expect(result.catalog_review.review_status).toBe("catalog_compatible"); expect(result.reason_codes).toContain("planner_selected_open_scope_value_flow_total_from_data_need_graph"); + expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow"); }); it("keeps metadata-scoped movement evidence in clarification instead of forcing entity resolution", () => {