From 8b6cd4c3298472eea2e654d421157279c18d8a53 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 22 Apr 2026 22:47:22 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20catalog=20primitive=20search=20?= =?UTF-8?q?=D0=BA=20data-need=20planner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/assistantMcpCatalogIndex.js | 42 ++++++ .../assistantMcpDiscoveryAnswerAdapter.js | 14 +- .../services/assistantMcpDiscoveryPlanner.js | 119 +++++++++++++--- .../src/services/assistantMcpCatalogIndex.ts | 56 ++++++++ .../assistantMcpDiscoveryAnswerAdapter.ts | 14 +- .../services/assistantMcpDiscoveryPlanner.ts | 130 +++++++++++++++--- .../tests/assistantMcpCatalogIndex.test.ts | 36 ++++- ...assistantMcpDiscoveryAnswerAdapter.test.ts | 2 +- .../assistantMcpDiscoveryPlanner.test.ts | 2 + ...assistantMcpDiscoveryRuntimeBridge.test.ts | 2 +- 10 files changed, 364 insertions(+), 53 deletions(-) diff --git a/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js b/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js index 7abb71a..f2d5c9e 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js +++ b/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js @@ -1,6 +1,7 @@ "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.searchAssistantMcpCatalogPrimitivesByDecompositionCandidates = searchAssistantMcpCatalogPrimitivesByDecompositionCandidates; exports.buildAssistantMcpCatalogIndex = buildAssistantMcpCatalogIndex; exports.getAssistantMcpCatalogPrimitive = getAssistantMcpCatalogPrimitive; exports.reviewAssistantMcpDiscoveryPlanAgainstCatalog = reviewAssistantMcpDiscoveryPlanAgainstCatalog; @@ -11,6 +12,7 @@ const PRIMITIVE_CONTRACTS = [ { primitive_id: "search_business_entity", purpose: "Find candidate 1C business entities by user wording before a fact query is executed.", + decomposition_hints: ["search_business_entity"], required_axes_any_of: [["business_entity"], ["counterparty"], ["organization"], ["contract"], ["item"]], optional_axes: ["period", "document", "account"], output_fact_kinds: ["entity_candidates", "entity_ambiguity"], @@ -21,6 +23,7 @@ const PRIMITIVE_CONTRACTS = [ { primitive_id: "inspect_1c_metadata", purpose: "Inspect available 1C schema/catalog/document/register surface before selecting a query lane.", + decomposition_hints: ["inspect_metadata_surface"], 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"], @@ -31,6 +34,7 @@ const PRIMITIVE_CONTRACTS = [ { primitive_id: "resolve_entity_reference", purpose: "Resolve a user-visible entity name to a concrete 1C reference candidate.", + decomposition_hints: ["resolve_entity_reference"], required_axes_any_of: [["business_entity"], ["counterparty"], ["organization"], ["contract"], ["item"]], optional_axes: ["period", "inn", "document"], output_fact_kinds: ["resolved_entity_ref", "entity_conflict"], @@ -41,6 +45,12 @@ const PRIMITIVE_CONTRACTS = [ { primitive_id: "query_movements", purpose: "Fetch or aggregate accounting/register movements for a scoped business question.", + decomposition_hints: [ + "collect_scoped_movements", + "collect_incoming_movements", + "collect_outgoing_movements", + "fetch_scoped_movements" + ], 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"], @@ -51,6 +61,7 @@ const PRIMITIVE_CONTRACTS = [ { primitive_id: "query_documents", purpose: "Fetch documents related to a scoped entity, period, contract, or movement explanation.", + decomposition_hints: ["fetch_scoped_documents", "fetch_supporting_documents"], required_axes_any_of: [["document"], ["counterparty"], ["contract"], ["period", "organization"]], optional_axes: ["account", "amount", "item", "warehouse"], output_fact_kinds: ["document_rows", "document_dates", "document_amounts"], @@ -61,6 +72,7 @@ const PRIMITIVE_CONTRACTS = [ { primitive_id: "aggregate_by_axis", purpose: "Aggregate already-scoped 1C evidence by a business axis such as counterparty, contract, or period.", + decomposition_hints: ["aggregate_checked_amounts", "aggregate_ranked_axis_values", "aggregate_by_month"], 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"], @@ -71,6 +83,7 @@ const PRIMITIVE_CONTRACTS = [ { primitive_id: "drilldown_related_objects", purpose: "Drill from a known entity or document into related contracts, documents, movements, or payments.", + decomposition_hints: ["drilldown_related_objects"], required_axes_any_of: [["business_entity"], ["document"], ["contract"], ["counterparty"]], optional_axes: ["period", "account", "amount"], output_fact_kinds: ["related_objects", "relationship_edges"], @@ -81,6 +94,7 @@ const PRIMITIVE_CONTRACTS = [ { primitive_id: "probe_coverage", purpose: "Check whether the selected MCP/schema route can prove the requested fact or only support a bounded inference.", + decomposition_hints: ["probe_coverage"], required_axes_any_of: [["coverage_target"], ["domain_family"], ["primitive_id"]], optional_axes: ["period", "organization", "counterparty", "document", "account"], output_fact_kinds: ["coverage_status", "known_gaps"], @@ -91,6 +105,7 @@ const PRIMITIVE_CONTRACTS = [ { primitive_id: "explain_evidence_basis", purpose: "Produce a machine-readable explanation of which checked MCP evidence supports, limits, or fails the answer.", + decomposition_hints: ["explain_evidence_basis"], 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"], @@ -123,6 +138,33 @@ function hasAnyAxisGroup(axisSet, groups) { function missingAxisGroups(axisSet, groups) { return groups.filter((group) => !group.every((axis) => axisSet.has(axis))); } +function normalizeDecompositionStep(value) { + return value.trim().toLowerCase(); +} +function searchAssistantMcpCatalogPrimitivesByDecompositionCandidates(input) { + const allowAggregateByAxis = input.allow_aggregate_by_axis !== false; + const result = []; + for (const candidate of input.decomposition_candidates) { + const normalizedCandidate = normalizeDecompositionStep(candidate); + if (!normalizedCandidate) { + continue; + } + for (const contract of PRIMITIVE_CONTRACTS) { + if (contract.primitive_id === "aggregate_by_axis" && + normalizedCandidate === "aggregate_by_month" && + !allowAggregateByAxis) { + continue; + } + if (!contract.decomposition_hints.includes(normalizedCandidate)) { + continue; + } + if (!result.includes(contract.primitive_id)) { + result.push(contract.primitive_id); + } + } + } + return result; +} function buildAssistantMcpCatalogIndex() { const reasonCodes = []; const missingContracts = assistantMcpDiscoveryPolicy_1.ASSISTANT_MCP_DISCOVERY_PRIMITIVES.filter((primitive) => !PRIMITIVE_CONTRACT_MAP.has(primitive)); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 3535610..639e0d7 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -260,7 +260,7 @@ function headlineFor(mode, pilot) { return "По текущему каталожному поиску 1С точный контрагент пока не подтвержден."; } if (pilot.derived_ranked_value_flow && mode === "confirmed_with_bounded_inference") { - return "По данным 1С можно построить ограниченный ranking по контрагентам на подтвержденных строках денежных движений."; + return "По данным 1С можно построить ограниченный рейтинг по контрагентам на подтвержденных строках денежных движений."; } if (isMovementPilot(pilot) && mode === "confirmed_with_bounded_inference") { return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`; @@ -323,15 +323,15 @@ function headlineFor(mode, pilot) { } if (mode === "needs_clarification" && isBidirectionalValueFlowComparisonClarification(pilot)) { const need = clarificationNeedRu(pilot); - return `Могу сравнить входящий и исходящий денежный поток, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; + return `Могу сравнить входящий и исходящий денежный поток, но для проверяемого поиска в 1С ${need.verb} ${need.subject}.`; } if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) { const need = clarificationNeedRu(pilot); - return `Могу посчитать ranking по денежному потоку между контрагентами, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; + return `Могу посчитать рейтинг по денежному потоку между контрагентами, но для проверяемого поиска в 1С ${need.verb} ${need.subject}.`; } if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) { const need = clarificationNeedRu(pilot); - return `Могу посчитать общий денежный поток в проверяемом окне, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; + return `Могу посчитать общий денежный поток в проверяемом окне, но для проверяемого поиска в 1С ${need.verb} ${need.subject}.`; } if (mode === "needs_clarification") { return "Нужно уточнить контекст перед поиском в 1С."; @@ -370,7 +370,7 @@ function nextStepFor(mode, pilot) { return clarificationNextStepLine(pilot, "сравнению входящих и исходящих денежных потоков"); } if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) { - return clarificationNextStepLine(pilot, "ranking-поиску между контрагентами"); + return clarificationNextStepLine(pilot, "рейтингу контрагентов по денежному потоку"); } if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) { return clarificationNextStepLine(pilot, "денежному потоку"); @@ -547,7 +547,7 @@ function derivedRankedValueFlowInferenceLine(pilot) { } const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : ""; const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне"; - return `Ranking по контрагентам${organization}${period} рассчитан только по подтвержденным строкам 1С и не доказывает полный исторический срез вне проверенного окна.`; + return `Рейтинг по контрагентам${organization}${period} рассчитан только по подтвержденным строкам 1С и не доказывает полный исторический срез вне проверенного окна.`; } function derivedRankedValueFlowConfirmedLine(pilot) { const ranking = pilot.derived_ranked_value_flow; @@ -570,7 +570,7 @@ function derivedRankedValueFlowConfirmedLine(pilot) { .join("; "); const trail = tail ? ` Следом: ${tail}.` : ""; const limitCaveat = ranking.coverage_limited_by_probe_limit - ? " Лимит строк проверки достигнут; ranking может быть неполным." + ? " Лимит строк проверки достигнут; рейтинг может быть неполным." : ""; return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${trail}${limitCaveat}`; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index 916c68a..bff526a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -64,6 +64,37 @@ function includesAny(text, tokens) { function isYearDateScope(meaning) { return /^\d{4}$/.test(toNonEmptyString(meaning?.explicit_date_scope) ?? ""); } +function primitivesFromGraphDecomposition(input) { + const decompositionCandidates = input.dataNeedGraph?.decomposition_candidates ?? []; + if (decompositionCandidates.length <= 0) { + return { primitives: input.fallbackPrimitives, reasonCodes: [] }; + } + const searchedPrimitives = (0, assistantMcpCatalogIndex_1.searchAssistantMcpCatalogPrimitivesByDecompositionCandidates)({ + decomposition_candidates: decompositionCandidates, + allow_aggregate_by_axis: input.allowAggregateByAxis + }); + if (searchedPrimitives.length <= 0) { + return { + primitives: input.fallbackPrimitives, + reasonCodes: ["planner_fell_back_to_recipe_primitives_after_empty_catalog_search"] + }; + } + const mergedPrimitives = [...searchedPrimitives]; + for (const primitive of input.fallbackPrimitives) { + if (!mergedPrimitives.includes(primitive)) { + mergedPrimitives.push(primitive); + } + } + return { + primitives: mergedPrimitives, + reasonCodes: mergedPrimitives.length === searchedPrimitives.length + ? ["planner_selected_catalog_primitives_from_decomposition_candidates"] + : [ + "planner_selected_catalog_primitives_from_decomposition_candidates", + "planner_completed_catalog_searched_chain_with_recipe_primitives" + ] + }; +} function budgetOverrideFor(input, recipe) { const meaning = input.turnMeaning ?? null; const requestedAggregationAxis = aggregationAxis(meaning); @@ -110,6 +141,11 @@ function recipeFor(input) { } if (graphFactFamily === "value_flow") { if (dataNeedGraph?.comparison_need === "incoming_vs_outgoing" && !hasSubjectCandidates(dataNeedGraph)) { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["query_movements", "probe_coverage"], + allowAggregateByAxis: false + }); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); if (requestedAggregationAxis === "month" || graphAggregation === "by_month") { @@ -119,12 +155,18 @@ function recipeFor(input) { semanticDataNeed: "bidirectional value-flow comparison evidence", 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: ["query_movements", "probe_coverage"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_bidirectional_value_flow_comparison_from_data_need_graph" + reason: "planner_selected_bidirectional_value_flow_comparison_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (dataNeedGraph?.ranking_need && !hasSubjectCandidates(dataNeedGraph)) { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["query_movements", "aggregate_by_axis", "probe_coverage"], + allowAggregateByAxis: true + }); pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); @@ -132,14 +174,20 @@ function recipeFor(input) { semanticDataNeed: "ranked value-flow evidence", 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: ["query_movements", "aggregate_by_axis", "probe_coverage"], + primitives: primitiveSelection.primitives, axes, 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) { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["query_movements", "aggregate_by_axis", "probe_coverage"], + allowAggregateByAxis: true + }); pushUnique(axes, "organization"); pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); @@ -151,13 +199,19 @@ function recipeFor(input) { semanticDataNeed: "organization-scoped value-flow evidence", 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: ["query_movements", "aggregate_by_axis", "probe_coverage"], + 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" + : "planner_selected_open_scope_value_flow_total_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], + allowAggregateByAxis: true + }); pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); @@ -168,14 +222,19 @@ function recipeFor(input) { semanticDataNeed: "counterparty value-flow evidence", 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"], + primitives: primitiveSelection.primitives, axes, reason: requestedAggregationAxis === "month" || graphAggregation === "by_month" ? "planner_selected_monthly_value_flow_from_data_need_graph" - : "planner_selected_value_flow_from_data_need_graph" + : "planner_selected_value_flow_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "activity_lifecycle") { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"] + }); pushUnique(axes, "document_date"); pushUnique(axes, "coverage_target"); pushUnique(axes, "evidence_basis"); @@ -183,56 +242,77 @@ function recipeFor(input) { semanticDataNeed: "counterparty lifecycle evidence", 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"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_lifecycle_from_data_need_graph" + reason: "planner_selected_lifecycle_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "schema_surface") { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["inspect_1c_metadata"] + }); pushUnique(axes, "metadata_scope"); return { semanticDataNeed: "1C metadata evidence", chainId: "metadata_inspection", chainSummary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.", - primitives: ["inspect_1c_metadata"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_metadata_from_data_need_graph" + reason: "planner_selected_metadata_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "movement_evidence") { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["resolve_entity_reference", "query_movements", "probe_coverage"] + }); pushUnique(axes, "coverage_target"); return { semanticDataNeed: "movement evidence", 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"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_movement_from_data_need_graph" + reason: "planner_selected_movement_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "document_evidence") { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage"] + }); pushUnique(axes, "coverage_target"); return { semanticDataNeed: "document evidence", 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"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_document_from_data_need_graph" + reason: "planner_selected_document_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "entity_grounding" || (!graphFactFamily && (dataNeedGraph?.subject_candidates.length ?? 0) > 0)) { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"] + }); pushUnique(axes, "business_entity"); pushUnique(axes, "coverage_target"); return { semanticDataNeed: "entity discovery evidence", 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"], + primitives: primitiveSelection.primitives, axes, reason: graphAction === "search_business_entity" ? "planner_selected_entity_resolution_from_data_need_graph" - : "planner_selected_entity_resolution_recipe" + : "planner_selected_entity_resolution_recipe", + extraReasons: primitiveSelection.reasonCodes }; } if (includesAny(combined, ["metadata_lane_choice_clarification", "resolve_next_lane"])) { @@ -347,6 +427,9 @@ function planAssistantMcpDiscovery(input) { const dataNeedGraph = input.dataNeedGraph ?? null; const reasonCodes = []; pushReason(reasonCodes, recipe.reason); + for (const reason of recipe.extraReasons ?? []) { + pushReason(reasonCodes, reason); + } if (dataNeedGraph) { pushReason(reasonCodes, "planner_consumed_data_need_graph_v1"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts b/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts index 0efce0d..c85aad2 100644 --- a/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts +++ b/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts @@ -13,6 +13,7 @@ export type AssistantMcpCatalogPlanReviewStatus = "catalog_compatible" | "needs_ export interface AssistantMcpCatalogPrimitiveContract { primitive_id: AssistantMcpDiscoveryPrimitive; purpose: string; + decomposition_hints: string[]; required_axes_any_of: string[][]; optional_axes: string[]; output_fact_kinds: string[]; @@ -43,6 +44,7 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ { primitive_id: "search_business_entity", purpose: "Find candidate 1C business entities by user wording before a fact query is executed.", + decomposition_hints: ["search_business_entity"], required_axes_any_of: [["business_entity"], ["counterparty"], ["organization"], ["contract"], ["item"]], optional_axes: ["period", "document", "account"], output_fact_kinds: ["entity_candidates", "entity_ambiguity"], @@ -53,6 +55,7 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ { primitive_id: "inspect_1c_metadata", purpose: "Inspect available 1C schema/catalog/document/register surface before selecting a query lane.", + decomposition_hints: ["inspect_metadata_surface"], 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"], @@ -63,6 +66,7 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ { primitive_id: "resolve_entity_reference", purpose: "Resolve a user-visible entity name to a concrete 1C reference candidate.", + decomposition_hints: ["resolve_entity_reference"], required_axes_any_of: [["business_entity"], ["counterparty"], ["organization"], ["contract"], ["item"]], optional_axes: ["period", "inn", "document"], output_fact_kinds: ["resolved_entity_ref", "entity_conflict"], @@ -73,6 +77,12 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ { primitive_id: "query_movements", purpose: "Fetch or aggregate accounting/register movements for a scoped business question.", + decomposition_hints: [ + "collect_scoped_movements", + "collect_incoming_movements", + "collect_outgoing_movements", + "fetch_scoped_movements" + ], 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"], @@ -83,6 +93,7 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ { primitive_id: "query_documents", purpose: "Fetch documents related to a scoped entity, period, contract, or movement explanation.", + decomposition_hints: ["fetch_scoped_documents", "fetch_supporting_documents"], required_axes_any_of: [["document"], ["counterparty"], ["contract"], ["period", "organization"]], optional_axes: ["account", "amount", "item", "warehouse"], output_fact_kinds: ["document_rows", "document_dates", "document_amounts"], @@ -93,6 +104,7 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ { primitive_id: "aggregate_by_axis", purpose: "Aggregate already-scoped 1C evidence by a business axis such as counterparty, contract, or period.", + decomposition_hints: ["aggregate_checked_amounts", "aggregate_ranked_axis_values", "aggregate_by_month"], 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"], @@ -103,6 +115,7 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ { primitive_id: "drilldown_related_objects", purpose: "Drill from a known entity or document into related contracts, documents, movements, or payments.", + decomposition_hints: ["drilldown_related_objects"], required_axes_any_of: [["business_entity"], ["document"], ["contract"], ["counterparty"]], optional_axes: ["period", "account", "amount"], output_fact_kinds: ["related_objects", "relationship_edges"], @@ -113,6 +126,7 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ { primitive_id: "probe_coverage", purpose: "Check whether the selected MCP/schema route can prove the requested fact or only support a bounded inference.", + decomposition_hints: ["probe_coverage"], required_axes_any_of: [["coverage_target"], ["domain_family"], ["primitive_id"]], optional_axes: ["period", "organization", "counterparty", "document", "account"], output_fact_kinds: ["coverage_status", "known_gaps"], @@ -123,6 +137,7 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ { primitive_id: "explain_evidence_basis", purpose: "Produce a machine-readable explanation of which checked MCP evidence supports, limits, or fails the answer.", + decomposition_hints: ["explain_evidence_basis"], 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"], @@ -164,6 +179,47 @@ function missingAxisGroups(axisSet: Set, groups: string[][]): string[][] return groups.filter((group) => !group.every((axis) => axisSet.has(axis))); } +function normalizeDecompositionStep(value: string): string { + return value.trim().toLowerCase(); +} + +export interface AssistantMcpCatalogPrimitiveSearchInput { + decomposition_candidates: string[]; + allow_aggregate_by_axis?: boolean; +} + +export function searchAssistantMcpCatalogPrimitivesByDecompositionCandidates( + input: AssistantMcpCatalogPrimitiveSearchInput +): AssistantMcpDiscoveryPrimitive[] { + const allowAggregateByAxis = input.allow_aggregate_by_axis !== false; + const result: AssistantMcpDiscoveryPrimitive[] = []; + + for (const candidate of input.decomposition_candidates) { + const normalizedCandidate = normalizeDecompositionStep(candidate); + if (!normalizedCandidate) { + continue; + } + + for (const contract of PRIMITIVE_CONTRACTS) { + if ( + contract.primitive_id === "aggregate_by_axis" && + normalizedCandidate === "aggregate_by_month" && + !allowAggregateByAxis + ) { + continue; + } + if (!contract.decomposition_hints.includes(normalizedCandidate)) { + continue; + } + if (!result.includes(contract.primitive_id)) { + result.push(contract.primitive_id); + } + } + } + + return result; +} + export function buildAssistantMcpCatalogIndex(): AssistantMcpCatalogIndexContract { const reasonCodes: string[] = []; const missingContracts = ASSISTANT_MCP_DISCOVERY_PRIMITIVES.filter((primitive) => !PRIMITIVE_CONTRACT_MAP.has(primitive)); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 498272c..84205d5 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -346,7 +346,7 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD return "По текущему каталожному поиску 1С точный контрагент пока не подтвержден."; } if (pilot.derived_ranked_value_flow && mode === "confirmed_with_bounded_inference") { - return "По данным 1С можно построить ограниченный ranking по контрагентам на подтвержденных строках денежных движений."; + return "По данным 1С можно построить ограниченный рейтинг по контрагентам на подтвержденных строках денежных движений."; } if (isMovementPilot(pilot) && mode === "confirmed_with_bounded_inference") { return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`; @@ -409,15 +409,15 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD } if (mode === "needs_clarification" && isBidirectionalValueFlowComparisonClarification(pilot)) { const need = clarificationNeedRu(pilot); - return `Могу сравнить входящий и исходящий денежный поток, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; + return `Могу сравнить входящий и исходящий денежный поток, но для проверяемого поиска в 1С ${need.verb} ${need.subject}.`; } if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) { const need = clarificationNeedRu(pilot); - return `Могу посчитать ranking по денежному потоку между контрагентами, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; + return `Могу посчитать рейтинг по денежному потоку между контрагентами, но для проверяемого поиска в 1С ${need.verb} ${need.subject}.`; } if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) { const need = clarificationNeedRu(pilot); - return `Могу посчитать общий денежный поток в проверяемом окне, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; + return `Могу посчитать общий денежный поток в проверяемом окне, но для проверяемого поиска в 1С ${need.verb} ${need.subject}.`; } if (mode === "needs_clarification") { return "Нужно уточнить контекст перед поиском в 1С."; @@ -461,7 +461,7 @@ function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD return clarificationNextStepLine(pilot, "сравнению входящих и исходящих денежных потоков"); } if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) { - return clarificationNextStepLine(pilot, "ranking-поиску между контрагентами"); + return clarificationNextStepLine(pilot, "рейтингу контрагентов по денежному потоку"); } if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) { return clarificationNextStepLine(pilot, "денежному потоку"); @@ -655,7 +655,7 @@ function derivedRankedValueFlowInferenceLine(pilot: AssistantMcpDiscoveryPilotEx } const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : ""; const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне"; - return `Ranking по контрагентам${organization}${period} рассчитан только по подтвержденным строкам 1С и не доказывает полный исторический срез вне проверенного окна.`; + return `Рейтинг по контрагентам${organization}${period} рассчитан только по подтвержденным строкам 1С и не доказывает полный исторический срез вне проверенного окна.`; } function derivedRankedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { @@ -680,7 +680,7 @@ function derivedRankedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotEx .join("; "); const trail = tail ? ` Следом: ${tail}.` : ""; const limitCaveat = ranking.coverage_limited_by_probe_limit - ? " Лимит строк проверки достигнут; ranking может быть неполным." + ? " Лимит строк проверки достигнут; рейтинг может быть неполным." : ""; return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${trail}${limitCaveat}`; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index 335de78..b3a35aa 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -5,6 +5,7 @@ import { type AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscoveryPolicy"; import { + searchAssistantMcpCatalogPrimitivesByDecompositionCandidates, reviewAssistantMcpDiscoveryPlanAgainstCatalog, type AssistantMcpCatalogPlanReview } from "./assistantMcpCatalogIndex"; @@ -53,6 +54,7 @@ interface PlannerRecipe { primitives: AssistantMcpDiscoveryPrimitive[]; axes: string[]; reason: string; + extraReasons?: string[]; } interface PlannerBudgetOverride { @@ -133,6 +135,46 @@ function isYearDateScope(meaning: AssistantMcpDiscoveryTurnMeaningRef | null | u return /^\d{4}$/.test(toNonEmptyString(meaning?.explicit_date_scope) ?? ""); } +function primitivesFromGraphDecomposition(input: { + dataNeedGraph: AssistantMcpDiscoveryDataNeedGraphContract | null; + fallbackPrimitives: AssistantMcpDiscoveryPrimitive[]; + allowAggregateByAxis?: boolean; +}): { primitives: AssistantMcpDiscoveryPrimitive[]; reasonCodes: string[] } { + const decompositionCandidates = input.dataNeedGraph?.decomposition_candidates ?? []; + if (decompositionCandidates.length <= 0) { + return { primitives: input.fallbackPrimitives, reasonCodes: [] }; + } + + const searchedPrimitives = searchAssistantMcpCatalogPrimitivesByDecompositionCandidates({ + decomposition_candidates: decompositionCandidates, + allow_aggregate_by_axis: input.allowAggregateByAxis + }); + if (searchedPrimitives.length <= 0) { + return { + primitives: input.fallbackPrimitives, + reasonCodes: ["planner_fell_back_to_recipe_primitives_after_empty_catalog_search"] + }; + } + + const mergedPrimitives = [...searchedPrimitives]; + for (const primitive of input.fallbackPrimitives) { + if (!mergedPrimitives.includes(primitive)) { + mergedPrimitives.push(primitive); + } + } + + return { + primitives: mergedPrimitives, + reasonCodes: + mergedPrimitives.length === searchedPrimitives.length + ? ["planner_selected_catalog_primitives_from_decomposition_candidates"] + : [ + "planner_selected_catalog_primitives_from_decomposition_candidates", + "planner_completed_catalog_searched_chain_with_recipe_primitives" + ] + }; +} + function budgetOverrideFor(input: AssistantMcpDiscoveryPlannerInput, recipe: PlannerRecipe): PlannerBudgetOverride { const meaning = input.turnMeaning ?? null; const requestedAggregationAxis = aggregationAxis(meaning); @@ -184,6 +226,11 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { if (graphFactFamily === "value_flow") { if (dataNeedGraph?.comparison_need === "incoming_vs_outgoing" && !hasSubjectCandidates(dataNeedGraph)) { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["query_movements", "probe_coverage"], + allowAggregateByAxis: false + }); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); if (requestedAggregationAxis === "month" || graphAggregation === "by_month") { @@ -194,12 +241,18 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { 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: ["query_movements", "probe_coverage"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_bidirectional_value_flow_comparison_from_data_need_graph" + reason: "planner_selected_bidirectional_value_flow_comparison_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (dataNeedGraph?.ranking_need && !hasSubjectCandidates(dataNeedGraph)) { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["query_movements", "aggregate_by_axis", "probe_coverage"], + allowAggregateByAxis: true + }); pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); @@ -208,15 +261,21 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { 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: ["query_movements", "aggregate_by_axis", "probe_coverage"], + primitives: primitiveSelection.primitives, axes, 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) { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["query_movements", "aggregate_by_axis", "probe_coverage"], + allowAggregateByAxis: true + }); pushUnique(axes, "organization"); pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); @@ -229,14 +288,20 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { 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: ["query_movements", "aggregate_by_axis", "probe_coverage"], + 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" + : "planner_selected_open_scope_value_flow_total_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], + allowAggregateByAxis: true + }); pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); @@ -247,16 +312,21 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { semanticDataNeed: "counterparty value-flow evidence", 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"], + primitives: primitiveSelection.primitives, axes, reason: requestedAggregationAxis === "month" || graphAggregation === "by_month" ? "planner_selected_monthly_value_flow_from_data_need_graph" - : "planner_selected_value_flow_from_data_need_graph" + : "planner_selected_value_flow_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "activity_lifecycle") { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"] + }); pushUnique(axes, "document_date"); pushUnique(axes, "coverage_target"); pushUnique(axes, "evidence_basis"); @@ -264,61 +334,82 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { semanticDataNeed: "counterparty lifecycle evidence", 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"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_lifecycle_from_data_need_graph" + reason: "planner_selected_lifecycle_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "schema_surface") { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["inspect_1c_metadata"] + }); pushUnique(axes, "metadata_scope"); return { semanticDataNeed: "1C metadata evidence", chainId: "metadata_inspection", chainSummary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.", - primitives: ["inspect_1c_metadata"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_metadata_from_data_need_graph" + reason: "planner_selected_metadata_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "movement_evidence") { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["resolve_entity_reference", "query_movements", "probe_coverage"] + }); pushUnique(axes, "coverage_target"); return { semanticDataNeed: "movement evidence", 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"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_movement_from_data_need_graph" + reason: "planner_selected_movement_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "document_evidence") { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage"] + }); pushUnique(axes, "coverage_target"); return { semanticDataNeed: "document evidence", 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"], + primitives: primitiveSelection.primitives, axes, - reason: "planner_selected_document_from_data_need_graph" + reason: "planner_selected_document_from_data_need_graph", + extraReasons: primitiveSelection.reasonCodes }; } if (graphFactFamily === "entity_grounding" || (!graphFactFamily && (dataNeedGraph?.subject_candidates.length ?? 0) > 0)) { + const primitiveSelection = primitivesFromGraphDecomposition({ + dataNeedGraph, + fallbackPrimitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"] + }); pushUnique(axes, "business_entity"); pushUnique(axes, "coverage_target"); return { semanticDataNeed: "entity discovery evidence", 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"], + primitives: primitiveSelection.primitives, axes, reason: graphAction === "search_business_entity" ? "planner_selected_entity_resolution_from_data_need_graph" - : "planner_selected_entity_resolution_recipe" + : "planner_selected_entity_resolution_recipe", + extraReasons: primitiveSelection.reasonCodes }; } @@ -448,6 +539,9 @@ export function planAssistantMcpDiscovery( const dataNeedGraph = input.dataNeedGraph ?? null; const reasonCodes: string[] = []; pushReason(reasonCodes, recipe.reason); + for (const reason of recipe.extraReasons ?? []) { + pushReason(reasonCodes, reason); + } if (dataNeedGraph) { pushReason(reasonCodes, "planner_consumed_data_need_graph_v1"); } diff --git a/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts b/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts index 4d21b92..fad98e1 100644 --- a/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts @@ -3,7 +3,8 @@ import { ASSISTANT_MCP_DISCOVERY_PRIMITIVES, buildAssistantMcpDiscoveryPlan } fr import { buildAssistantMcpCatalogIndex, getAssistantMcpCatalogPrimitive, - reviewAssistantMcpDiscoveryPlanAgainstCatalog + reviewAssistantMcpDiscoveryPlanAgainstCatalog, + searchAssistantMcpCatalogPrimitivesByDecompositionCandidates } from "../src/services/assistantMcpCatalogIndex"; describe("assistant MCP catalog index", () => { @@ -16,11 +17,44 @@ describe("assistant MCP catalog index", () => { for (const entry of index.primitives) { expect(entry.safe_for_model_planning).toBe(true); expect(entry.runtime_must_execute).toBe(true); + expect(entry.decomposition_hints.length).toBeGreaterThan(0); expect(entry.required_axes_any_of.length).toBeGreaterThan(0); expect(entry.output_fact_kinds.length).toBeGreaterThan(0); } }); + it("can search reviewed primitives from data-need decomposition candidates", () => { + const primitives = searchAssistantMcpCatalogPrimitivesByDecompositionCandidates({ + decomposition_candidates: [ + "resolve_entity_reference", + "collect_scoped_movements", + "aggregate_checked_amounts", + "probe_coverage" + ] + }); + + expect(primitives).toEqual([ + "resolve_entity_reference", + "query_movements", + "aggregate_by_axis", + "probe_coverage" + ]); + }); + + it("can suppress aggregate_by_axis for decomposition shapes that derive comparison without an aggregate primitive", () => { + const primitives = searchAssistantMcpCatalogPrimitivesByDecompositionCandidates({ + decomposition_candidates: [ + "collect_incoming_movements", + "collect_outgoing_movements", + "aggregate_by_month", + "probe_coverage" + ], + allow_aggregate_by_axis: false + }); + + expect(primitives).toEqual(["query_movements", "probe_coverage"]); + }); + it("marks a counterparty turnover discovery plan as catalog-compatible when required axes exist", () => { const plan = buildAssistantMcpDiscoveryPlan({ semanticDataNeed: "counterparty turnover evidence", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 26d446f..1f22844 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -251,7 +251,7 @@ describe("assistant MCP discovery answer adapter", () => { const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); expect(draft.answer_mode).toBe("needs_clarification"); - expect(draft.headline).toContain("ranking"); + expect(draft.headline).toContain("рейтинг"); expect(draft.next_step_line).toContain("организацию"); expect(draft.next_step_line).not.toContain("Уточните контрагента"); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index fb1e556..60087b0 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -50,6 +50,7 @@ describe("assistant MCP discovery planner", () => { expect(result.discovery_plan.execution_budget.max_probe_count).toBe(30); expect(result.reason_codes).toContain("planner_enabled_chunked_coverage_probe_budget"); expect(result.reason_codes).toContain("planner_consumed_data_need_graph_v1"); + expect(result.reason_codes).toContain("planner_selected_catalog_primitives_from_decomposition_candidates"); }); it("keeps a value-flow plan in clarification state when period axis is missing", () => { @@ -147,6 +148,7 @@ describe("assistant MCP discovery planner", () => { expect(result.proposed_primitives).not.toContain("aggregate_by_axis"); 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"); }); it("can select value-flow chain from data need graph even when turn meaning family is still under-specified", () => { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts index 51a1906..27a223d 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts @@ -105,7 +105,7 @@ describe("assistant MCP discovery runtime bridge", () => { expect(result.requires_user_clarification).toBe(true); expect(result.pilot.mcp_execution_performed).toBe(false); expect(result.planner.selected_chain_id).toBe("value_flow_ranking"); - expect(result.answer_draft.headline).toContain("ranking"); + expect(result.answer_draft.headline).toContain("рейтинг"); expect(result.answer_draft.next_step_line).toContain("организацию"); expect(result.answer_draft.next_step_line).not.toContain("Уточните контрагента"); });