ARCH: подключить catalog primitive search к data-need planner

This commit is contained in:
2026-04-22 22:47:22 +03:00
parent 70325dacb6
commit 8b6cd4c329
10 changed files with 364 additions and 53 deletions
@@ -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<string>, 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));
@@ -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}`;
}
@@ -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");
}