NODEDC_1C/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntim...

541 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi } from "vitest";
import { runAssistantMcpDiscoveryRuntimeBridge } from "../src/services/assistantMcpDiscoveryRuntimeBridge";
function buildDeps(rows: Array<Record<string, unknown>>, error: string | null = null) {
return {
executeAddressMcpQuery: vi.fn(async () => ({
fetched_rows: rows.length,
matched_rows: error ? 0 : rows.length,
raw_rows: rows,
rows: error ? [] : rows,
error
}))
};
}
function buildBidirectionalDeps(
incomingRows: Array<Record<string, unknown>>,
outgoingRows: Array<Record<string, unknown>>
) {
return {
executeAddressMcpQuery: vi
.fn()
.mockResolvedValueOnce({
fetched_rows: incomingRows.length,
matched_rows: incomingRows.length,
raw_rows: incomingRows,
rows: incomingRows,
error: null
})
.mockResolvedValueOnce({
fetched_rows: outgoingRows.length,
matched_rows: outgoingRows.length,
raw_rows: outgoingRows,
rows: outgoingRows,
error: null
})
};
}
describe("assistant MCP discovery runtime bridge", () => {
it("composes planner, pilot executor, and answer draft without wiring the hot runtime", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
turnMeaning: {
asked_domain_family: "counterparty_lifecycle",
asked_action_family: "activity_duration",
explicit_entity_candidates: ["SVK"]
},
deps: buildDeps([{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST" }])
});
expect(result.schema_version).toBe("assistant_mcp_discovery_runtime_bridge_v1");
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.hot_runtime_wired).toBe(false);
expect(result.pilot.mcp_execution_performed).toBe(true);
expect(result.answer_draft.answer_mode).toBe("confirmed_with_bounded_inference");
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.user_facing_response_allowed).toBe(true);
expect(result.reason_codes).toContain("runtime_bridge_not_wired_to_hot_assistant_answer");
});
it("keeps missing scope as clarification and does not authorize a business fact answer", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_entity_candidates: ["SVK"]
},
deps: buildDeps([])
});
expect(result.bridge_status).toBe("needs_clarification");
expect(result.requires_user_clarification).toBe(true);
expect(result.pilot.mcp_execution_performed).toBe(false);
expect(result.business_fact_answer_allowed).toBe(false);
expect(result.answer_draft.next_step_line).toContain("Уточните контрагента");
});
it("keeps ranked value-flow in clarification without asking for a counterparty when only the period is known", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "turnover",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: "top_desc",
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_date_scope: "2020"
},
deps: buildDeps([])
});
expect(result.bridge_status).toBe("needs_clarification");
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("рейтинг");
expect(result.answer_draft.next_step_line).toContain("организацию");
expect(result.answer_draft.next_step_line).not.toContain("Уточните контрагента");
});
it("emits a resumable loop state for clarification-driven ranking chains", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "turnover",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: "top_desc",
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_date_scope: "2020"
},
deps: buildDeps([])
});
expect(result.bridge_status).toBe("needs_clarification");
expect(result.loop_state).toMatchObject({
loop_status: "awaiting_clarification",
selected_chain_id: "value_flow_ranking",
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
ranking_need: "top_desc",
explicit_date_scope: "2020"
});
expect(result.loop_state.pending_axes).toContain("organization");
expect(result.loop_state.provided_axes).toContain("aggregate_axis");
expect(result.reason_codes).toContain("runtime_bridge_loop_state_awaiting_clarification");
});
it("produces a bounded ranked value-flow answer when period and organization are known", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "turnover",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: "top_desc",
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_date_scope: "2020",
explicit_organization_scope: "ООО Альтернатива Плюс"
},
deps: buildDeps([
{ Period: "2020-01-10T00:00:00", Amount: 1200, Counterparty: "СВК-А" },
{ Period: "2020-03-11T00:00:00", Amount: 800, Counterparty: "СВК-Б" },
{ Period: "2020-05-12T00:00:00", Amount: 900, Counterparty: "СВК-А" }
])
});
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.planner.selected_chain_id).toBe("value_flow_ranking");
expect(result.pilot.derived_ranked_value_flow?.ranked_values[0]).toMatchObject({
axis_value: "СВК-А",
total_amount: 2100
});
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("СВК-А");
});
it("keeps open bidirectional comparison in clarification without asking for a counterparty when only the period is known", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_comparison_incoming_vs_outgoing"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_date_scope: "2020"
},
deps: buildDeps([])
});
expect(result.bridge_status).toBe("needs_clarification");
expect(result.requires_user_clarification).toBe(true);
expect(result.pilot.mcp_execution_performed).toBe(false);
expect(result.planner.selected_chain_id).toBe("value_flow_comparison");
expect(result.answer_draft.headline).toContain("входящий и исходящий");
expect(result.answer_draft.next_step_line).toContain("организацию");
expect(result.answer_draft.next_step_line).not.toContain("Уточните контрагента");
});
it("produces a bounded bidirectional comparison answer when period and organization are known", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_comparison_incoming_vs_outgoing"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_date_scope: "2020",
explicit_organization_scope: "ООО Альтернатива Плюс"
},
deps: buildBidirectionalDeps(
[
{ Period: "2020-01-10T00:00:00", Amount: 3200, Counterparty: "СВК-А" },
{ Period: "2020-04-11T00:00:00", Amount: 1800, Counterparty: "СВК-Б" }
],
[{ Period: "2020-02-12T00:00:00", Amount: 1400, Counterparty: "СВК-А" }]
)
});
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.planner.selected_chain_id).toBe("value_flow_comparison");
expect(result.pilot.derived_bidirectional_value_flow).toMatchObject({
period_scope: "2020",
incoming_customer_revenue: {
total_amount: 5000
},
outgoing_supplier_payout: {
total_amount: 1400
}
});
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("получили");
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("заплатили");
});
it("keeps document-ready plans bounded when the pilot finds no confirmed rows", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
turnMeaning: {
asked_domain_family: "document",
asked_action_family: "documents",
explicit_entity_candidates: ["SVK"]
},
deps: buildDeps([])
});
expect(result.bridge_status).toBe("checked_sources_only");
expect(result.hot_runtime_wired).toBe(false);
expect(result.pilot.pilot_scope).toBe("counterparty_document_evidence_query_documents_v1");
expect(result.pilot.mcp_execution_performed).toBe(true);
expect(result.business_fact_answer_allowed).toBe(false);
expect(result.reason_codes).toContain("runtime_bridge_status_checked_sources_only");
});
it("keeps inventory catalog templates unsupported until exact runtime evidence is bridged", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "inventory_stock_snapshot",
action_family: "stock_snapshot",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: [
"fetch_scoped_movements",
"aggregate_checked_amounts",
"probe_coverage",
"explain_evidence_basis"
],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_stock_snapshot"],
reason_codes: ["data_need_graph_built", "data_need_graph_family_inventory_stock_snapshot"]
},
turnMeaning: {
asked_domain_family: "inventory_stock",
asked_action_family: "stock_snapshot",
explicit_organization_scope: "OOO Alternative Plus",
explicit_date_scope: "2021-09-30"
},
deps: buildDeps([])
});
const userFacing = [
result.answer_draft.headline,
...result.answer_draft.confirmed_lines,
...result.answer_draft.inference_lines,
...result.answer_draft.unknown_lines,
...result.answer_draft.limitation_lines,
result.answer_draft.next_step_line ?? ""
].join("\n");
expect(result.bridge_status).toBe("unsupported");
expect(result.answer_draft.answer_mode).toBe("checked_sources_only");
expect(result.pilot.pilot_scope).toBe("inventory_route_template_v1");
expect(result.pilot.mcp_execution_performed).toBe(false);
expect(result.loop_state).toMatchObject({
loop_status: "blocked",
selected_chain_id: "inventory_stock_snapshot",
pilot_scope: "inventory_route_template_v1",
asked_domain_family: "inventory_stock",
asked_action_family: "stock_snapshot",
explicit_organization_scope: "OOO Alternative Plus",
explicit_date_scope: "2021-09-30"
});
expect(result.business_fact_answer_allowed).toBe(false);
expect(result.reason_codes).toContain("pilot_scope_unsupported_for_live_execution");
expect(result.answer_draft.must_not_claim).toEqual(
expect.arrayContaining([
"Do not present inventory route-template planning as executed stock, supplier, purchase, or sale evidence.",
"Do not expose inventory_route_template_v1 or MCP primitive names in the user answer."
])
);
expect(userFacing).toContain("Инвентарный route-template");
expect(userFacing).not.toContain("inventory_route_template_v1");
expect(userFacing).not.toContain("query_movements");
expect(userFacing).not.toContain("primitive");
expect(userFacing).not.toContain("MCP discovery pilot");
});
it("keeps document evidence executable when the planner expands primitives from fact-axis search", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: ["SVK"],
business_fact_family: "document_evidence",
action_family: "list_documents",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: [],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built"]
},
turnMeaning: {
asked_domain_family: "documents",
asked_action_family: "list_documents",
explicit_entity_candidates: ["SVK"],
explicit_date_scope: "2020",
unsupported_but_understood_family: "document_evidence"
},
deps: buildDeps([{ Period: "2020-01-15T00:00:00", Registrator: "DOC-1", Counterparty: "SVK" }])
});
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.planner.selected_chain_id).toBe("document_evidence");
expect(result.planner.proposed_primitives).toEqual(["resolve_entity_reference", "query_documents", "probe_coverage"]);
expect(result.planner.reason_codes).toContain("planner_selected_catalog_primitives_from_fact_axis_search");
expect(result.business_fact_answer_allowed).toBe(true);
});
it("preserves the answer adapter boundary against internal mechanics leakage", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
turnMeaning: {
asked_domain_family: "counterparty_lifecycle",
asked_action_family: "activity_duration",
explicit_entity_candidates: ["SVK"]
},
deps: buildDeps([{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST" }])
});
const userFacing = [
result.answer_draft.headline,
...result.answer_draft.confirmed_lines,
...result.answer_draft.inference_lines,
...result.answer_draft.unknown_lines,
...result.answer_draft.limitation_lines,
result.answer_draft.next_step_line ?? ""
].join("\n");
expect(userFacing).not.toContain("query_documents");
expect(userFacing).not.toContain("runtime_bridge");
expect(userFacing).not.toContain("primitive");
});
it("produces a bounded one-sided value-flow answer for an organization-scoped total without inventing a counterparty", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "turnover",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_scoped_movements", "aggregate_checked_amounts", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_open_scope_total_without_subject"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_date_scope: "2020",
explicit_organization_scope: "ООО Альтернатива Плюс"
},
deps: buildDeps([
{ Period: "2020-01-10T00:00:00", Amount: 3200, Counterparty: "Клиент-А" },
{ Period: "2020-05-22T00:00:00", Amount: 1800, Counterparty: "Клиент-Б" }
])
});
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.planner.selected_chain_id).toBe("value_flow");
expect(result.pilot.derived_value_flow).toMatchObject({
counterparty: null,
period_scope: "2020",
total_amount: 5000
});
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("5 000");
expect(result.answer_draft.confirmed_lines.join("\n")).not.toContain("контрагенту");
});
it("keeps generic one-sided open totals in organization clarification without asking for a counterparty", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "turnover",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "clarification_required",
clarification_gaps: ["organization"],
decomposition_candidates: ["collect_scoped_movements", "aggregate_checked_amounts", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: [
"data_need_graph_built",
"data_need_graph_open_scope_total_without_subject",
"data_need_graph_open_scope_total_needs_organization"
]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_date_scope: "2020"
},
deps: buildDeps([])
});
expect(result.bridge_status).toBe("needs_clarification");
expect(result.requires_user_clarification).toBe(true);
expect(result.planner.selected_chain_id).toBe("value_flow");
expect(result.answer_draft.next_step_line).toContain("\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e");
expect(result.answer_draft.next_step_line).not.toContain(
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430"
);
});
it("persists metadata scope and subject-optional flags in the resumable loop state", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
metadata_scope_hint: "\u041d\u0414\u0421",
subject_resolution_optional: true,
business_fact_family: "movement_evidence",
action_family: "list_movements",
aggregation_need: null,
time_scope_need: "period_required",
comparison_need: null,
ranking_need: null,
proof_expectation: "clarification_required",
clarification_gaps: ["organization", "period"],
decomposition_candidates: ["fetch_scoped_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_metadata_scoped_open_lane_without_subject"]
},
turnMeaning: {
asked_domain_family: "movements",
asked_action_family: "list_movements",
metadata_scope_hint: "\u041d\u0414\u0421",
subject_resolution_optional: true,
unsupported_but_understood_family: "movement_evidence"
},
deps: buildDeps([])
});
expect(result.bridge_status).toBe("needs_clarification");
expect(result.loop_state).toMatchObject({
loop_status: "awaiting_clarification",
selected_chain_id: "movement_evidence",
metadata_scope_hint: "\u041d\u0414\u0421",
subject_resolution_optional: true
});
expect(result.loop_state.pending_axes).toEqual(["organization", "period"]);
expect(result.loop_state.explicit_entity_candidates).toEqual([]);
});
});