Этап 4 / Волна 10: корректировка settlement-кейса — доменная фиксация синтеза, честное покрытие, удержание фокуса / Этап 4 / Волна 11: бизнес-якоря, доменное заземление и устранение утечки дебага
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user