NODEDC_1C/llm_normalizer/backend/tests/assistantMcpRuntimeBridge.t...

312 lines
12 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 fs from "fs";
import os from "os";
import path from "path";
import { afterEach, describe, expect, it, vi } from "vitest";
const MCP_FLAG = "FEATURE_ASSISTANT_MCP_RUNTIME_V1";
const MCP_PROXY = "ASSISTANT_MCP_PROXY_URL";
const MCP_CHANNEL = "ASSISTANT_MCP_CHANNEL";
const ORIGINAL_ENV = {
[MCP_FLAG]: process.env[MCP_FLAG],
[MCP_PROXY]: process.env[MCP_PROXY],
[MCP_CHANNEL]: process.env[MCP_CHANNEL]
};
const TEMP_DIRS: string[] = [];
function restoreEnv(): void {
for (const key of [MCP_FLAG, MCP_PROXY, MCP_CHANNEL] as const) {
const original = ORIGINAL_ENV[key];
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
}
}
function cleanupTempDirs(): void {
for (const dir of TEMP_DIRS.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function createSnapshotRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "assistant-mcp-bridge-"));
TEMP_DIRS.push(root);
return root;
}
describe.sequential("assistant MCP runtime bridge", () => {
afterEach(() => {
vi.unstubAllGlobals();
restoreEnv();
cleanupTempDirs();
vi.resetModules();
});
it("does not call MCP when runtime flag is disabled", async () => {
process.env[MCP_FLAG] = "0";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
const result = await dataLayer.executeRouteRuntime("hybrid_store_plus_live", "Почему по счету 60.01 долг остался?");
expect(fetchMock).not.toHaveBeenCalled();
expect(result.summary.live_mcp).toBeUndefined();
});
it("uses MCP live probe for hybrid route when runtime flag is enabled", async () => {
process.env[MCP_FLAG] = "1";
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
process.env[MCP_CHANNEL] = "default";
const payload = JSON.stringify({
success: true,
data: [
{
Период: "2026-03-01T00:00:00",
Регистратор: "Списание с расчетного счета 0001",
СчетДт: "60.01",
СчетКт: "51",
Сумма: 15000
},
{
Период: "2026-03-02T00:00:00",
Регистратор: "Операция бухгалтерская 0002",
СчетДт: "91.02",
СчетКт: "51",
Сумма: 900
}
]
});
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
const result = await dataLayer.executeRouteRuntime("hybrid_store_plus_live", "Проверь 60.01 и 60.02: оплата есть, долг остался");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.status).toBe("ok");
expect(result.items.length).toBeGreaterThan(0);
const summary = result.summary as Record<string, unknown>;
const liveSummary = summary.live_mcp as Record<string, unknown>;
expect(liveSummary.status).toBe("ok");
expect(liveSummary.channel).toBe("default");
const firstItem = result.items[0] as Record<string, unknown>;
expect(firstItem.source_layer).toBe("mcp_live_probe");
});
it("keeps snapshot fallback when MCP responds with error", async () => {
process.env[MCP_FLAG] = "1";
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
process.env[MCP_CHANNEL] = "default";
const payload = JSON.stringify({
success: false,
data: null,
error: "channel_not_connected"
});
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
const result = await dataLayer.executeRouteRuntime("hybrid_store_plus_live", "Проверь 60.01 остаток");
expect(fetchMock).toHaveBeenCalledTimes(1);
const summary = result.summary as Record<string, unknown>;
const liveSummary = summary.live_mcp as Record<string, unknown>;
expect(liveSummary.status).toBe("error");
expect(result.limitations.some((item) => /live/i.test(item))).toBe(true);
});
it("does not inject out-of-scope rows when account filter matched zero", async () => {
process.env[MCP_FLAG] = "1";
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
process.env[MCP_CHANNEL] = "default";
const payload = JSON.stringify({
success: true,
data: [
{
Период: "2026-03-01T00:00:00",
Регистратор: "Операция бухгалтерская 0002",
СчетДт: "91.02",
СчетКт: "51",
Сумма: 900
}
]
});
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
const result = await dataLayer.executeRouteRuntime("hybrid_store_plus_live", "Check account 60 only");
const summary = result.summary as Record<string, unknown>;
const liveSummary = summary.live_mcp as Record<string, unknown>;
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(Array.isArray(liveSummary.account_scope)).toBe(true);
expect((liveSummary.account_scope as string[]).some((item) => String(item).includes("60"))).toBe(true);
expect(liveSummary.matched_rows).toBe(0);
expect(liveSummary.returned_rows).toBe(0);
expect(result.items).toHaveLength(0);
});
it("uses claim-bound live call sequence for RBP lifecycle query", async () => {
process.env[MCP_FLAG] = "1";
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
process.env[MCP_CHANNEL] = "default";
const payload = JSON.stringify({
success: true,
data: [
{
Период: "2020-07-31T00:00:00",
Регистратор: "Списание РБП за Июль 2020",
СчетДт: "20.01",
СчетКт: "97.01",
Сумма: 5000
},
{
Период: "2020-07-31T00:00:00",
Регистратор: "Закрытие месяца Июль 2020",
СчетДт: "97.01",
СчетКт: "20.01",
Сумма: 5000
}
]
});
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
const result = await dataLayer.executeRouteRuntime(
"hybrid_store_plus_live",
"31 июля прошло Списание РБП за июль 2020 на 5000. Есть ли хвост РБП?"
);
expect(fetchMock).toHaveBeenCalledTimes(4);
const summary = result.summary as Record<string, unknown>;
const liveSummary = summary.live_mcp as Record<string, unknown>;
expect(liveSummary.claim_type).toBe("prove_rbp_tail_state");
expect(Array.isArray(liveSummary.required_live_calls)).toBe(true);
expect((liveSummary.required_live_calls as unknown[]).length).toBe(4);
expect(Array.isArray(liveSummary.executed_live_calls)).toBe(true);
expect((liveSummary.executed_live_calls as unknown[]).length).toBe(4);
const rbpCallLimits = fetchMock.mock.calls.map(([, requestInit]) => {
const init = requestInit as { body?: string };
return Number(JSON.parse(String(init.body ?? "{}")).limit ?? 0);
});
expect(rbpCallLimits).toEqual([96, 96, 96, 128]);
expect(liveSummary.matched_rows).toBeGreaterThan(0);
expect(result.items.some((item) => Array.isArray((item as Record<string, unknown>).relation_pattern_hits))).toBe(true);
});
it("uses claim-bound live call sequence for fixed-asset amortization coverage query", async () => {
process.env[MCP_FLAG] = "1";
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
process.env[MCP_CHANNEL] = "default";
const payload = JSON.stringify({
success: true,
data: [
{
period: "2020-07-31T00:00:00",
registrator: "Начисление амортизации Июль 2020",
account_dt: "20.01",
account_kt: "02.01",
amount: 2471.52
},
{
period: "2020-07-31T00:00:00",
registrator: "Начисление амортизации Июль 2020",
account_dt: "20.01",
account_kt: "02.01",
amount: 2465.28
}
]
});
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
const result = await dataLayer.executeRouteRuntime(
"hybrid_store_plus_live",
"31 июля начислена амортизация тремя суммами — 2 471,52, 2 465,28 и 849,83. Есть риск, что объект ОС не попал в амортизацию?"
);
expect(fetchMock).toHaveBeenCalledTimes(4);
const summary = result.summary as Record<string, unknown>;
const liveSummary = summary.live_mcp as Record<string, unknown>;
expect(liveSummary.claim_type).toBe("prove_fixed_asset_amortization_coverage");
expect(liveSummary.source_profile).toBe("claim_bound_fa_live_path");
expect(Array.isArray(liveSummary.required_live_calls)).toBe(true);
expect((liveSummary.required_live_calls as unknown[]).length).toBe(4);
expect(Array.isArray(liveSummary.executed_live_calls)).toBe(true);
expect((liveSummary.executed_live_calls as unknown[]).length).toBe(4);
const faCallLimits = fetchMock.mock.calls.map(([, requestInit]) => {
const init = requestInit as { body?: string };
return Number(JSON.parse(String(init.body ?? "{}")).limit ?? 0);
});
expect(faCallLimits).toEqual([96, 96, 128, 128]);
expect(liveSummary.matched_rows).toBeGreaterThan(0);
expect(result.items.some((item) => (item as Record<string, unknown>).fa_expected_set_candidate === true)).toBe(true);
expect(result.items.some((item) => (item as Record<string, unknown>).fa_actual_set_candidate === true)).toBe(true);
});
it("uses claim-bound VAT live path instead of supplier-tail generic probe for VAT chain query", async () => {
process.env[MCP_FLAG] = "1";
process.env[MCP_PROXY] = "http://127.0.0.1:6003";
process.env[MCP_CHANNEL] = "default";
const payload = JSON.stringify({
success: true,
data: [
{
period: "2020-07-15T00:00:00",
registrator: "Реализация товаров 0001",
account_dt: "62.01",
account_kt: "90.01",
amount: 1400
},
{
period: "2020-07-15T00:00:00",
registrator: "Счет-фактура выданный 0001",
account_dt: "90.03",
account_kt: "68.02",
amount: 233.33
}
]
});
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot());
const result = await dataLayer.executeRouteRuntime(
"hybrid_store_plus_live",
"По поставщику и счету-фактуре проверь НДС-цепочку: есть ли выпадение между документом, регистром и книгой покупок?"
);
expect(fetchMock).toHaveBeenCalledTimes(4);
const summary = result.summary as Record<string, unknown>;
const liveSummary = summary.live_mcp as Record<string, unknown>;
expect(liveSummary.claim_type).toBe("prove_vat_chain_completeness");
expect(liveSummary.query_subject).toBe("vat_chain_conflict");
expect(liveSummary.source_profile).toBe("claim_bound_vat_live_path");
expect(Array.isArray(liveSummary.required_live_calls)).toBe(true);
expect((liveSummary.required_live_calls as unknown[]).length).toBe(4);
expect(Array.isArray(liveSummary.account_scope)).toBe(true);
expect(liveSummary.account_scope).toEqual(["19", "68"]);
});
});