NODEDC_1C/llm_normalizer/backend/tests/assistantDataLayerGraphCrit...

287 lines
10 KiB
TypeScript
Raw Permalink 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 GRAPH_RUNTIME_FLAG = "FEATURE_ASSISTANT_GRAPH_RUNTIME_V1";
const ORIGINAL_GRAPH_RUNTIME_FLAG = process.env[GRAPH_RUNTIME_FLAG];
const TEMP_DIRS: string[] = [];
function restoreGraphFlag(): void {
if (ORIGINAL_GRAPH_RUNTIME_FLAG === undefined) {
delete process.env[GRAPH_RUNTIME_FLAG];
return;
}
process.env[GRAPH_RUNTIME_FLAG] = ORIGINAL_GRAPH_RUNTIME_FLAG;
}
function cleanupTempDirs(): void {
for (const dir of TEMP_DIRS.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function createSnapshotRoot(records: Array<Record<string, unknown>>): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "assistant-graph-critical-"));
TEMP_DIRS.push(root);
fs.writeFileSync(
path.resolve(root, "09_samples_key_fields_Recorder_Ref_Supplier_Buyer_Responsible.json"),
JSON.stringify({ records }, null, 2),
"utf-8"
);
return root;
}
function buildRecord(input: {
id: string;
counterparty: string;
description: string;
account?: string;
period?: string;
unknownLinks?: number;
withDocumentLink?: boolean;
recorder?: string | null;
}): Record<string, unknown> {
return {
source_entity: "Document",
source_id: input.id,
display_name: input.id,
unknown_link_count: input.unknownLinks ?? 0,
attributes: {
Recorder: input.recorder === null ? "" : (input.recorder ?? `${input.id}-REC`),
Period: input.period ?? "2020-06-15T00:00:00",
Description: input.description,
Account: input.account ?? "60"
},
links: [
{
relation: "document_has_counterparty",
target_entity: "Counterparty",
target_id: input.counterparty,
source_field: "Counterparty"
},
...(input.withDocumentLink === false
? []
: [
{
relation: "document_refers_to_document",
target_entity: "Document",
target_id: `${input.id}-LINK`,
source_field: "Recorder"
}
])
]
};
}
async function executeHybrid(input: {
flag: "0" | "1";
query: string;
records: Array<Record<string, unknown>>;
}) {
process.env[GRAPH_RUNTIME_FLAG] = input.flag;
vi.resetModules();
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot(input.records));
return dataLayer.executeRoute("hybrid_store_plus_live", input.query);
}
function summaryObject(result: { summary: Record<string, unknown> }): Record<string, unknown> {
return result.summary as Record<string, unknown>;
}
function graphTraversal(result: { summary: Record<string, unknown> }): Record<string, unknown> {
const summary = summaryObject(result);
return (summary.graph_traversal as Record<string, unknown>) ?? {};
}
describe.sequential("stage4 graph critical supplemental coverage", () => {
afterEach(() => {
cleanupTempDirs();
restoreGraphFlag();
vi.resetModules();
});
it("captures neighbor branch lifting when linked branch is outside primary 97 scope", async () => {
const result = await executeHybrid({
flag: "1",
query: "Покажи по 97-му, где видно, что движение началось, но до ожидаемого закрытия не дошло.",
records: [
buildRecord({
id: "NBR-1",
counterparty: "CP-NBR",
account: "97",
description: "deferred expense writeoff vat invoice linked branch",
period: "2020-06-21T00:00:00"
})
]
});
const traversal = graphTraversal(result);
expect(Number(traversal.neighbor_branch_lifted_candidates ?? 0)).toBeGreaterThan(0);
expect((traversal.ranking_shift_signals as string[]).includes("neighbor_branch_lifting")).toBe(true);
});
it("surfaces cross-branch inconsistency as graph-critical conflict signal", async () => {
const result = await executeHybrid({
flag: "1",
query: "Проверь по НДС, где документы и регистры показывают разную картину по одной и той же операции.",
records: [
buildRecord({
id: "CBR-1",
counterparty: "CP-CBR",
account: "68",
description: "bank payment vat invoice register conflict operation",
period: "2020-06-22T00:00:00"
})
]
});
const traversal = graphTraversal(result);
const signalCounts = (traversal.signal_counts as Record<string, unknown>) ?? {};
expect(Number(signalCounts.conflicting_transition ?? 0)).toBeGreaterThan(0);
expect(Number(traversal.cross_branch_conflict_candidates ?? 0)).toBeGreaterThan(0);
});
it("keeps terminal gap explicit instead of collapsing it into generic anomaly", async () => {
const result = await executeHybrid({
flag: "1",
query: "Что сейчас сильнее всего мешает закрытию периода не по отдельному документу, а по связанной цепочке операций?",
records: [
buildRecord({
id: "TRM-1",
counterparty: "CP-TRM",
account: "97",
description: "period close deferred expense chain almost completed",
period: "2020-06-30T23:59:59",
unknownLinks: 1
})
]
});
const traversal = graphTraversal(result);
const signalCounts = (traversal.signal_counts as Record<string, unknown>) ?? {};
expect(Number(signalCounts.terminal_state_gap ?? 0)).toBeGreaterThan(0);
expect(Number(traversal.terminal_gap_candidates ?? 0)).toBeGreaterThan(0);
});
it("shows ranking shift between graph OFF and graph ON for graph-critical contour", async () => {
const records = [
buildRecord({
id: "RS-A",
counterparty: "CP-A",
account: "60",
description: "supplier payment contract settlement contour",
period: "2020-06-10T00:00:00"
}),
buildRecord({
id: "RS-B",
counterparty: "CP-B",
account: "60",
description: "supplier payment contract lifecycle transition",
period: "2020-06-30T23:59:59"
})
];
const query = "Покажи, где по 60-му счёту хвост выглядит не случайным, а похож на реально незавершённый контур.";
const off = await executeHybrid({
flag: "0",
query,
records
});
const on = await executeHybrid({
flag: "1",
query,
records
});
const offItems = off.items as Array<Record<string, unknown>>;
const onItems = on.items as Array<Record<string, unknown>>;
expect(offItems.length).toBeGreaterThan(1);
expect(onItems.length).toBeGreaterThan(1);
expect(String(offItems[0]?.counterparty_id)).toBe("CP-A");
expect(String(onItems[0]?.counterparty_id)).toBe("CP-B");
const offSummary = summaryObject(off);
const onSummary = summaryObject(on);
expect(offSummary.graph_traversal_applied).toBe(false);
expect(onSummary.graph_traversal_applied).toBe(true);
});
it("confirms multi-hop traversal is used for chain reasoning", async () => {
const result = await executeHybrid({
flag: "1",
query: "Где лучше всего видно, что проблема сидит не в одном документе, а в разрыве между связанными объектами?",
records: [
buildRecord({
id: "MHP-1",
counterparty: "CP-MHP",
account: "60",
description: "supplier payment contract bank statement settlement contour",
period: "2020-06-25T00:00:00"
})
]
});
const traversal = graphTraversal(result);
expect(Number(traversal.multi_hop_candidates ?? 0)).toBeGreaterThan(0);
expect(Number(traversal.max_relation_hops ?? 0)).toBeGreaterThanOrEqual(2);
});
it("keeps domain separation across deferred, fixed asset, vat, period close, bank and customer settlement", async () => {
const genericRecords = [
buildRecord({
id: "DOM-1",
counterparty: "CP-DOM",
description: "generic accounting operation",
account: "60"
})
];
const cases: Array<{ query: string; expectedDomain: string }> = [
{
query: "Посмотри, пожалуйста, по поставщикам, где оплата прошла, а расчёт нормально не закрылся.",
expectedDomain: "bank_settlement"
},
{
query: "Проверь по 97-му счёту, где расходы будущих периодов зависли и не дошли до нормального списания.",
expectedDomain: "deferred_expense"
},
{
query: "Покажи по основным средствам, где карточка, документы и начисления между собой не бьются.",
expectedDomain: "fixed_asset"
},
{
query: "Проверь по НДС, где документы и регистры показывают разную картину по одной и той же операции.",
expectedDomain: "vat_flow"
},
{
query: "Что сейчас сильнее всего мешает закрытию периода не по отдельному документу, а по связанной цепочке операций?",
expectedDomain: "period_close"
},
{
query: "Show customer payments where settlement did not close.",
expectedDomain: "customer_settlement"
}
];
const observedDomains: string[] = [];
for (const testCase of cases) {
const result = await executeHybrid({
flag: "1",
query: testCase.query,
records: genericRecords
});
const traversal = graphTraversal(result);
const targetDomains = Array.isArray(traversal.target_domains) ? (traversal.target_domains as string[]) : [];
expect(targetDomains.includes(testCase.expectedDomain)).toBe(true);
observedDomains.push(...targetDomains);
const summary = summaryObject(result);
expect(summary.graph_eligible).toBe(true);
}
const uniqueObserved = Array.from(new Set(observedDomains));
expect(uniqueObserved.length).toBeGreaterThanOrEqual(5);
});
});