362 lines
15 KiB
TypeScript
362 lines
15 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import { planAssistantMcpDiscovery } from "../src/services/assistantMcpDiscoveryPlanner";
|
||
import { executeAssistantMcpDiscoveryPilot } from "../src/services/assistantMcpDiscoveryPilotExecutor";
|
||
|
||
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 buildSequentialDeps(results: Array<{ rows: Array<Record<string, unknown>>; error?: string | null }>) {
|
||
const executeAddressMcpQuery = vi.fn(async () => {
|
||
const next = results.shift() ?? { rows: [] };
|
||
const rows = next.rows;
|
||
const error = next.error ?? null;
|
||
return {
|
||
fetched_rows: rows.length,
|
||
matched_rows: error ? 0 : rows.length,
|
||
raw_rows: rows,
|
||
rows: error ? [] : rows,
|
||
error
|
||
};
|
||
});
|
||
return { executeAddressMcpQuery };
|
||
}
|
||
|
||
describe("assistant MCP discovery pilot executor", () => {
|
||
it("executes only the lifecycle query_documents primitive through injected MCP deps", async () => {
|
||
const planner = planAssistantMcpDiscovery({
|
||
turnMeaning: {
|
||
asked_domain_family: "counterparty_lifecycle",
|
||
asked_action_family: "activity_duration",
|
||
explicit_entity_candidates: ["SVK"]
|
||
}
|
||
});
|
||
const deps = buildDeps([
|
||
{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST", Контрагент: "SVK" },
|
||
{ Период: "2023-12-20T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY", Контрагент: "SVK" }
|
||
]);
|
||
|
||
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
|
||
|
||
expect(result.pilot_status).toBe("executed");
|
||
expect(result.mcp_execution_performed).toBe(true);
|
||
expect(result.executed_primitives).toEqual(["query_documents"]);
|
||
expect(result.skipped_primitives).toEqual(["resolve_entity_reference", "probe_coverage", "explain_evidence_basis"]);
|
||
expect(result.evidence.evidence_status).toBe("confirmed");
|
||
expect(result.evidence.confirmed_facts[0]).toContain("SVK");
|
||
expect(result.evidence.inferred_facts[0]).toContain("may be inferred");
|
||
expect(result.evidence.unknown_facts).toContain("Legal registration date is not proven by this MCP discovery pilot");
|
||
expect(result.source_rows_summary).toBe("2 MCP document rows fetched, 2 matched lifecycle scope");
|
||
expect(result.derived_activity_period).toEqual({
|
||
first_activity_date: "2020-01-15",
|
||
latest_activity_date: "2023-12-20",
|
||
matched_rows: 2,
|
||
duration_total_months: 47,
|
||
duration_years: 3,
|
||
duration_months_remainder: 11,
|
||
duration_human_ru: "3 года 11 месяцев",
|
||
inference_basis: "first_and_latest_confirmed_1c_activity_rows"
|
||
});
|
||
expect(result.reason_codes).toContain("pilot_query_documents_mcp_executed");
|
||
expect(result.reason_codes).toContain("pilot_derived_activity_period_from_confirmed_rows");
|
||
|
||
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(1);
|
||
const call = deps.executeAddressMcpQuery.mock.calls[0]?.[0];
|
||
expect(String(call?.query ?? "")).toContain("Документ.ПоступлениеНаРасчетныйСчет");
|
||
expect(call?.limit).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("does not execute MCP when dry-run still needs clarification", async () => {
|
||
const planner = planAssistantMcpDiscovery({
|
||
turnMeaning: {
|
||
asked_domain_family: "counterparty_value",
|
||
asked_action_family: "turnover",
|
||
explicit_entity_candidates: ["SVK"]
|
||
}
|
||
});
|
||
const deps = buildDeps([]);
|
||
|
||
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
|
||
|
||
expect(result.pilot_status).toBe("skipped_needs_clarification");
|
||
expect(result.mcp_execution_performed).toBe(false);
|
||
expect(result.evidence.evidence_status).toBe("insufficient");
|
||
expect(deps.executeAddressMcpQuery).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("executes value-flow query_movements and derives a guarded turnover sum", async () => {
|
||
const planner = planAssistantMcpDiscovery({
|
||
turnMeaning: {
|
||
asked_domain_family: "counterparty_value",
|
||
asked_action_family: "turnover",
|
||
explicit_entity_candidates: ["SVK"],
|
||
explicit_date_scope: "2020"
|
||
}
|
||
});
|
||
const deps = buildDeps([
|
||
{ Period: "2020-01-15T00:00:00", Amount: 1250, Counterparty: "SVK" },
|
||
{ Period: "2020-02-20T00:00:00", Amount: "2 500,50", Counterparty: "SVK" }
|
||
]);
|
||
|
||
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
|
||
|
||
expect(result.pilot_status).toBe("executed");
|
||
expect(result.pilot_scope).toBe("counterparty_value_flow_query_movements_v1");
|
||
expect(result.mcp_execution_performed).toBe(true);
|
||
expect(result.executed_primitives).toEqual(["query_movements"]);
|
||
expect(result.skipped_primitives).toEqual(["resolve_entity_reference", "aggregate_by_axis", "probe_coverage"]);
|
||
expect(result.evidence.evidence_status).toBe("confirmed");
|
||
expect(result.evidence.confirmed_facts[0]).toContain("value-flow rows");
|
||
expect(result.source_rows_summary).toBe("2 MCP value-flow rows fetched, 2 matched value-flow scope");
|
||
expect(result.derived_value_flow).toMatchObject({
|
||
counterparty: "SVK",
|
||
period_scope: "2020",
|
||
rows_matched: 2,
|
||
rows_with_amount: 2,
|
||
total_amount: 3750.5,
|
||
coverage_limited_by_probe_limit: false,
|
||
first_movement_date: "2020-01-15",
|
||
latest_movement_date: "2020-02-20",
|
||
inference_basis: "sum_of_confirmed_1c_value_flow_rows"
|
||
});
|
||
expect(result.reason_codes).toContain("pilot_query_movements_mcp_executed");
|
||
expect(result.reason_codes).toContain("pilot_derived_value_flow_from_confirmed_rows");
|
||
|
||
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(1);
|
||
const call = deps.executeAddressMcpQuery.mock.calls[0]?.[0];
|
||
expect(String(call?.query ?? "")).toContain("ПоступлениеНаРасчетныйСчет");
|
||
expect(call?.limit).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("executes supplier payout query_movements and derives a guarded outgoing payment sum", async () => {
|
||
const planner = planAssistantMcpDiscovery({
|
||
turnMeaning: {
|
||
asked_domain_family: "counterparty_value",
|
||
asked_action_family: "payout",
|
||
explicit_entity_candidates: ["SVK"],
|
||
explicit_date_scope: "2020",
|
||
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
|
||
}
|
||
});
|
||
const deps = buildDeps([
|
||
{ Period: "2020-03-15T00:00:00", Amount: 4100, Counterparty: "SVK" },
|
||
{ Period: "2020-04-20T00:00:00", Amount: "900,25", Counterparty: "SVK" }
|
||
]);
|
||
|
||
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
|
||
|
||
expect(result.pilot_status).toBe("executed");
|
||
expect(result.pilot_scope).toBe("counterparty_supplier_payout_query_movements_v1");
|
||
expect(result.derived_value_flow).toMatchObject({
|
||
value_flow_direction: "outgoing_supplier_payout",
|
||
counterparty: "SVK",
|
||
period_scope: "2020",
|
||
rows_matched: 2,
|
||
rows_with_amount: 2,
|
||
total_amount: 5000.25,
|
||
coverage_limited_by_probe_limit: false,
|
||
first_movement_date: "2020-03-15",
|
||
latest_movement_date: "2020-04-20"
|
||
});
|
||
expect(result.evidence.confirmed_facts[0]).toContain("supplier-payout rows");
|
||
expect(result.evidence.inferred_facts[0]).toContain("supplier-payout total");
|
||
expect(result.evidence.unknown_facts).toContain("Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot");
|
||
expect(result.reason_codes).toContain("pilot_supplier_payout_recipe_selected");
|
||
|
||
const call = deps.executeAddressMcpQuery.mock.calls[0]?.[0];
|
||
expect(String(call?.query ?? "")).toContain("СписаниеСРасчетногоСчета");
|
||
});
|
||
|
||
it("marks value-flow coverage as limited when the probe row limit is reached", async () => {
|
||
const planner = planAssistantMcpDiscovery({
|
||
turnMeaning: {
|
||
asked_domain_family: "counterparty_value",
|
||
asked_action_family: "payout",
|
||
explicit_entity_candidates: ["SVK"],
|
||
explicit_date_scope: "2020",
|
||
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
|
||
}
|
||
});
|
||
const rows = Array.from({ length: 100 }, (_, index) => ({
|
||
Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`,
|
||
Amount: 10,
|
||
Counterparty: "SVK"
|
||
}));
|
||
|
||
const result = await executeAssistantMcpDiscoveryPilot(planner, buildDeps(rows));
|
||
|
||
expect(result.derived_value_flow?.coverage_limited_by_probe_limit).toBe(true);
|
||
expect(result.evidence.unknown_facts).toContain(
|
||
"Complete requested-period coverage is not proven because the MCP discovery probe row limit was reached"
|
||
);
|
||
});
|
||
|
||
it("executes bidirectional value-flow queries and derives guarded net cash flow", async () => {
|
||
const planner = planAssistantMcpDiscovery({
|
||
turnMeaning: {
|
||
asked_domain_family: "counterparty_value",
|
||
asked_action_family: "net_value_flow",
|
||
explicit_entity_candidates: ["SVK"],
|
||
explicit_date_scope: "2020",
|
||
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting"
|
||
}
|
||
});
|
||
const deps = buildSequentialDeps([
|
||
{
|
||
rows: [
|
||
{ Period: "2020-01-15T00:00:00", Amount: 10000, Counterparty: "SVK" },
|
||
{ Period: "2020-02-20T00:00:00", Amount: "2 500,50", Counterparty: "SVK" }
|
||
]
|
||
},
|
||
{
|
||
rows: [{ Period: "2020-03-10T00:00:00", Amount: 4000, Counterparty: "SVK" }]
|
||
}
|
||
]);
|
||
|
||
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
|
||
|
||
expect(result.pilot_status).toBe("executed");
|
||
expect(result.pilot_scope).toBe("counterparty_bidirectional_value_flow_query_movements_v1");
|
||
expect(result.mcp_execution_performed).toBe(true);
|
||
expect(result.executed_primitives).toEqual(["query_movements"]);
|
||
expect(result.derived_value_flow).toBeNull();
|
||
expect(result.derived_bidirectional_value_flow).toMatchObject({
|
||
counterparty: "SVK",
|
||
period_scope: "2020",
|
||
net_amount: 8500.5,
|
||
net_amount_human_ru: "8 500,50 руб.",
|
||
net_direction: "net_incoming",
|
||
coverage_limited_by_probe_limit: false,
|
||
incoming_customer_revenue: {
|
||
rows_matched: 2,
|
||
rows_with_amount: 2,
|
||
total_amount: 12500.5,
|
||
first_movement_date: "2020-01-15",
|
||
latest_movement_date: "2020-02-20"
|
||
},
|
||
outgoing_supplier_payout: {
|
||
rows_matched: 1,
|
||
rows_with_amount: 1,
|
||
total_amount: 4000,
|
||
first_movement_date: "2020-03-10",
|
||
latest_movement_date: "2020-03-10"
|
||
}
|
||
});
|
||
expect(result.evidence.confirmed_facts[0]).toContain("bidirectional value-flow rows");
|
||
expect(result.evidence.inferred_facts[0]).toContain("net value-flow");
|
||
expect(result.evidence.unknown_facts).toContain(
|
||
"Full bidirectional value-flow outside the checked period is not proven by this MCP discovery pilot"
|
||
);
|
||
expect(result.reason_codes).toContain("pilot_bidirectional_value_flow_recipes_selected");
|
||
expect(result.reason_codes).toContain("pilot_derived_bidirectional_value_flow_from_confirmed_rows");
|
||
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("derives monthly bidirectional value-flow breakdown when the turn explicitly asks by month", async () => {
|
||
const planner = planAssistantMcpDiscovery({
|
||
turnMeaning: {
|
||
asked_domain_family: "counterparty_value",
|
||
asked_action_family: "net_value_flow",
|
||
asked_aggregation_axis: "month",
|
||
explicit_entity_candidates: ["SVK"],
|
||
explicit_date_scope: "2020",
|
||
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting"
|
||
}
|
||
});
|
||
const deps = buildSequentialDeps([
|
||
{
|
||
rows: [
|
||
{ Period: "2020-01-15T00:00:00", Amount: 10000, Counterparty: "SVK" },
|
||
{ Period: "2020-02-20T00:00:00", Amount: "2 500,50", Counterparty: "SVK" }
|
||
]
|
||
},
|
||
{
|
||
rows: [
|
||
{ Period: "2020-01-10T00:00:00", Amount: 4000, Counterparty: "SVK" },
|
||
{ Period: "2020-02-11T00:00:00", Amount: 1000, Counterparty: "SVK" }
|
||
]
|
||
}
|
||
]);
|
||
|
||
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
|
||
|
||
expect(result.derived_bidirectional_value_flow?.aggregation_axis).toBe("month");
|
||
expect(result.derived_bidirectional_value_flow?.monthly_breakdown).toMatchObject([
|
||
{
|
||
month_bucket: "2020-01",
|
||
incoming_total_amount: 10000,
|
||
incoming_rows_with_amount: 1,
|
||
outgoing_total_amount: 4000,
|
||
outgoing_rows_with_amount: 1,
|
||
net_amount: 6000,
|
||
net_direction: "net_incoming"
|
||
},
|
||
{
|
||
month_bucket: "2020-02",
|
||
incoming_total_amount: 2500.5,
|
||
incoming_rows_with_amount: 1,
|
||
outgoing_total_amount: 1000,
|
||
outgoing_rows_with_amount: 1,
|
||
net_amount: 1500.5,
|
||
net_direction: "net_incoming"
|
||
}
|
||
]);
|
||
expect(result.derived_bidirectional_value_flow?.monthly_breakdown[0]?.incoming_total_amount_human_ru).toContain("10 000");
|
||
expect(result.derived_bidirectional_value_flow?.monthly_breakdown[0]?.net_amount_human_ru).toContain("6 000");
|
||
expect(result.derived_bidirectional_value_flow?.monthly_breakdown[1]?.incoming_total_amount_human_ru).toContain("2 500,50");
|
||
expect(result.derived_bidirectional_value_flow?.monthly_breakdown[1]?.net_amount_human_ru).toContain("1 500,50");
|
||
expect(result.evidence.inferred_facts).toContain(
|
||
"Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows"
|
||
);
|
||
expect(result.reason_codes).toContain("pilot_derived_bidirectional_monthly_breakdown_from_confirmed_rows");
|
||
});
|
||
|
||
it("keeps non-lifecycle ready plans unsupported until a dedicated pilot exists", async () => {
|
||
const planner = planAssistantMcpDiscovery({
|
||
turnMeaning: {
|
||
asked_domain_family: "counterparty_documents",
|
||
asked_action_family: "list_documents",
|
||
explicit_entity_candidates: ["SVK"]
|
||
}
|
||
});
|
||
const deps = buildDeps([]);
|
||
|
||
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
|
||
|
||
expect(result.pilot_status).toBe("unsupported");
|
||
expect(result.mcp_execution_performed).toBe(false);
|
||
expect(result.skipped_primitives).toEqual(["resolve_entity_reference", "query_documents", "probe_coverage"]);
|
||
expect(result.reason_codes).toContain("pilot_scope_unsupported_for_live_execution");
|
||
expect(deps.executeAddressMcpQuery).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("records MCP errors as limitations without converting them into facts", async () => {
|
||
const planner = planAssistantMcpDiscovery({
|
||
turnMeaning: {
|
||
asked_domain_family: "counterparty_lifecycle",
|
||
asked_action_family: "activity_duration",
|
||
explicit_entity_candidates: ["SVK"]
|
||
}
|
||
});
|
||
const deps = buildDeps([], "MCP fetch failed: timeout");
|
||
|
||
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
|
||
|
||
expect(result.pilot_status).toBe("executed");
|
||
expect(result.mcp_execution_performed).toBe(true);
|
||
expect(result.evidence.evidence_status).toBe("insufficient");
|
||
expect(result.evidence.confirmed_facts).toEqual([]);
|
||
expect(result.derived_activity_period).toBeNull();
|
||
expect(result.query_limitations).toContain("MCP fetch failed: timeout");
|
||
expect(result.reason_codes).toContain("pilot_query_documents_mcp_error");
|
||
});
|
||
});
|