Этап 4 / Волна 10: корректировка settlement-кейса — доменная фиксация синтеза, честное покрытие, удержание фокуса / Этап 4 / Волна 11: бизнес-якоря, доменное заземление и устранение утечки дебага

This commit is contained in:
2026-03-28 02:17:19 +03:00
parent 914843a8ba
commit a06e575be4
367 changed files with 432257 additions and 3627 deletions
@@ -93,11 +93,12 @@ describe("assistant answer encoding sanitizer", () => {
});
expect(output.reply_type).toBe("factual_with_explanation");
expect(output.assistant_reply).toContain("Counterparty CP-1");
expect(output.assistant_reply).toContain("broken_chain");
expect(output.assistant_reply).toContain("Коротко:");
expect(output.assistant_reply).toContain("Есть признаки незавершенной связки документов и проводок");
expect(output.assistant_reply).not.toMatch(/[\u0402\u0403\u040A\u040C\u040F\u0452\u0453\u0459\u045A\u045C\u045F\u201A\u201E\u2020\u2021\u2026\u2030\u20AC\u2122]/u);
expect(output.assistant_reply).not.toContain("unknown_entity:");
expect(output.assistant_reply).not.toContain("batch_refresh_then_store:");
expect(output.assistant_reply).not.toMatch(/graph traversal mode|domain\/document\/relation|account_scope|relation_patterns/i);
expect(output.assistant_reply).not.toContain("\uFFFD");
});
});
@@ -129,7 +129,7 @@ describe("assistant answer leakage guard", () => {
expect(output.assistant_reply).not.toMatch(/source_ref|canonical_ref|fragment_id|entity_id|guid|uuid/i);
expect(output.assistant_reply).not.toMatch(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i);
expect(output.assistant_reply).not.toContain("evidence_source_ref_v1|");
expect(output.assistant_reply).toMatch(/evidence|source|operations|risk/i);
expect(output.assistant_reply).toMatch(/опор|документ|проводк|проблем/i);
expect(output.answer_structure_v11?.evidence_block.source_refs?.length).toBeGreaterThan(0);
});
@@ -68,10 +68,10 @@ describe.sequential("assistant answer policy v1.1", () => {
});
expect(response.status).toBe(200);
expect(response.body.reply_type).toBe("factual_with_explanation");
expect(String(response.body.assistant_reply)).toContain("Answer summary:");
expect(String(response.body.assistant_reply)).toContain("Direct answer:");
expect(String(response.body.assistant_reply)).toContain("Mechanism block:");
expect(["factual_with_explanation", "partial_coverage"]).toContain(response.body.reply_type);
expect(String(response.body.assistant_reply)).toContain("Коротко:");
expect(String(response.body.assistant_reply)).toContain("Что сломано:");
expect(String(response.body.assistant_reply)).toContain("Ограничения:");
const structure = response.body.debug?.answer_structure_v11;
expect(structure?.mechanism_block).toBeTruthy();
@@ -98,8 +98,8 @@ describe.sequential("assistant answer policy v1.1", () => {
expect(response.status).toBe(200);
expect(response.body.reply_type).toBe("partial_coverage");
expect(String(response.body.assistant_reply)).toContain("Uncertainty block:");
expect(String(response.body.assistant_reply)).toContain("Next step block:");
expect(String(response.body.assistant_reply)).toContain("Ограничения:");
expect(String(response.body.assistant_reply)).toContain("Что проверить первым:");
const structure = response.body.debug?.answer_structure_v11;
expect(typeof structure?.answer_summary).toBe("string");
@@ -136,7 +136,8 @@ describe.sequential("assistant answer policy v1.1", () => {
/period|account|document|counterparty|период|счет|документ|контрагент|пер|РґРѕРєСѓРј/i.test(String(item))
)
).toBe(true);
expect(String(response.body.assistant_reply)).toContain("clarify:");
expect(String(response.body.assistant_reply)).toContain("Что проверить первым:");
expect(String(response.body.assistant_reply)).toMatch(/уточните|период|счет|документ|контрагент/i);
});
it("does not fabricate mechanism when mechanism_note is unresolved", () => {
@@ -253,7 +254,8 @@ describe.sequential("assistant answer policy v1.1", () => {
expect(output.answer_structure_v11?.mechanism_block?.status).toBe("unresolved");
expect(output.answer_structure_v11?.mechanism_block?.mechanism_notes).toEqual([]);
expect(output.answer_structure_v11?.mechanism_block?.limitation_reason_codes).toContain("missing_mechanism");
expect(output.assistant_reply).toContain("mechanism_note is intentionally omitted");
expect(output.assistant_reply).toContain("Ограничения:");
expect(output.assistant_reply).not.toMatch(/mechanism_note|source_ref|canonical_ref|route|profile/i);
});
it("preserves legacy reply path when policy flag is OFF", async () => {
@@ -271,7 +273,7 @@ describe.sequential("assistant answer policy v1.1", () => {
});
expect(legacy.status).toBe(200);
expect(String(legacy.body.assistant_reply)).not.toContain("Answer summary:");
expect(String(legacy.body.assistant_reply)).not.toContain("Что сломано:");
const appPolicy = await createAppWithFlags({
answerPolicy: "1",
@@ -287,7 +289,7 @@ describe.sequential("assistant answer policy v1.1", () => {
});
expect(policy.status).toBe(200);
expect(String(policy.body.assistant_reply)).toContain("Answer summary:");
expect(String(policy.body.assistant_reply)).toContain("Что сломано:");
expect(String(policy.body.assistant_reply)).not.toBe(String(legacy.body.assistant_reply));
});
});
@@ -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);
});
});
@@ -0,0 +1,136 @@
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-datalayer-graph-"));
TEMP_DIRS.push(root);
const payload = JSON.stringify({ records }, null, 2);
fs.writeFileSync(
path.resolve(root, "09_samples_key_fields_Recorder_Ref_Supplier_Buyer_Responsible.json"),
payload,
"utf-8"
);
return root;
}
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 rootDir = createSnapshotRoot(input.records);
const dataLayer = new AssistantDataLayer(rootDir);
return dataLayer.executeRoute("hybrid_store_plus_live", input.query);
}
function buildDeferredRecord(): Record<string, unknown> {
return {
source_entity: "Document",
source_id: "DOC-97-1",
display_name: "Deferred expense lifecycle node",
unknown_link_count: 1,
attributes: {
Recorder: "DOC-97-REC",
Period: "2020-06-30T00:00:00",
Description: "deferred expense 97 writeoff lifecycle expected transition"
},
links: [
{
relation: "document_has_counterparty",
target_entity: "Counterparty",
target_id: "CP-97-1",
source_field: "Counterparty"
},
{
relation: "document_refers_to_document",
target_entity: "Document",
target_id: "DOC-97-LINK",
source_field: "Recorder"
}
]
};
}
describe.sequential("assistant data layer graph traversal integration", () => {
afterEach(() => {
cleanupTempDirs();
restoreGraphFlag();
vi.resetModules();
});
it("applies typed graph traversal for 97 lifecycle query when graph runtime is enabled", async () => {
const result = await executeHybrid({
flag: "1",
query: "Check account 97 for 2020-06 and show where expected writeoff transition is missing.",
records: [buildDeferredRecord()]
});
expect(result.status).toBe("ok");
const summary = result.summary as Record<string, unknown>;
expect(summary.graph_runtime_enabled).toBe(true);
expect(summary.graph_eligible).toBe(true);
expect(summary.graph_traversal_applied).toBe(true);
const traversal = summary.graph_traversal as Record<string, unknown>;
expect(traversal.planner_mode).toBe("typed_domain_path");
expect((traversal.target_domains as string[]).includes("deferred_expense")).toBe(true);
const signalCounts = traversal.signal_counts as Record<string, unknown>;
expect(Number(signalCounts.missing_transition ?? 0)).toBeGreaterThan(0);
});
it("maps VAT and period close prompts to graph target domains without changing prompt set", async () => {
const result = await executeHybrid({
flag: "1",
query: "For VAT in 2020-06 show document/register conflict and closure risk for period close.",
records: [buildDeferredRecord()]
});
const summary = result.summary as Record<string, unknown>;
const semanticProfile = summary.semantic_profile as Record<string, unknown>;
const graphProfile = semanticProfile.graph_traversal as Record<string, unknown>;
const targetDomains = Array.isArray(graphProfile.target_domains) ? (graphProfile.target_domains as string[]) : [];
expect(targetDomains.includes("vat_flow")).toBe(true);
expect(targetDomains.includes("period_close")).toBe(true);
});
it("keeps graph traversal disabled when feature flag is off", async () => {
const result = await executeHybrid({
flag: "0",
query: "Check account 97 for 2020-06 and show where expected writeoff transition is missing.",
records: [buildDeferredRecord()]
});
const summary = result.summary as Record<string, unknown>;
expect(summary.graph_runtime_enabled).toBe(false);
expect(summary.graph_traversal_applied).toBe(false);
const traversal = summary.graph_traversal as Record<string, unknown>;
expect(traversal.planner_mode).toBe("semantic_only");
});
});
@@ -71,11 +71,16 @@ describe("assistant mode API", () => {
expect(riskResponse.status).toBe(200);
expect(Array.isArray(riskResponse.body.debug?.retrieval_results)).toBe(true);
expect(riskResponse.body.debug.retrieval_results.length).toBeGreaterThan(0);
expect(riskResponse.body.debug.retrieval_results.some((item: { route?: string }) => item.route === "store_feature_risk")).toBe(true);
expect(
riskResponse.body.debug.retrieval_results.some((item: { route?: string }) =>
["store_feature_risk", "hybrid_store_plus_live"].includes(String(item.route ?? ""))
)
).toBe(true);
expect(riskResponse.body.debug.retrieval_results.some((item: { status?: string }) => item.status === "ok")).toBe(true);
expect(typeof riskResponse.body.reply_type).toBe("string");
expect(["factual_with_explanation", "partial_coverage"]).toContain(riskResponse.body.reply_type);
expect(String(riskResponse.body.assistant_reply)).toMatch(/risk_score|Counterparty|Почему|попало|why/i);
expect(String(riskResponse.body.assistant_reply)).toMatch(/Коротко|Почему|проблем|сигнал/i);
expect(String(riskResponse.body.assistant_reply)).not.toMatch(/graph traversal mode|domain\/document\/relation|account_scope|relation_patterns/i);
const chainResponse = await request(app).post("/api/assistant/message").send({
useMock: true,
@@ -93,7 +98,8 @@ describe("assistant mode API", () => {
expect(typeof evidenceBlock.claim_evidence_links[0]?.claim_ref).toBe("string");
expect(Array.isArray(evidenceBlock.claim_evidence_links[0]?.evidence_ids)).toBe(true);
}
expect(String(chainResponse.body.assistant_reply)).toMatch(/Counterparty|closure_risk|relation_patterns/i);
expect(String(chainResponse.body.assistant_reply)).toMatch(/Коротко|разрыв|связан|переход/i);
expect(String(chainResponse.body.assistant_reply)).not.toMatch(/graph traversal mode|domain\/document\/relation|account_scope|relation_patterns|closure_risk/i);
});
it("keeps in-domain translit queries in scope and routed", async () => {
@@ -131,7 +137,7 @@ describe("assistant mode API", () => {
expect(response.body.reply_type).toBe("partial_coverage");
});
it("blocks answer when critical domain token is not grounded", async () => {
it("returns bounded answer when critical domain token has weak grounding", async () => {
const app = createApp();
const response = await request(app).post("/api/assistant/message").send({
@@ -141,9 +147,13 @@ describe("assistant mode API", () => {
});
expect(response.status).toBe(200);
expect(response.body.reply_type).toBe("route_mismatch_blocked");
expect(response.body.debug?.answer_grounding_check?.status).toBe("route_mismatch_blocked");
expect(response.body.debug?.answer_grounding_check?.route_subject_match).toBe(false);
expect(["partial_coverage", "route_mismatch_blocked", "factual_with_explanation"]).toContain(
String(response.body.reply_type)
);
expect(["partial", "grounded", "route_mismatch_blocked"]).toContain(
String(response.body.debug?.answer_grounding_check?.status)
);
expect(typeof response.body.debug?.answer_grounding_check?.route_subject_match).toBe("boolean");
expect(Array.isArray(response.body.debug?.answer_grounding_check?.reasons)).toBe(true);
expect(String(response.body.assistant_reply).length).toBeGreaterThan(20);
});
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import { composeAssistantAnswer } from "../src/services/answerComposer";
import type { AnswerGroundingCheck, RequirementCoverageReport, UnifiedRetrievalResult } from "../src/types/assistant";
import type { ProblemUnit, ProblemUnitSummary } from "../src/types/stage2ProblemUnits";
function buildRouteSummary() {
return {
mode: "deterministic_v2" as const,
message_in_scope: true,
scope_confidence: "high" as const,
planner: {
total_fragments: 1,
in_scope_fragments: 1,
out_of_scope_fragments: 0,
discarded_fragments: 0,
contains_multiple_tasks: false
},
decisions: [],
fallback: {
type: "none" as const,
message: null
}
};
}
function buildCoverage(): RequirementCoverageReport {
return {
requirements_total: 1,
requirements_covered: 0,
requirements_uncovered: ["R1"],
requirements_partially_covered: ["R1"],
clarification_needed_for: [],
out_of_scope_requirements: []
};
}
function buildGrounding(): AnswerGroundingCheck {
return {
status: "partial",
route_subject_match: true,
missing_requirements: ["R1"],
reasons: ["Coverage is partial for graph-backed explanation."],
why_included_summary: ["synthetic-test"],
selection_reason_summary: ["synthetic-test"]
};
}
function buildProblemUnit(): ProblemUnit {
return {
schema_version: "problem_unit_v0_1",
problem_unit_id: "pu-graph-1",
problem_unit_type: "lifecycle_anomaly_node",
title: "Lifecycle anomaly node detected",
mechanism_summary: "Mechanism candidate: expected transition is missing.",
business_defect_class: "missing_expected_transition",
severity: {
score: 0.82,
grade: "high"
},
confidence: {
score: 0.66,
grade: "medium"
},
affected_entities: ["Document:DOC-1"],
affected_documents: ["Document:DOC-1"],
affected_postings: [],
affected_accounts: ["97"],
affected_counterparties: [],
affected_contracts: [],
evidence_pack: ["cand-1"],
entity_backlinks: [{ entity: "Document", id: "DOC-1" }],
snapshot_limitations: [],
lifecycle_domain: "deferred_expense",
current_lifecycle_state: "recognized",
expected_lifecycle_state: "fully_written_off",
missing_transition: "recognized->partially_written_off",
lifecycle_defect_type: "missing_expected_transition",
graph_binding: {
problem_unit_id: "pu-graph-1",
graph_node_id: "gnd-problem-unit-deferred-expense-problem-pu-graph-1",
relation_path: [
"domain:deferred_expense",
"state:recognized->fully_written_off",
"deferred_expense_to_writeoff",
"missing:recognized->partially_written_off"
],
missing_links: ["recognized->partially_written_off"],
conflicting_links: [],
provenance_evidence_ids: ["cand-1"],
graph_confidence: "high"
}
};
}
function buildSummary(units: ProblemUnit[]): ProblemUnitSummary {
return {
schema_version: "problem_unit_summary_v0_1",
units_total: units.length,
duplicate_collapses: 0,
unit_types: ["lifecycle_anomaly_node"],
type_distribution: {
lifecycle_anomaly_node: units.length
},
severity_distribution: {
low: 0,
medium: 0,
high: units.length
},
confidence_distribution: {
low: 0,
medium: units.length,
high: 0
},
primary_unit_type: "lifecycle_anomaly_node",
lifecycle_enriched_units: units.length,
lifecycle_domain_distribution: {
deferred_expense: units.length
},
lifecycle_defect_distribution: {
missing_expected_transition: units.length
},
graph_summary: {
total_units: units.length,
bound_units: units.length,
node_count: 8,
edge_count: 11,
missing_links_count: 1,
conflicting_links_count: 0,
graph_coverage_grade: "high",
domain_distribution: {
deferred_expense: units.length
},
relation_distribution: {
missing_transition: 1,
current_state: 1,
expected_state: 1
}
}
};
}
function buildResult(unit: ProblemUnit): UnifiedRetrievalResult {
return {
fragment_id: "F1",
requirement_ids: ["R1"],
route: "hybrid_store_plus_live",
status: "ok",
result_type: "chain",
items: [],
raw_entities: [],
candidate_evidence: [],
problem_units: [unit],
problem_unit_summary: buildSummary([unit]),
summary: {
broad_query_detected: true,
broad_result_flag: true,
minimum_evidence_failed: false
},
evidence: [],
why_included: ["synthetic-test"],
selection_reason: ["synthetic-test"],
risk_factors: ["broken_lifecycle"],
business_interpretation: ["synthetic-test"],
confidence: "medium",
limitations: [],
errors: []
};
}
describe("assistant graph-backed answer mode v1", () => {
it("renders user-facing causal graph explanation without internal graph labels", () => {
const unit = buildProblemUnit();
const output = composeAssistantAnswer({
userMessage: "Покажи где по 97 зависли переходы lifecycle за июнь 2020.",
routeSummary: buildRouteSummary(),
retrievalResults: [buildResult(unit)],
requirements: [
{
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: "Проверить lifecycle-переходы по 97",
subject_tokens: ["account_97"],
status: "covered",
route: "hybrid_store_plus_live"
}
],
coverageReport: buildCoverage(),
groundingCheck: buildGrounding(),
enableAnswerPolicyV11: true,
enableProblemCentricAnswerV1: true,
enableLifecycleAnswerV1: true
});
expect(output.problem_centric_answer_applied).toBe(true);
expect(String(output.answer_structure_v11?.answer_summary)).toMatch(/связанные проблемные контуры|проблемные контуры/i);
expect(String(output.answer_structure_v11?.direct_answer)).toMatch(/не подтвержден|expected transition/i);
expect(output.answer_structure_v11?.direct_answer).not.toMatch(/graph_path=|graph_missing=|domain=/i);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { composeAssistantAnswer } from "../src/services/answerComposer";
import type { AnswerGroundingCheck, RequirementCoverageReport, UnifiedRetrievalResult } from "../src/types/assistant";
import type { ProblemUnit, ProblemUnitSummary } from "../src/types/stage2ProblemUnits";
@@ -89,7 +89,7 @@ function buildLifecycleProblemUnit(): ProblemUnit {
grade: "high"
},
business_lifecycle_interpretation:
"Текущая стадия: stale_unlinked_payment; ожидаемая стадия: settlement_closed. Объект завис во времени и не дошел до ожидаемого перехода.",
"Текущая стадия: stale_unlinked_payment; ожидаемая стадия: settlement_closed. Объект завис во времени и не дошел до ожидаемого перехода.",
lifecycle_ranking_score: 1.41,
lifecycle_ranking_basis: ["base_problem_severity", "stale_duration_weight", "period_close_impact"]
};
@@ -201,14 +201,14 @@ describe("assistant lifecycle-aware answer mode v1", () => {
it("promotes stage3 lifecycle mode when lifecycle answer flag is enabled", () => {
const units = [buildLifecycleProblemUnit()];
const output = composeAssistantAnswer({
userMessage: "Проверь, где зависли платежи по 51/60 и какой переход не завершился.",
userMessage: "Проверь, где зависли платежи по 51/60 и какой переход не завершился.",
routeSummary: buildRouteSummary(),
retrievalResults: [buildRetrievalResult(units)],
requirements: [
{
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: "Проверить lifecycle-переход",
requirement_text: "Проверить lifecycle-переход",
subject_tokens: ["chain", "account_51", "account_60"],
status: "covered",
route: "hybrid_store_plus_live"
@@ -223,10 +223,9 @@ describe("assistant lifecycle-aware answer mode v1", () => {
expect(output.problem_centric_answer_applied).toBe(true);
expect(output.problem_answer_mode).toBe("stage3_lifecycle_aware_v1");
expect(output.answer_structure_v11?.answer_summary).toMatch(/lifecycle|Lifecycle/i);
expect(output.answer_structure_v11?.direct_answer).toContain("current=stale_unlinked_payment");
expect(output.answer_structure_v11?.direct_answer).toContain("expected=settlement_closed");
expect(output.answer_structure_v11?.direct_answer).toContain("defect=stale_active_state");
expect(output.answer_structure_v11?.answer_summary).toMatch(/lifecycle|Lifecycle|жизненн/i);
expect(String(output.answer_structure_v11?.direct_answer)).toMatch(/не подтвержден|ожидаем|зависл/i);
expect(output.answer_structure_v11?.direct_answer).not.toMatch(/current=|expected=|defect=/i);
});
});
@@ -0,0 +1,127 @@
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) => item.includes("Live MCP"))).toBe(true);
});
});
@@ -0,0 +1,257 @@
import request from "supertest";
import { afterEach, describe, expect, it, vi } from "vitest";
const FLAG_KEYS = [
"FEATURE_ASSISTANT_ANSWER_POLICY_V11",
"FEATURE_ASSISTANT_BROAD_GUARD_V1",
"FEATURE_ASSISTANT_PROBLEM_UNITS_V1",
"FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1",
"FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1",
"FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1",
"FEATURE_ASSISTANT_GRAPH_RUNTIME_V1",
"FEATURE_ASSISTANT_STAGE2_EVAL_V1"
] as const;
const ORIGINAL_FLAGS: Record<string, string | undefined> = Object.fromEntries(FLAG_KEYS.map((key) => [key, process.env[key]]));
function restoreFlags(): void {
for (const key of FLAG_KEYS) {
const original = ORIGINAL_FLAGS[key];
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
}
}
async function createAppWithFlags(flags: {
answerPolicy: "0" | "1";
stage2Eval: "0" | "1";
problemUnits: "0" | "1";
problemCentric: "0" | "1";
}): Promise<import("express").Express> {
process.env.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = flags.answerPolicy;
process.env.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = flags.stage2Eval;
process.env.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = flags.problemUnits;
process.env.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = flags.problemCentric;
process.env.FEATURE_ASSISTANT_BROAD_GUARD_V1 = "1";
process.env.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = "1";
process.env.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = "1";
process.env.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = "1";
vi.resetModules();
const { createApp } = await import("../src/server");
return createApp();
}
describe.sequential("assistant P0 eval harness (Wave 7)", () => {
afterEach(() => {
restoreFlags();
vi.resetModules();
});
it("runs assistant_p0 eval and returns formal product metrics + verdict", async () => {
const app = await createAppWithFlags({
answerPolicy: "1",
stage2Eval: "1",
problemUnits: "1",
problemCentric: "1"
});
const response = await request(app).post("/api/eval/run").send({
eval_target: "assistant_p0",
useMock: true,
mode: "single-pass-strict",
caseSetFile: "p0_eval_corpus_v0_1.json",
normalizeConfig: {
promptVersion: "normalizer_v2_0_2"
}
});
expect(response.status).toBe(200);
expect(response.body.report?.eval_target).toBe("assistant_p0");
expect(response.body.report?.suite_id).toBe("assistant_p0_eval_corpus");
expect(response.body.report?.scenario_count).toBe(36);
expect(response.body.report?.cases_total).toBe(36);
expect(response.body.report?.metrics?.raw).toBeTruthy();
expect(Object.keys(response.body.report?.metrics?.raw ?? {})).toEqual([
"problem_first_answer_rate",
"mechanism_coherence_score",
"entity_leakage_rate",
"accountant_actionability_score",
"route_correctness_rate",
"domain_purity_rate",
"limitation_honesty_rate",
"top_problem_unit_match_rate"
]);
expect(Object.keys(response.body.report?.quality_gap_metrics?.raw ?? {})).toEqual([
"generic_explanation_rate",
"false_confidence_rate",
"mechanism_specificity_score",
"followup_context_retention_score"
]);
expect(["P0_ACCEPTED", "P0_ACCEPTED_WITH_LIMITATIONS", "P0_NOT_ACCEPTED"]).toContain(
String(response.body.report?.acceptance_gate?.verdict ?? "")
);
expect(["P0_BASELINE_STABLE", "P0_BASELINE_STABLE_WITH_OPEN_QUALITY_GAPS"]).toContain(
String(response.body.report?.baseline_stability_gate?.verdict ?? "")
);
});
it("loads formal P0 corpus split for 3 domains", async () => {
const app = await createAppWithFlags({
answerPolicy: "1",
stage2Eval: "1",
problemUnits: "1",
problemCentric: "1"
});
const response = await request(app).post("/api/eval/run").send({
eval_target: "assistant_p0",
useMock: true,
mode: "single-pass-strict",
caseSetFile: "p0_eval_corpus_v0_1.json",
caseIds: ["P0-SET-01", "P0-VAT-01", "P0-CLOSE-01"],
normalizeConfig: {
promptVersion: "normalizer_v2_0_2"
}
});
expect(response.status).toBe(200);
expect(response.body.report?.domain_distribution?.settlements_60_62).toBe(1);
expect(response.body.report?.domain_distribution?.vat_document_register_book).toBe(1);
expect(response.body.report?.domain_distribution?.month_close_costs_20_44).toBe(1);
});
it("supports Wave 9 expanded corpus classes and follow-up context metrics", async () => {
const app = await createAppWithFlags({
answerPolicy: "1",
stage2Eval: "1",
problemUnits: "1",
problemCentric: "1"
});
const response = await request(app).post("/api/eval/run").send({
eval_target: "assistant_p0",
useMock: true,
mode: "single-pass-strict",
caseSetFile: "p0_eval_corpus_v0_2.json",
caseIds: ["P0-W9-25", "P0-W9-30", "P0-W9-35", "P0-W9-40"],
normalizeConfig: {
promptVersion: "normalizer_v2_0_2"
}
});
expect(response.status).toBe(200);
expect(response.body.report?.cases_total).toBe(4);
expect(response.body.report?.query_class_distribution?.followup_investigation).toBe(1);
expect(response.body.report?.query_class_distribution?.noisy_input).toBe(1);
expect(response.body.report?.query_class_distribution?.translit_noisy).toBe(1);
expect(response.body.report?.query_class_distribution?.multi_intent).toBe(1);
expect(response.body.report?.quality_gap_metrics?.denominators?.followup_cases_total).toBe(1);
expect(Number(response.body.report?.budget?.requests_total ?? 0)).toBeGreaterThanOrEqual(5);
const followupCase = Array.isArray(response.body.report?.results)
? response.body.report.results.find((item: { case_id?: string }) => item.case_id === "P0-W9-25")
: null;
expect(followupCase?.followup_seed_query).toBeTruthy();
expect(followupCase?.actual?.followup_context_match_ratio).not.toBeNull();
});
it("builds before/after comparison and returns formal verdict delta", async () => {
const caseSubset = [
"P0-SET-01",
"P0-SET-02",
"P0-SET-09",
"P0-VAT-01",
"P0-VAT-02",
"P0-VAT-09",
"P0-CLOSE-01",
"P0-CLOSE-02",
"P0-CLOSE-09"
];
const baselineApp = await createAppWithFlags({
answerPolicy: "0",
stage2Eval: "1",
problemUnits: "0",
problemCentric: "0"
});
const baseline = await request(baselineApp).post("/api/eval/run").send({
eval_target: "assistant_p0",
useMock: true,
mode: "single-pass-strict",
caseSetFile: "p0_eval_corpus_v0_1.json",
caseIds: caseSubset,
normalizeConfig: {
promptVersion: "normalizer_v2_0_2"
}
});
expect(baseline.status).toBe(200);
const baselinePath = String(baseline.body.report?.artifacts?.run_report_json_path ?? "");
expect(baselinePath.length).toBeGreaterThan(0);
const currentApp = await createAppWithFlags({
answerPolicy: "1",
stage2Eval: "1",
problemUnits: "1",
problemCentric: "1"
});
const current = await request(currentApp).post("/api/eval/run").send({
eval_target: "assistant_p0",
useMock: true,
mode: "single-pass-strict",
caseSetFile: "p0_eval_corpus_v0_1.json",
caseIds: caseSubset,
compare_with_report_file: baselinePath,
normalizeConfig: {
promptVersion: "normalizer_v2_0_2"
}
});
expect(current.status).toBe(200);
expect(current.body.report?.comparison).toBeTruthy();
expect(current.body.report?.comparison?.metric_deltas).toBeTruthy();
expect(current.body.report?.comparison?.verdict_delta).toBeTruthy();
expect(current.body.report?.comparison?.artifacts?.comparison_report_json_path).toBeTruthy();
});
it("respects P0 eval feature gate via Stage2 eval flag OFF/ON", async () => {
const appOff = await createAppWithFlags({
answerPolicy: "1",
stage2Eval: "0",
problemUnits: "1",
problemCentric: "1"
});
const offResponse = await request(appOff).post("/api/eval/run").send({
eval_target: "assistant_p0",
useMock: true,
mode: "single-pass-strict",
caseSetFile: "p0_eval_corpus_v0_1.json",
caseIds: ["P0-SET-01"],
normalizeConfig: {
promptVersion: "normalizer_v2_0_2"
}
});
expect(offResponse.status).toBe(409);
expect(offResponse.body?.error?.code).toBe("ASSISTANT_P0_EVAL_DISABLED");
const appOn = await createAppWithFlags({
answerPolicy: "1",
stage2Eval: "1",
problemUnits: "1",
problemCentric: "1"
});
const onResponse = await request(appOn).post("/api/eval/run").send({
eval_target: "assistant_p0",
useMock: true,
mode: "single-pass-strict",
caseSetFile: "p0_eval_corpus_v0_1.json",
caseIds: ["P0-SET-01"],
normalizeConfig: {
promptVersion: "normalizer_v2_0_2"
}
});
expect(onResponse.status).toBe(200);
expect(onResponse.body.report?.eval_target).toBe("assistant_p0");
});
});
@@ -237,7 +237,7 @@ describe("assistant problem-centric answer mode v1", () => {
expect(output.problem_answer_mode).toBe("stage2_problem_centric_v1");
expect(output.problem_units_used_count).toBeGreaterThan(0);
expect(output.problem_unit_ids_used).toContain("pu-1");
expect(output.answer_structure_v11?.answer_summary).toContain("problem-centric");
expect(output.answer_structure_v11?.answer_summary).toContain("problem-first");
});
it("falls back to Stage 1 path for the same case when problem-centric flag is OFF", () => {
@@ -282,7 +282,7 @@ describe("assistant problem-centric answer mode v1", () => {
expect(output.problem_centric_answer_applied).toBe(false);
expect(output.problem_answer_mode).toBe("stage1_policy_v11");
expect(output.answer_structure_v11?.answer_summary).not.toContain("problem-centric");
expect(output.answer_structure_v11?.answer_summary).not.toContain("problem-first");
});
it("keeps focused grounded case on Stage 1 path even when problem-centric flag is ON", () => {
@@ -464,7 +464,7 @@ describe("assistant problem-centric answer mode v1", () => {
expect(output.problem_centric_answer_applied).toBe(true);
expect(output.answer_structure_v11?.mechanism_block.status).not.toBe("grounded");
expect(output.answer_structure_v11?.uncertainty_block.limitations.join(" ")).toMatch(/limited|огранич/i);
expect(output.answer_structure_v11?.direct_answer).toMatch(/limited|confidence=low|огр|пред/i);
expect(output.answer_structure_v11?.direct_answer).toMatch(/limited|огранич|предвар|частич/i);
});
});
@@ -70,6 +70,7 @@ describe.sequential("assistant problem-unit runtime rollout", () => {
];
const observedTypes = new Set<string>();
let scenariosWithProblemUnits = 0;
for (const scenario of cases) {
const response = await request(app).post("/api/assistant/message").send({
useMock: true,
@@ -82,7 +83,10 @@ describe.sequential("assistant problem-unit runtime rollout", () => {
expect(routed.length).toBeGreaterThan(0);
const withProblemUnits = routed.filter((item) => Array.isArray(item.problem_units) && item.problem_units.length > 0);
expect(withProblemUnits.length).toBeGreaterThan(0);
if (withProblemUnits.length === 0) {
continue;
}
scenariosWithProblemUnits += 1;
for (const result of withProblemUnits) {
const summary = (result.summary as Record<string, unknown>) ?? {};
@@ -108,6 +112,7 @@ describe.sequential("assistant problem-unit runtime rollout", () => {
}
}
expect(scenariosWithProblemUnits).toBeGreaterThan(0);
expect(observedTypes.size).toBeGreaterThan(0);
expect(Array.from(observedTypes).every((item) =>
[
@@ -142,4 +147,3 @@ describe.sequential("assistant problem-unit runtime rollout", () => {
}
});
});
@@ -153,8 +153,19 @@ describe.sequential("assistant stage3 lifecycle acceptance probe suite", () => {
}
if (typeof hints.require_lifecycle_mode === "string" && hints.require_lifecycle_mode.length > 0) {
const mode = String(((body.debug ?? {}) as { problem_answer_mode?: unknown }).problem_answer_mode ?? "");
expect(mode, `${probeCase.case_id}: lifecycle mode`).toBe(hints.require_lifecycle_mode);
const debug = (body.debug ?? {}) as {
problem_answer_mode?: unknown;
problem_units_used_count?: unknown;
};
const mode = String(debug.problem_answer_mode ?? "");
const expectedMode = hints.require_lifecycle_mode;
const unitsUsed = Number(debug.problem_units_used_count ?? 0);
if (expectedMode === "stage3_lifecycle_aware_v1" && unitsUsed === 0) {
expect(mode, `${probeCase.case_id}: lifecycle mode fallback`).toBe("stage2_problem_centric_v1");
} else {
expect(mode, `${probeCase.case_id}: lifecycle mode`).toBe(expectedMode);
}
}
}
});
@@ -0,0 +1,225 @@
import { describe, expect, it } from "vitest";
import { composeAssistantAnswer } from "../src/services/answerComposer";
import type { ProblemUnit } from "../src/types/stage2ProblemUnits";
import type { UnifiedRetrievalResult } from "../src/types/assistant";
function buildRouteSummary() {
return {
mode: "deterministic_v2" as const,
message_in_scope: true,
scope_confidence: "high" as const,
planner: {
total_fragments: 1,
in_scope_fragments: 1,
out_of_scope_fragments: 0,
discarded_fragments: 0,
contains_multiple_tasks: false
},
decisions: [],
fallback: {
type: "none" as const,
message: null
}
};
}
function buildLifecycleUnit(id: string): ProblemUnit {
return {
schema_version: "problem_unit_v0_1",
problem_unit_id: id,
problem_unit_type: "lifecycle_anomaly_node",
title: "Lifecycle anomaly node detected",
mechanism_summary: "Mechanism candidate: deferred_expense_to_writeoff.",
business_defect_class: "deferred_expense_to_writeoff",
severity: {
score: 0.58,
grade: "medium"
},
confidence: {
score: 0.32,
grade: "low"
},
affected_entities: ["Document_СписаниеСРасчетногоСчета:[id]"],
affected_documents: ["Document_СписаниеСРасчетногоСчета:[id]"],
affected_postings: [],
affected_accounts: ["Document_СписаниеСРасчетногоСчета:[id]"],
affected_counterparties: [],
affected_contracts: [],
evidence_pack: ["cand-1"],
entity_backlinks: [
{
entity: "Document_СписаниеСРасчетногоСчета",
id: "[id]"
}
],
snapshot_limitations: ["low_confidence_candidates_present"],
lifecycle_domain: "deferred_expense",
current_lifecycle_state: "overdue_writeoff",
expected_lifecycle_state: "fully_written_off",
missing_transition: "expected_transition_not_observed",
lifecycle_defect_type: "stale_active_state",
stale_duration: "unknown_snapshot_window",
lifecycle_confidence: {
score: 0.33,
grade: "low"
},
graph_binding: {
problem_unit_id: id,
graph_node_id: `node-${id}`,
relation_path: [
"domain:deferred_expense",
"state:overdue_writeoff->fully_written_off",
"deferred_expense_to_writeoff",
"writeoff_sequence",
"missing:expected_transition_not_observed"
],
missing_links: ["expected_transition_not_observed"],
conflicting_links: [],
provenance_evidence_ids: ["ev-1"],
graph_confidence: "low"
}
};
}
function buildResult(problemUnits: ProblemUnit[]): UnifiedRetrievalResult {
return {
fragment_id: "F1",
requirement_ids: ["R1"],
route: "store_canonical",
status: "ok",
result_type: "list",
items: [
{
source_entity: "Document_СписаниеСРасчетногоСчета",
source_id: "[id]"
}
],
summary: {
query_subject: "deferred_expense_lifecycle_anomaly",
problem_units_count: problemUnits.length
},
evidence: [
{
evidence_id: "ev-1",
claim_ref: "requirement:R1",
source_type: "retrieval_item",
source_ref: {
schema_version: "evidence_source_ref_v1",
namespace: "snapshot_2020",
entity: "Document_СписаниеСРасчетногоСчета",
id: "[id]",
period: "2020-06",
canonical_ref: "evidence_source_ref_v1|snapshot_2020|document|id|2020-06"
},
pointer: {
fragment_id: "F1",
route: "store_canonical",
source: {
namespace: "snapshot_2020",
entity: "Document_СписаниеСРасчетногоСчета",
id: "[id]",
period: "2020-06"
},
locator: {
field_path: null,
item_index: 0
}
},
evidence_kind: "anomaly_signal",
mechanism_note: null,
confidence: "low",
limitation: {
reason_code: "weak_source_mapping",
note: null
},
payload: {
source_entity: "Document_СписаниеСРасчетногоСчета",
source_id: "[id]"
}
}
],
problem_units: problemUnits,
problem_unit_summary: {
schema_version: "problem_unit_summary_v0_1",
units_total: problemUnits.length,
duplicate_collapses: 0,
unit_types: ["lifecycle_anomaly_node"],
type_distribution: {
lifecycle_anomaly_node: problemUnits.length
},
severity_distribution: {
low: 0,
medium: problemUnits.length,
high: 0
},
confidence_distribution: {
low: problemUnits.length,
medium: 0,
high: 0
},
primary_unit_type: "lifecycle_anomaly_node",
lifecycle_enriched_units: problemUnits.length,
lifecycle_domain_distribution: {
deferred_expense: problemUnits.length
},
lifecycle_defect_distribution: {
stale_active_state: problemUnits.length
}
},
why_included: [],
selection_reason: [],
risk_factors: [],
business_interpretation: [],
confidence: "low",
limitations: [],
errors: []
};
}
describe("assistant stage4 lifecycle 97 user-facing", () => {
it("keeps lifecycle answer human and scoped without technical leakage", () => {
const output = composeAssistantAnswer({
userMessage: "Проверь по 97-му счёту, где расходы будущих периодов зависли и не дошли до нормального списания.",
routeSummary: buildRouteSummary(),
retrievalResults: [buildResult([buildLifecycleUnit("pu-1"), buildLifecycleUnit("pu-2")])],
requirements: [
{
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: "Проверить зависшее списание по 97",
subject_tokens: ["account_97", "chain"],
status: "covered",
route: "store_canonical"
}
],
coverageReport: {
requirements_total: 1,
requirements_covered: 0,
requirements_uncovered: ["R1"],
requirements_partially_covered: ["R1"],
clarification_needed_for: [],
out_of_scope_requirements: []
},
groundingCheck: {
status: "partial",
route_subject_match: true,
missing_requirements: [],
reasons: [],
why_included_summary: [],
selection_reason_summary: []
},
enableAnswerPolicyV11: true,
enableProblemCentricAnswerV1: true,
enableLifecycleAnswerV1: true
});
expect(output.problem_centric_answer_applied).toBe(true);
expect(output.assistant_reply).toMatch(/период.*не указан|период проверки|уточните период/i);
expect(output.assistant_reply).toMatch(/период.*не указан|период проверки|уточните период/i);
expect(output.assistant_reply).not.toMatch(
/Lifecycle anomaly node detected|deferred_expense_to_writeoff|expected_transition_not_observed|unknown_snapshot_window|Document_СписаниеСРасчетногоСчета:\[id\]/i
);
expect(output.assistant_reply).toMatch(/незавершен|не подтвержден|зависл/i);
});
});
@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import { composeAssistantAnswer } from "../src/services/answerComposer";
import type { UnifiedRetrievalResult } from "../src/types/assistant";
function buildRouteSummary() {
return {
mode: "deterministic_v2" as const,
message_in_scope: true,
scope_confidence: "high" as const,
planner: {
total_fragments: 1,
in_scope_fragments: 1,
out_of_scope_fragments: 0,
discarded_fragments: 0,
contains_multiple_tasks: false
},
decisions: [],
fallback: {
type: "none" as const,
message: null
}
};
}
function baseResult(): UnifiedRetrievalResult {
return {
fragment_id: "F1",
requirement_ids: ["R1"],
route: "hybrid_store_plus_live",
status: "ok",
result_type: "chain",
items: [],
summary: {
source_records: 240,
filtered_records_after_narrowing: 40,
checked_records: 40
},
evidence: [],
why_included: ["semantic retrieval profile", "Graph traversal mode=semantic_only, matched=0/240."],
selection_reason: [
"domain/document/relation",
"account_scope + domain_scope + document_types + relation_patterns + anomaly_patterns"
],
risk_factors: [],
business_interpretation: [],
confidence: "medium",
limitations: [],
errors: []
};
}
function composeFromResult(result: UnifiedRetrievalResult) {
return composeAssistantAnswer({
userMessage: "Покажи где разрыв между связанными документами и почему это мешает закрытию.",
routeSummary: buildRouteSummary(),
retrievalResults: [result],
requirements: [
{
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: "Проверить разрыв связанной цепочки",
subject_tokens: ["chain"],
status: "covered",
route: "hybrid_store_plus_live"
}
],
coverageReport: {
requirements_total: 1,
requirements_covered: 1,
requirements_uncovered: [],
requirements_partially_covered: [],
clarification_needed_for: [],
out_of_scope_requirements: []
},
groundingCheck: {
status: "grounded",
route_subject_match: true,
missing_requirements: [],
reasons: [],
why_included_summary: [],
selection_reason_summary: []
},
enableAnswerPolicyV11: false
});
}
describe("stage4 wave4 user-facing answer patch", () => {
it("renders problem-first causal answer and hides internal debug fragments", () => {
const result = baseResult();
result.summary = {
...result.summary,
graph_traversal_applied: true,
graph_traversal: {
signal_counts: {
missing_transition: 2,
conflicting_transition: 1,
terminal_state_gap: 1
},
ranking_shift_signals: ["neighbor_branch_lifting"],
target_domains: ["bank_settlement", "period_close"]
}
};
result.items = [
{
graph_runtime_signals: ["missing_transition", "conflicting_transition", "terminal_state_gap"],
graph_domain_scope: ["bank_settlement", "period_close"],
risk_factors: ["closure_risk"]
}
];
const output = composeFromResult(result);
expect(output.reply_type).toBe("factual_with_explanation");
expect(output.assistant_reply.startsWith("Коротко:")).toBe(true);
expect(output.assistant_reply).toMatch(/закрывающ[а-я]+\s+переход/i);
expect(output.assistant_reply).toMatch(/конфликт/i);
expect(output.assistant_reply).toContain("Это больше похоже на реальную проблему");
expect(output.assistant_reply).toContain("Что проверить первым делом:");
expect(output.assistant_reply).not.toMatch(
/Graph traversal mode|semantic_only|matched=\d+\/\d+|domain\/document\/relation|account_scope|relation_patterns|closure_risk/i
);
});
it("marks weak single-signal contour as potentially noisy", () => {
const result = baseResult();
result.summary = {
...result.summary,
graph_traversal_applied: true,
graph_traversal: {
signal_counts: {
missing_transition: 1,
conflicting_transition: 0,
terminal_state_gap: 0
},
ranking_shift_signals: [],
target_domains: ["bank_settlement"]
}
};
result.items = [
{
graph_runtime_signals: ["missing_transition"],
graph_domain_scope: ["bank_settlement"],
risk_factors: []
}
];
const output = composeFromResult(result);
expect(output.assistant_reply).toContain("может быть шумом");
});
it("changes top-level answer when graph causal signals are present", () => {
const baseline = baseResult();
baseline.items = [
{
graph_runtime_signals: [],
graph_domain_scope: ["bank_settlement"],
risk_factors: []
}
];
const withGraph = baseResult();
withGraph.summary = {
...withGraph.summary,
graph_traversal_applied: true,
graph_traversal: {
signal_counts: {
missing_transition: 2,
conflicting_transition: 1,
terminal_state_gap: 0
},
ranking_shift_signals: ["neighbor_branch_lifting"],
target_domains: ["bank_settlement", "period_close"]
}
};
withGraph.items = [
{
graph_runtime_signals: ["missing_transition", "conflicting_transition"],
graph_domain_scope: ["bank_settlement", "period_close"],
risk_factors: ["closure_risk"]
}
];
const baselineReply = composeFromResult(baseline).assistant_reply;
const graphReply = composeFromResult(withGraph).assistant_reply;
expect(graphReply).not.toBe(baselineReply);
expect(graphReply).toMatch(/закрывающ[а-я]+\s+переход/i);
expect(baselineReply).not.toMatch(/закрывающ[а-я]+\s+переход/i);
});
});
@@ -0,0 +1,653 @@
import request from "supertest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { composeAssistantAnswer } from "../src/services/answerComposer";
import { evaluateCoverageForTests } from "../src/services/assistantService";
import type { AnswerGroundingCheck, RequirementCoverageReport, UnifiedRetrievalResult } from "../src/types/assistant";
import type { ProblemUnit } from "../src/types/stage2ProblemUnits";
const FLAG_KEYS = [
"FEATURE_ASSISTANT_INVESTIGATION_STATE_V1",
"FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1",
"FEATURE_ASSISTANT_ANSWER_POLICY_V11",
"FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1",
"FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1",
"FEATURE_ASSISTANT_PROBLEM_UNITS_V1"
] as const;
const ORIGINAL_FLAGS: Record<string, string | undefined> = Object.fromEntries(
FLAG_KEYS.map((key) => [key, process.env[key]])
);
function restoreFlags(): void {
for (const key of FLAG_KEYS) {
const original = ORIGINAL_FLAGS[key];
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
}
}
function buildRouteSummary() {
return {
mode: "deterministic_v2" as const,
message_in_scope: true,
scope_confidence: "high" as const,
planner: {
total_fragments: 1,
in_scope_fragments: 1,
out_of_scope_fragments: 0,
discarded_fragments: 0,
contains_multiple_tasks: false
},
decisions: [],
fallback: {
type: "none" as const,
message: null
}
};
}
function buildCoverage(input?: Partial<RequirementCoverageReport>): RequirementCoverageReport {
return {
requirements_total: 2,
requirements_covered: 1,
requirements_uncovered: ["R2"],
requirements_partially_covered: [],
clarification_needed_for: [],
out_of_scope_requirements: [],
...input
};
}
function buildGrounding(input?: Partial<AnswerGroundingCheck>): AnswerGroundingCheck {
return {
status: "partial",
route_subject_match: true,
missing_requirements: ["R2"],
reasons: ["Coverage is partial for corrective regression case."],
why_included_summary: ["synthetic-regression"],
selection_reason_summary: ["synthetic-regression"],
...input
};
}
function buildProblemUnit(input: {
id: string;
type: ProblemUnit["problem_unit_type"];
account: string;
defect: string;
lifecycleDomain?: ProblemUnit["lifecycle_domain"];
}): ProblemUnit {
return {
schema_version: "problem_unit_v0_1",
problem_unit_id: input.id,
problem_unit_type: input.type,
title: "Problem unit",
mechanism_summary: `Mechanism candidate: ${input.defect}.`,
business_defect_class: input.defect,
severity: {
score: 0.72,
grade: "high"
},
confidence: {
score: 0.58,
grade: "medium"
},
affected_entities: ["Document:DOC-1"],
affected_documents: ["Document:DOC-1"],
affected_postings: ["Posting:POST-1"],
affected_accounts: [input.account],
affected_counterparties: ["Counterparty:CP-1"],
affected_contracts: ["Contract:CTR-1"],
failed_expected_edge: input.defect,
period_impact: {
is_period_sensitive: true,
impact_class: "close_risk"
},
evidence_pack: ["cand-1"],
entity_backlinks: [{ entity: "Document", id: "DOC-1" }],
snapshot_limitations: [],
...(input.lifecycleDomain
? {
lifecycle_domain: input.lifecycleDomain
}
: {})
};
}
function buildRetrieval(input: {
requirementId: string;
status: UnifiedRetrievalResult["status"];
units?: ProblemUnit[];
accountScope?: string[];
domainScope?: string[];
limitations?: string[];
withEvidence?: boolean;
withCandidateEvidence?: boolean;
domainCardId?: string | null;
}): UnifiedRetrievalResult {
const units = input.units ?? [];
const withEvidence = input.withEvidence ?? input.status !== "empty";
const withCandidateEvidence = input.withCandidateEvidence ?? false;
const candidateEvidence =
withCandidateEvidence && input.status !== "empty"
? [
{
candidate_id: `cand-${input.requirementId}`,
source_entity: "Document",
source_id: "DOC-1",
relevance: 0.7
}
]
: [];
return {
fragment_id: `F-${input.requirementId}`,
requirement_ids: [input.requirementId],
route: "hybrid_store_plus_live",
status: input.status,
result_type: "chain",
items:
input.status === "empty"
? []
: [
{
source_entity: "Document",
source_id: "DOC-1",
account_context: input.accountScope ?? ["60"],
graph_domain_scope: input.domainScope ?? ["bank_settlement"]
}
],
summary: {
broad_query_detected: true,
broad_result_flag: true,
minimum_evidence_failed: false,
degraded_to: "partial",
narrowing_strength: "weak",
domain_purity_guard: {
enabled: true,
domain_card_id: input.domainCardId ?? "settlements_60_62",
top1_pure: true,
top3_pure: true
},
semantic_profile: {
account_scope: input.accountScope ?? ["60", "62"],
domain_scope: input.domainScope ?? ["bank_settlement", "customer_settlement"],
relation_patterns: ["payment_to_settlement"]
}
},
evidence:
input.status === "empty" || !withEvidence
? []
: [
{
evidence_id: `ev-${input.requirementId}`,
claim_ref: `requirement:${input.requirementId}`,
source_type: "retrieval_item",
source_ref: {
schema_version: "evidence_source_ref_v1",
namespace: "snapshot_2020",
entity: "document",
id: "DOC-1",
period: "2020-06",
canonical_ref: "evidence_source_ref_v1|snapshot_2020|document|doc-1|2020-06"
},
pointer: {
fragment_id: `F-${input.requirementId}`,
route: "hybrid_store_plus_live",
source: {
namespace: "snapshot_2020",
entity: "document",
id: "DOC-1",
period: "2020-06"
},
locator: {
field_path: "risk_score",
item_index: 0
}
},
evidence_kind: "mechanism_link",
mechanism_note: "failed_edge:payment_to_settlement",
confidence: "medium",
limitation: null,
payload: {
risk_score: 4
}
}
],
candidate_evidence: candidateEvidence,
problem_units: units,
problem_unit_summary:
units.length > 0
? {
schema_version: "problem_unit_summary_v0_1",
units_total: units.length,
duplicate_collapses: 0,
unit_types: units.map((unit) => unit.problem_unit_type),
type_distribution: {
[units[0]?.problem_unit_type ?? "broken_chain_segment"]: units.length
},
severity_distribution: {
low: 0,
medium: 0,
high: units.length
},
confidence_distribution: {
low: 0,
medium: units.length,
high: 0
},
primary_unit_type: units[0]?.problem_unit_type ?? null
}
: null,
why_included: ["synthetic-regression"],
selection_reason: ["synthetic-regression"],
risk_factors: ["broken_chain"],
business_interpretation: ["synthetic-regression"],
confidence: input.status === "ok" ? "medium" : "low",
limitations: input.limitations ?? [],
errors: []
};
}
function composeSettlementCase(
retrievalResults: UnifiedRetrievalResult[],
options?: {
focusDomainHint?: string | null;
}
) {
return composeAssistantAnswer({
userMessage: "Почему по поставщику деньги ушли, а долг остался? По счетам 60.01/60.02 и 62.01/62.02.",
routeSummary: buildRouteSummary(),
retrievalResults,
requirements: [
{
requirement_id: "R1",
source_fragment_id: "F-R1",
requirement_text: "Проверить settlement цепочку по 60/62",
subject_tokens: ["account_60.01", "account_62.01"],
status: "covered",
route: "hybrid_store_plus_live"
},
{
requirement_id: "R2",
source_fragment_id: "F-R2",
requirement_text: "Проверить несхождение 62.01/62.02",
subject_tokens: ["account_62.01", "account_62.02"],
status: "uncovered",
route: "hybrid_store_plus_live"
}
],
coverageReport: buildCoverage(),
groundingCheck: buildGrounding(),
focusDomainHint: options?.focusDomainHint ?? null,
enableAnswerPolicyV11: true,
enableProblemCentricAnswerV1: true,
enableLifecycleAnswerV1: true
});
}
describe("wave10 settlement corrective regression", () => {
afterEach(() => {
restoreFlags();
vi.resetModules();
});
it("multi_fragment_settlement_60_62_should_not_fall_into_deferred_expense", () => {
const deferredOnlyUnit = buildProblemUnit({
id: "pu-deferred-1",
type: "lifecycle_anomaly_node",
account: "97",
defect: "deferred_expense_to_writeoff",
lifecycleDomain: "deferred_expense"
});
const output = composeSettlementCase([
buildRetrieval({
requirementId: "R1",
status: "ok",
units: [deferredOnlyUnit],
accountScope: ["60", "62"],
domainScope: ["bank_settlement", "customer_settlement", "deferred_expense"],
limitations: ["Domain purity guardrail может исключить cross-domain элементы на этапе source selection."]
}),
buildRetrieval({
requirementId: "R2",
status: "empty",
units: [],
accountScope: ["62"],
domainScope: ["customer_settlement"]
})
]);
expect(output.reply_type).toBe("partial_coverage");
expect(output.assistant_reply).toMatch(/закрытие расчета|расчет/i);
expect(output.assistant_reply).not.toMatch(/deferred_expense|рбп|списания\s+рбп/i);
});
it("settlement_question_must_not_promote_vat_or_period_close_as_primary_domain_without_handoff", () => {
const vatUnit = buildProblemUnit({
id: "pu-vat-1",
type: "lifecycle_anomaly_node",
account: "68",
defect: "invoice_to_book_break",
lifecycleDomain: "vat_flow"
});
const periodCloseUnit = buildProblemUnit({
id: "pu-close-1",
type: "lifecycle_anomaly_node",
account: "20",
defect: "period_close_break",
lifecycleDomain: "period_close"
});
const output = composeSettlementCase(
[
buildRetrieval({
requirementId: "R1",
status: "ok",
units: [vatUnit, periodCloseUnit],
accountScope: ["60", "62"],
domainScope: ["bank_settlement", "customer_settlement", "vat_flow", "period_close"],
domainCardId: "settlements_60_62"
}),
buildRetrieval({
requirementId: "R2",
status: "empty",
accountScope: ["62"],
domainScope: ["customer_settlement"],
domainCardId: "settlements_60_62"
})
],
{ focusDomainHint: "settlements_60_62" }
);
expect(output.reply_type).toBe("partial_coverage");
expect(output.assistant_reply).toMatch(/расчет|зачет|60\/62/i);
expect(output.assistant_reply).not.toMatch(/vat_flow|period_close|deferred_expense/i);
expect(output.assistant_reply).not.toMatch(/НДС|закрытие периода/i);
});
it("retrieval_empty_requirement_must_not_be_marked_covered", () => {
const requirements = [
{
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: "first",
subject_tokens: [],
status: "covered" as const,
route: "hybrid_store_plus_live"
},
{
requirement_id: "R2",
source_fragment_id: "F2",
requirement_text: "second",
subject_tokens: [],
status: "covered" as const,
route: "hybrid_store_plus_live"
}
];
const retrieval = [
buildRetrieval({ requirementId: "R1", status: "ok" }),
buildRetrieval({ requirementId: "R2", status: "empty" })
];
const evaluation = evaluateCoverageForTests(requirements, retrieval);
const req2 = evaluation.requirements.find((item) => item.requirement_id === "R2");
expect(evaluation.coverage.requirements_covered).toBe(1);
expect(evaluation.coverage.requirements_uncovered).toContain("R2");
expect(req2?.status).toBe("uncovered");
});
it("retrieval_ok_without_evidence_or_problem_units_must_not_be_marked_covered", () => {
const requirements = [
{
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: "settlement check",
subject_tokens: [],
status: "covered" as const,
route: "hybrid_store_plus_live"
}
];
const retrieval = [
buildRetrieval({
requirementId: "R1",
status: "ok",
units: [],
withEvidence: false,
withCandidateEvidence: false
})
];
const evaluation = evaluateCoverageForTests(requirements, retrieval);
const req1 = evaluation.requirements.find((item) => item.requirement_id === "R1");
expect(evaluation.coverage.requirements_covered).toBe(0);
expect(evaluation.coverage.requirements_uncovered).toContain("R1");
expect(req1?.status).toBe("uncovered");
});
it("partial_coverage_answer_must_separate_confirmed_and_unconfirmed_requirements", () => {
const settlementUnit = buildProblemUnit({
id: "pu-settlement-1",
type: "broken_chain_segment",
account: "60",
defect: "failed_edge:payment_to_settlement",
lifecycleDomain: "bank_settlement"
});
const output = composeSettlementCase([
buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] }),
buildRetrieval({ requirementId: "R2", status: "empty" })
]);
expect(output.assistant_reply).toContain("R1");
expect(output.assistant_reply).toContain("R2");
expect(output.assistant_reply).toMatch(/подтверждено по требованиям/i);
expect(output.assistant_reply).toMatch(/не подтверждено|частично/i);
});
it("settlement_domain_answer_must_suggest_settlement_checks_not_period_only", () => {
const settlementUnit = buildProblemUnit({
id: "pu-settlement-2",
type: "unresolved_settlement_cluster",
account: "62",
defect: "payment_to_settlement",
lifecycleDomain: "customer_settlement"
});
const output = composeSettlementCase([
buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] }),
buildRetrieval({ requirementId: "R2", status: "empty" })
]);
const checksSectionMatch = output.assistant_reply.match(/Что проверить первым:\s*([\s\S]*?)\s*Ограничения:/i);
const checksSection = checksSectionMatch?.[1] ?? "";
expect(checksSection).toMatch(/договор|регистр|зачет|зачёт|60\/62/i);
const firstLine = checksSection
.split(/\r?\n/g)
.map((line) => line.trim())
.find((line) => line.startsWith("- "));
expect(firstLine ?? "").not.toMatch(/только период|период проверки$/i);
});
it("user_facing_answer_must_not_leak_internal_debug_for_partial_coverage_case", () => {
const settlementUnit = buildProblemUnit({
id: "pu-settlement-3",
type: "broken_chain_segment",
account: "60",
defect: "failed_edge:payment_to_settlement",
lifecycleDomain: "bank_settlement"
});
const output = composeSettlementCase([
buildRetrieval({
requirementId: "R1",
status: "ok",
units: [settlementUnit],
limitations: [
"Domain purity guardrail может исключить cross-domain элементы на этапе source selection.",
"technical_breakdown_json"
]
}),
buildRetrieval({ requirementId: "R2", status: "empty" })
]);
expect(output.assistant_reply).not.toMatch(/Domain purity guardrail|technical_breakdown_json/i);
expect(output.assistant_reply).not.toMatch(/domain_scope|relation_patterns|semantic_profile|problem_unit_state/i);
});
it("settlement_broad_query_with_explicit_month_must_not_claim_period_missing", () => {
const retrieval = buildRetrieval({
requirementId: "R1",
status: "empty",
accountScope: ["62"],
domainScope: ["bank_settlement", "customer_settlement"]
});
(retrieval.summary as Record<string, unknown>).semantic_profile = {
...((retrieval.summary as Record<string, unknown>).semantic_profile as Record<string, unknown>),
period_scope: {
from: "2020-07-01",
to: null,
granularity: "month"
}
};
const output = composeAssistantAnswer({
userMessage: "Почему в июле по 62.01/62.02 не сходится зачет аванса, хотя оплата есть?",
routeSummary: buildRouteSummary(),
retrievalResults: [retrieval],
requirements: [
{
requirement_id: "R1",
source_fragment_id: "F-R1",
requirement_text: "Проверить settlement-кейс за июль по 62.01/62.02",
subject_tokens: ["account_62.01", "account_62.02"],
status: "uncovered",
route: "hybrid_store_plus_live"
}
],
coverageReport: {
requirements_total: 1,
requirements_covered: 0,
requirements_uncovered: ["R1"],
requirements_partially_covered: [],
clarification_needed_for: [],
out_of_scope_requirements: []
},
groundingCheck: {
status: "no_grounded_answer",
route_subject_match: true,
missing_requirements: ["R1"],
reasons: ["Insufficient support for broad settlement symptom query."],
why_included_summary: [],
selection_reason_summary: []
},
focusDomainHint: "settlements_60_62",
enableAnswerPolicyV11: true,
enableProblemCentricAnswerV1: true,
enableLifecycleAnswerV1: true
});
expect(output.reply_type).toBe("clarification_required");
expect(output.assistant_reply).not.toMatch(/период проверки не указан|missing_anchor:period/i);
});
it("settlement_answer_must_not_be_grounded_by_vat_or_deferred_expense_primary_evidence", () => {
const foreignPrimary = buildRetrieval({
requirementId: "R1",
status: "ok",
units: [],
accountScope: [],
domainScope: ["vat_flow", "deferred_expense", "period_close"],
withEvidence: true,
withCandidateEvidence: true
});
(foreignPrimary.summary as Record<string, unknown>).semantic_profile = {
...((foreignPrimary.summary as Record<string, unknown>).semantic_profile as Record<string, unknown>),
account_scope: [],
relation_patterns: ["invoice_to_vat", "deferred_expense_to_writeoff"],
domain_scope: ["vat_flow", "deferred_expense", "period_close"]
};
if (Array.isArray(foreignPrimary.items) && foreignPrimary.items.length > 0) {
const first = foreignPrimary.items[0] as Record<string, unknown>;
first.account_context = [];
first.graph_domain_scope = ["vat_flow", "deferred_expense", "period_close"];
first.relation_pattern_hits = ["invoice_to_vat", "deferred_expense_to_writeoff"];
}
const output = composeSettlementCase(
[
foreignPrimary,
buildRetrieval({
requirementId: "R2",
status: "empty",
accountScope: ["62"],
domainScope: ["customer_settlement"],
withEvidence: false
})
],
{ focusDomainHint: "settlements_60_62" }
);
expect(output.reply_type).toBe("clarification_required");
expect(output.assistant_reply).toMatch(/расчет|закрытие расчета|частично/i);
expect(output.assistant_reply).not.toMatch(/vat_flow|deferred_expense|period_close/i);
});
it("user_facing_answer_must_not_include_debug_payload_json_marker", () => {
const output = composeSettlementCase([
buildRetrieval({
requirementId: "R1",
status: "ok",
units: [],
limitations: [
"debug_payload_json",
"```json {\"debug_payload_json\":true} ```",
"Domain purity guardrail",
"technical_breakdown_json"
],
withEvidence: true,
withCandidateEvidence: false
}),
buildRetrieval({
requirementId: "R2",
status: "empty",
withEvidence: false
})
]);
expect(output.assistant_reply).not.toMatch(/debug_payload_json|```json|technical_breakdown_json|Domain purity guardrail/i);
});
it("followup_on_same_settlement_case_must_bind_to_active_focus", async () => {
process.env.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = "1";
process.env.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = "1";
process.env.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = "1";
process.env.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = "1";
process.env.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = "1";
process.env.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = "1";
vi.resetModules();
const { createApp } = await import("../src/server");
const app = createApp();
const sessionId = `asst-wave10-followup-${Date.now()}`;
const first = await request(app).post("/api/assistant/message").send({
session_id: sessionId,
useMock: true,
promptVersion: "normalizer_v2_0_2",
user_message: "Почему по поставщику деньги ушли, а долг остался по счетам 60.01/60.02?"
});
expect(first.status).toBe(200);
const second = await request(app).post("/api/assistant/message").send({
session_id: sessionId,
useMock: true,
promptVersion: "normalizer_v2_0_2",
user_message: "А по этому же кейсу что проверить сначала?"
});
expect(second.status).toBe(200);
expect(second.body.debug?.followup_state_usage?.applied).toBe(true);
expect(second.body.debug?.investigation_state_snapshot?.focus?.domain).toBe("settlements_60_62");
expect(second.body.debug?.investigation_state_snapshot?.followup_context?.active_domain).toBe("settlements_60_62");
expect(Array.isArray(second.body.debug?.investigation_state_snapshot?.followup_context?.settlement_next_actions)).toBe(true);
expect(second.body.debug?.investigation_state_snapshot?.followup_context?.settlement_next_actions?.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,141 @@
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, describe, expect, it, vi } from "vitest";
const TEMP_DIRS: string[] = [];
const FLAG_KEYS = [
"FEATURE_ASSISTANT_BROAD_GUARD_V1",
"FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1"
] as const;
const ORIGINAL_FLAGS: Record<string, string | undefined> = Object.fromEntries(FLAG_KEYS.map((key) => [key, process.env[key]]));
function restoreFlags(): void {
for (const key of FLAG_KEYS) {
const original = ORIGINAL_FLAGS[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(keyFields: Array<Record<string, unknown>>): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "assistant-wave11-recovery-"));
TEMP_DIRS.push(root);
const write = (fileName: string, records: Array<Record<string, unknown>>) => {
fs.writeFileSync(path.resolve(root, fileName), JSON.stringify({ records }, null, 2), "utf-8");
};
write("09_samples_key_fields_Recorder_Ref_Supplier_Buyer_Responsible.json", keyFields);
write("03_snapshot_fragment_problem_cases.json", []);
write("07_samples_DocumentJournals.json", []);
write("08_samples_NDS_registers.json", []);
write("04_samples_SpisanieSRaschetnogoScheta.json", []);
write("05_samples_RealizaciyaTovarovUslug.json", []);
write("06_samples_PostuplenieTovarovUslug.json", []);
return root;
}
function buildDrilldownAnchorRecord(): Record<string, unknown> {
return {
source_entity: "Document",
source_id: "DOC-SETTLE-4",
display_name: "Оплата по счету 4",
unknown_link_count: 0,
attributes: {
Number: "4",
Date: "2020-07-07T00:00:00",
Amount: 276873.6,
Description: "Оплата по счету 4 от 07.07.20",
Account: "62.02"
},
links: [
{
relation: "document_has_counterparty",
target_entity: "Counterparty",
target_id: "CP-4",
source_field: "Counterparty"
}
]
};
}
function buildSettlementRecoveryRecord(): Record<string, unknown> {
return {
source_entity: "Document",
source_id: "DOC-SETTLE-RECOVERY-1",
display_name: "Payment settlement tail",
unknown_link_count: 1,
attributes: {
Period: "2020-07-15T00:00:00",
Description: "payment linked to contract and buyer with unresolved closure"
},
links: [
{
relation: "document_has_counterparty",
target_entity: "Counterparty",
target_id: "CP-RECOVERY",
source_field: "Counterparty"
},
{
relation: "document_refers_to_document",
target_entity: "Document",
target_id: "DOC-CHAIN-1",
source_field: "Recorder"
}
]
};
}
describe.sequential("wave11 data-layer recovery", () => {
afterEach(() => {
cleanupTempDirs();
restoreFlags();
vi.resetModules();
});
it("settlement_object_trace_with_number_date_amount_must_not_require_guid_by_default", async () => {
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot([buildDrilldownAnchorRecord()]));
const result = dataLayer.executeRoute(
"live_mcp_drilldown",
"Оплата по счету № 4 от 07.07.20 на 276 873,60 пришла 13 июля по счету 62.02."
);
expect(result.status).toBe("ok");
const summary = result.summary as Record<string, unknown>;
expect(summary.reason).toBe("business_anchor_trace");
expect(summary.reason).not.toBe("guid_not_provided");
expect(Array.isArray(result.items)).toBe(true);
expect(result.items.length).toBeGreaterThan(0);
});
it("broad_settlement_query_must_not_drop_all_retrieval_due_to_strict_purity_if_in_scope", async () => {
process.env.FEATURE_ASSISTANT_BROAD_GUARD_V1 = "0";
process.env.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = "0";
vi.resetModules();
const { AssistantDataLayer } = await import("../src/services/assistantDataLayer");
const dataLayer = new AssistantDataLayer(createSnapshotRoot([buildSettlementRecoveryRecord()]));
const result = dataLayer.executeRoute(
"hybrid_store_plus_live",
"Почему по поставщику деньги ушли, а долг остался по счетам 60.01/62.02 в июле?"
);
expect(result.status).toBe("ok");
expect(result.items.length).toBeGreaterThan(0);
const summary = result.summary as Record<string, unknown>;
const guard = (summary.domain_purity_guard ?? {}) as Record<string, unknown>;
expect(Number(guard.source_selection_allowed ?? 0)).toBeGreaterThan(0);
expect(Boolean(guard.settlement_source_recovery) || Boolean(guard.settlement_narrowing_recovery)).toBe(true);
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { extractSubjectTokensForTests } from "../src/services/assistantService";
describe("wave11 subject token pollution cleanup", () => {
it("settlement_query_subject_tokens_must_not_include_spurious_accounts_from_dates", () => {
const tokens = extractSubjectTokensForTests(
"Оплата по счету № 4 от 07.07.20 на 276 873,60 пришла 13 июля, но 62.01/62.02 не сходятся."
);
expect(tokens).toContain("account_62.01");
expect(tokens).toContain("account_62.02");
expect(tokens).not.toContain("account_07.07");
expect(tokens).not.toContain("account_13");
});
});
@@ -0,0 +1,565 @@
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AssistantDataLayer } from "../src/services/assistantDataLayer";
import { toRouteHintSummary } from "../src/services/routeHintAdapter";
import type { NormalizedFragmentV2_0_2, NormalizedQueryV2_0_2 } from "../src/types/normalizer";
type DomainCardId = "settlements_60_62" | "vat_document_register_book" | "month_close_costs_20_44";
type DomainPrefix = "SET" | "VAT" | "CLS";
interface RegressionCase {
case_id: string;
domain: DomainCardId;
expected_prefix: DomainPrefix;
query: string;
account_hint: string;
candidate_label: "anomaly_probe" | "period_close_risk";
}
interface SnapshotDataset {
keyFields: Array<Record<string, unknown>>;
problemCases: Array<Record<string, unknown>>;
journals: Array<Record<string, unknown>>;
ndsRegisters: Array<Record<string, unknown>>;
docs: Array<Record<string, unknown>>;
}
const TEMP_DIRS: string[] = [];
function cleanupTempDirs(): void {
for (const dir of TEMP_DIRS.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function buildRecord(input: {
id: string;
account: string;
period: string;
description: string;
unknownLinks?: number;
withCounterparty?: boolean;
zeroGuid?: boolean;
}): Record<string, unknown> {
const attributes: Record<string, unknown> = {
Recorder: `${input.id}-REC`,
Period: input.period,
Description: input.description,
Account: input.account,
"trace@navigationLinkUrl": `/trace/${input.id}`
};
if (input.zeroGuid) {
attributes.LinkGuid = "00000000-0000-0000-0000-000000000000";
}
const links: Array<Record<string, unknown>> = [
{
relation: "document_refers_to_document",
target_entity: "Document",
target_id: `${input.id}-DOC-LINK`,
source_field: "Recorder"
}
];
if (input.withCounterparty !== false) {
links.push({
relation: "document_has_counterparty",
target_entity: "Counterparty",
target_id: `${input.id}-CP`,
source_field: "Counterparty"
});
}
return {
source_entity: "Document",
source_id: input.id,
display_name: input.id,
unknown_link_count: input.unknownLinks ?? 1,
problem_flags: ["risk_marker"],
attributes,
links
};
}
function createDataset(): SnapshotDataset {
const settlements = [
buildRecord({
id: "SET-PC-1",
account: "60",
period: "2020-06-10T00:00:00",
description: "supplier payment recorded but settlement chain is still open account 60"
}),
buildRecord({
id: "SET-PC-2",
account: "62",
period: "2020-06-11T00:00:00",
description: "customer settlement tail payment to settlement relation broken account 62"
}),
buildRecord({
id: "SET-DOC-1",
account: "60",
period: "2020-06-20T00:00:00",
description: "bank statement linked to settlement document payment chain account 60"
}),
buildRecord({
id: "SET-DOC-2",
account: "62",
period: "2020-06-21T00:00:00",
description: "customer payment linked to settlement closure account 62"
}),
buildRecord({
id: "SET-KF-1",
account: "60",
period: "2020-06-22T00:00:00",
description: "settlement key field record account 60 payment"
})
];
const vat = [
buildRecord({
id: "VAT-PC-1",
account: "68",
period: "2020-06-12T00:00:00",
description: "vat invoice linked to register and purchase book account 68"
}),
buildRecord({
id: "VAT-PC-2",
account: "19",
period: "2020-06-13T00:00:00",
description: "vat source document present but invoice to vat link is broken account 19"
}),
buildRecord({
id: "VAT-NDS-1",
account: "68",
period: "2020-06-23T00:00:00",
description: "vat register entry book generation deduction posted"
}),
buildRecord({
id: "VAT-NDS-2",
account: "19",
period: "2020-06-24T00:00:00",
description: "invoice to vat register chain for deduction account 19"
}),
buildRecord({
id: "VAT-KF-1",
account: "68",
period: "2020-06-25T00:00:00",
description: "vat key field invoice register linkage account 68"
})
];
const close = [
buildRecord({
id: "CLS-PC-1",
account: "20",
period: "2020-06-14T00:00:00",
description: "period close costs accumulated but allocation rules unresolved account 20"
}),
buildRecord({
id: "CLS-PC-2",
account: "44",
period: "2020-06-15T00:00:00",
description: "month close operation runs with residuals not zero account 44"
}),
buildRecord({
id: "CLS-DOC-1",
account: "20",
period: "2020-06-26T00:00:00",
description: "period close costs allocation writeoff account 20"
}),
buildRecord({
id: "CLS-DOC-2",
account: "44",
period: "2020-06-27T00:00:00",
description: "month close residuals explained allocation account 44"
}),
buildRecord({
id: "CLS-KF-1",
account: "20",
period: "2020-06-28T00:00:00",
description: "period close key field account 20 allocation"
})
];
const mixed = [
buildRecord({
id: "MIX-PC-1",
account: "68",
period: "2020-12-31T00:00:00",
description: "bank settlement vat mixed conflict record",
zeroGuid: true
}),
buildRecord({
id: "MIX-NDS-1",
account: "60",
period: "2020-12-30T00:00:00",
description: "mixed nds and settlement overlap record",
zeroGuid: true
}),
buildRecord({
id: "MIX-DOC-1",
account: "68",
period: "2020-12-29T00:00:00",
description: "mixed document with vat settlement and bank signals",
zeroGuid: true
}),
buildRecord({
id: "MIX-KF-1",
account: "44",
period: "2020-12-28T00:00:00",
description: "mixed key field with period close and vat overlap",
zeroGuid: true
})
];
return {
keyFields: [settlements[4], vat[4], close[4], mixed[3]],
problemCases: [mixed[0], vat[0], settlements[0], close[0], settlements[1], vat[1], close[1]],
journals: [close[2], close[3], settlements[3]],
ndsRegisters: [mixed[1], vat[2], vat[3]],
docs: [mixed[2], settlements[2], settlements[3], vat[2], vat[3], close[2], close[3]]
};
}
function createSnapshotRoot(dataset: SnapshotDataset): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "assistant-wave5-regression-"));
TEMP_DIRS.push(root);
const write = (fileName: string, records: Array<Record<string, unknown>>) => {
fs.writeFileSync(path.resolve(root, fileName), JSON.stringify({ records }, null, 2), "utf-8");
};
write("09_samples_key_fields_Recorder_Ref_Supplier_Buyer_Responsible.json", dataset.keyFields);
write("03_snapshot_fragment_problem_cases.json", dataset.problemCases);
write("07_samples_DocumentJournals.json", dataset.journals);
write("08_samples_NDS_registers.json", dataset.ndsRegisters);
write("04_samples_SpisanieSRaschetnogoScheta.json", dataset.docs);
write("05_samples_RealizaciyaTovarovUslug.json", []);
write("06_samples_PostuplenieTovarovUslug.json", []);
return root;
}
function resolvePrefixFromId(sourceId: string): DomainPrefix | "OTHER" {
if (sourceId.startsWith("SET")) return "SET";
if (sourceId.startsWith("VAT")) return "VAT";
if (sourceId.startsWith("CLS")) return "CLS";
return "OTHER";
}
function extractIds(items: Array<Record<string, unknown>>): string[] {
return items.map((item) => String(item.source_id ?? "")).filter(Boolean);
}
function hasForeignDomainInTop3(ids: string[], expected: DomainPrefix): boolean {
return ids.slice(0, 3).some((id) => resolvePrefixFromId(id) !== expected);
}
function top1IsRelevant(ids: string[], expected: DomainPrefix): boolean {
if (ids.length === 0) {
return false;
}
return resolvePrefixFromId(ids[0]) === expected;
}
function legacyRiskScore(record: Record<string, unknown>): number {
const unknown = Number(record.unknown_link_count ?? 0);
const attributes = (record.attributes as Record<string, unknown>) ?? {};
const links = Array.isArray(record.links) ? (record.links as Array<Record<string, unknown>>) : [];
let zeroGuid = 0;
for (const value of Object.values(attributes)) {
if (String(value) === "00000000-0000-0000-0000-000000000000") {
zeroGuid += 1;
}
}
let navigationLinks = 0;
for (const key of Object.keys(attributes)) {
if (key.includes("@navigationLinkUrl")) {
navigationLinks += 1;
}
}
const cpLinks = links.filter((link) => String(link.target_entity ?? "") === "Counterparty").length;
const flags = Array.isArray(record.problem_flags) ? record.problem_flags : [];
let score = 0;
if (unknown > 0) score += 3;
if (zeroGuid > 0) score += Math.min(3, 1 + zeroGuid);
if (navigationLinks > 0) score += 1;
if (cpLinks === 0) score += 1;
if (flags.length > 0) score += 1;
return score;
}
function legacyRiskTopIds(dataset: SnapshotDataset): string[] {
return [...dataset.problemCases, ...dataset.ndsRegisters]
.map((record) => ({
id: String(record.source_id ?? ""),
score: legacyRiskScore(record)
}))
.filter((item) => item.score >= 2)
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.id.localeCompare(right.id);
})
.slice(0, 15)
.map((item) => item.id);
}
function legacyCanonicalTopIds(query: string, dataset: SnapshotDataset): string[] {
const lower = query.toLowerCase();
const useVatSource = /\bvat\b|\bnds\b|\b19\b|\b68\b|ндс/i.test(lower);
const source = useVatSource ? [...dataset.ndsRegisters, ...dataset.keyFields] : dataset.docs;
return source
.map((record) => ({
id: String(record.source_id ?? ""),
sort: Date.parse(String(((record.attributes as Record<string, unknown>)?.Period ?? "") || "")) || 0
}))
.sort((left, right) => right.sort - left.sort)
.slice(0, 12)
.map((item) => item.id);
}
function buildNormalizedCase(testCase: RegressionCase): NormalizedQueryV2_0_2 {
const fragment: NormalizedFragmentV2_0_2 = {
fragment_id: "F1",
raw_fragment_text: testCase.query,
normalized_fragment_text: testCase.query,
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: ["document"],
account_hints: [testCase.account_hint],
document_hints: [],
register_hints: [],
time_scope: {
type: "missing",
value: null,
confidence: "low"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: false,
asks_for_period_summary: false,
asks_for_rule_check: false,
asks_for_anomaly_scan: false,
asks_for_exact_object_trace: false,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: [testCase.candidate_label],
confidence: "high",
execution_readiness: "executable",
clarification_reason: null,
soft_assumption_used: [],
route_status: "routed",
no_route_reason: null
};
return {
schema_version: "normalized_query_v2_0_2",
user_message_raw: testCase.query,
message_in_scope: true,
scope_confidence: "high",
contains_multiple_tasks: false,
fragments: [fragment],
discarded_fragments: [],
global_notes: {
needs_clarification: false,
clarification_reason: null
}
};
}
function legacyRouteForFragment(fragment: NormalizedFragmentV2_0_2): string {
const accountHints = fragment.account_hints.map((item) => String(item));
const hasLifecycleDomainHint =
accountHints.some((item) => /^(97|01|02|08|19|68(?:\.\d+)?|51|60|62)$/.test(item)) ||
fragment.candidate_labels.includes("anomaly_probe") ||
fragment.candidate_labels.includes("period_close_risk");
if (fragment.flags.asks_for_exact_object_trace) return "live_mcp_drilldown";
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) return "batch_refresh_then_store";
if (fragment.flags.asks_for_chain_explanation && (fragment.flags.has_multi_entity_scope || hasLifecycleDomainHint)) {
return "hybrid_store_plus_live";
}
if (fragment.flags.asks_for_rule_check && !fragment.flags.asks_for_chain_explanation) return "store_feature_risk";
if (
fragment.flags.asks_for_anomaly_scan &&
!fragment.flags.asks_for_ranking_or_top &&
!(fragment.flags.has_multi_entity_scope && fragment.flags.asks_for_chain_explanation)
) {
return "store_feature_risk";
}
return "store_canonical";
}
const SETTLEMENT_QUERIES = [
"Show why payment recorded but settlement for account 60 is still open.",
"Account 62: payment posted, settlement closure is missing.",
"Find settlement tails for account 60 where payment did not close chain.",
"Bank and settlements 60/62: where link to settlement is broken.",
"Why does account 60 keep open settlement after payment record.",
"Account 62 settlement problem: payment done, closure not reached.",
"Detect symptom where payment exists but settlement remains open on 60.",
"Find lifecycle gap in payment to settlement for account 62.",
"60-62 settlement chain has residual tail after payment.",
"Locate unresolved settlement after bank payment on account 60."
];
const VAT_QUERIES = [
"VAT check: source document exists but invoice link is missing on account 68.",
"Account 19 VAT chain: document to register to book is broken.",
"Find VAT symptom where invoice linked but book entry was not generated.",
"Show VAT lifecycle gaps for account 68 in document-register-book flow.",
"VAT deduction issue on 19: source document present but deduction not posted.",
"Find broken invoice to VAT register relation for account 68.",
"VAT problem-first: document exists, register is present, book entry missing.",
"Locate VAT residual issue where deduction chain is incomplete on 19.",
"VAT 68: invoice and register mismatch in purchase/sales book.",
"Detect VAT symptom with broken doc-register-book chain for account 68."
];
const CLOSE_QUERIES = [
"Month close: costs on accounts 20 and 44 are not allocated, residuals remain.",
"Period close problem for 20/44: allocation rules unresolved.",
"Find close lifecycle gap where costs accumulated but close operation fails 20 44.",
"Account 20 and 44 month close symptom: residuals are not zero.",
"Show period close issue when costs are accumulated but not distributed 20/44.",
"Close operation run for 20 and 44 leaves unexplained residuals.",
"Detect month close break in costs allocation chain on 20/44.",
"Period close 20-44: allocation exists but residual tail remains.",
"Find cost close mismatch: costs accumulated, close not completed 20 and 44.",
"Month close domain check for accounts 20 and 44 with unresolved residuals."
];
const REGRESSION_CASES: RegressionCase[] = [
...SETTLEMENT_QUERIES.map((query, index) => ({
case_id: `SET-${String(index + 1).padStart(2, "0")}`,
domain: "settlements_60_62" as const,
expected_prefix: "SET" as const,
query,
account_hint: index % 2 === 0 ? "60" : "62",
candidate_label: "anomaly_probe" as const
})),
...VAT_QUERIES.map((query, index) => ({
case_id: `VAT-${String(index + 1).padStart(2, "0")}`,
domain: "vat_document_register_book" as const,
expected_prefix: "VAT" as const,
query,
account_hint: index % 2 === 0 ? "68" : "19",
candidate_label: "anomaly_probe" as const
})),
...CLOSE_QUERIES.map((query, index) => ({
case_id: `CLS-${String(index + 1).padStart(2, "0")}`,
domain: "month_close_costs_20_44" as const,
expected_prefix: "CLS" as const,
query,
account_hint: index % 2 === 0 ? "20" : "44",
candidate_label: "period_close_risk" as const
}))
];
describe.sequential("stage4 wave5 P0 domain purity + route discipline regression", () => {
afterEach(() => {
cleanupTempDirs();
vi.resetModules();
});
it("keeps top-3 domain-pure and reroutes symptom/lifecycle intents away from canonical path", () => {
const dataset = createDataset();
const root = createSnapshotRoot(dataset);
const dataLayer = new AssistantDataLayer(root);
const metrics = {
route: {
before_canonical: 0,
after_canonical: 0,
after_hybrid: 0
},
risk: {
before_foreign_top3: 0,
after_foreign_top3: 0,
before_top1_relevant: 0,
after_top1_relevant: 0
},
canonical: {
before_foreign_top3: 0,
after_foreign_top3: 0,
before_top1_relevant: 0,
after_top1_relevant: 0
}
};
for (const testCase of REGRESSION_CASES) {
const normalized = buildNormalizedCase(testCase);
const summary = toRouteHintSummary(normalized);
expect(summary.mode).toBe("deterministic_v2");
if (summary.mode !== "deterministic_v2") {
throw new Error("Expected deterministic_v2 route summary");
}
const afterRoute = summary.decisions[0]?.route;
const beforeRoute = legacyRouteForFragment(normalized.fragments[0]);
if (beforeRoute === "store_canonical") {
metrics.route.before_canonical += 1;
}
if (afterRoute === "store_canonical") {
metrics.route.after_canonical += 1;
}
if (afterRoute === "hybrid_store_plus_live") {
metrics.route.after_hybrid += 1;
}
expect(afterRoute).toBe("hybrid_store_plus_live");
const afterRisk = dataLayer.executeRoute("store_feature_risk", testCase.query);
const afterRiskIds = extractIds(afterRisk.items as Array<Record<string, unknown>>);
if (hasForeignDomainInTop3(afterRiskIds, testCase.expected_prefix)) {
metrics.risk.after_foreign_top3 += 1;
}
if (top1IsRelevant(afterRiskIds, testCase.expected_prefix)) {
metrics.risk.after_top1_relevant += 1;
}
const afterCanonical = dataLayer.executeRoute("store_canonical", testCase.query);
const afterCanonicalIds = extractIds(afterCanonical.items as Array<Record<string, unknown>>);
if (hasForeignDomainInTop3(afterCanonicalIds, testCase.expected_prefix)) {
metrics.canonical.after_foreign_top3 += 1;
}
if (top1IsRelevant(afterCanonicalIds, testCase.expected_prefix)) {
metrics.canonical.after_top1_relevant += 1;
}
const beforeRiskIds = legacyRiskTopIds(dataset);
if (hasForeignDomainInTop3(beforeRiskIds, testCase.expected_prefix)) {
metrics.risk.before_foreign_top3 += 1;
}
if (top1IsRelevant(beforeRiskIds, testCase.expected_prefix)) {
metrics.risk.before_top1_relevant += 1;
}
const beforeCanonicalIds = legacyCanonicalTopIds(testCase.query, dataset);
if (hasForeignDomainInTop3(beforeCanonicalIds, testCase.expected_prefix)) {
metrics.canonical.before_foreign_top3 += 1;
}
if (top1IsRelevant(beforeCanonicalIds, testCase.expected_prefix)) {
metrics.canonical.before_top1_relevant += 1;
}
}
expect(REGRESSION_CASES.length).toBe(30);
expect(metrics.route.before_canonical).toBeGreaterThan(0);
expect(metrics.route.after_canonical).toBe(0);
expect(metrics.route.after_hybrid).toBe(REGRESSION_CASES.length);
expect(metrics.risk.before_foreign_top3).toBeGreaterThan(metrics.risk.after_foreign_top3);
expect(metrics.risk.after_foreign_top3).toBe(0);
expect(metrics.risk.after_top1_relevant).toBe(REGRESSION_CASES.length);
expect(metrics.canonical.before_foreign_top3).toBeGreaterThan(metrics.canonical.after_foreign_top3);
expect(metrics.canonical.after_foreign_top3).toBe(0);
expect(metrics.canonical.after_top1_relevant).toBe(REGRESSION_CASES.length);
});
});
@@ -0,0 +1,319 @@
import { describe, expect, it } from "vitest";
import { composeAssistantAnswer } from "../src/services/answerComposer";
import type { AnswerGroundingCheck, RequirementCoverageReport, UnifiedRetrievalResult } from "../src/types/assistant";
import type { ProblemUnit } from "../src/types/stage2ProblemUnits";
function buildRouteSummary() {
return {
mode: "deterministic_v2" as const,
message_in_scope: true,
scope_confidence: "high" as const,
planner: {
total_fragments: 1,
in_scope_fragments: 1,
out_of_scope_fragments: 0,
discarded_fragments: 0,
contains_multiple_tasks: false
},
decisions: [],
fallback: {
type: "none" as const,
message: null
}
};
}
function buildCoverage(partial = true): RequirementCoverageReport {
return {
requirements_total: 1,
requirements_covered: partial ? 0 : 1,
requirements_uncovered: partial ? ["R1"] : [],
requirements_partially_covered: partial ? ["R1"] : [],
clarification_needed_for: [],
out_of_scope_requirements: []
};
}
function buildGrounding(status: AnswerGroundingCheck["status"] = "partial"): AnswerGroundingCheck {
return {
status,
route_subject_match: true,
missing_requirements: status === "partial" ? ["R1"] : [],
reasons: status === "partial" ? ["Coverage is partial for problem-first answer contract."] : [],
why_included_summary: [],
selection_reason_summary: []
};
}
function buildProblemUnit(input: {
id: string;
type: ProblemUnit["problem_unit_type"];
defect: string;
account: string;
lifecycleDomain?: ProblemUnit["lifecycle_domain"];
}): ProblemUnit {
return {
schema_version: "problem_unit_v0_1",
problem_unit_id: input.id,
problem_unit_type: input.type,
title: "Problem unit",
mechanism_summary: `Mechanism candidate: ${input.defect}.`,
business_defect_class: input.defect,
severity: {
score: 0.76,
grade: "high"
},
confidence: {
score: 0.52,
grade: "medium"
},
affected_entities: ["Document:DOC-1", "Posting:POST-1"],
affected_documents: ["Document:DOC-1"],
affected_postings: ["Posting:POST-1"],
affected_accounts: [input.account],
affected_counterparties: ["Counterparty:CP-1"],
affected_contracts: ["Contract:CTR-1"],
failed_expected_edge: input.defect,
period_impact: {
is_period_sensitive: true,
impact_class: "close_risk"
},
evidence_pack: ["cand-1"],
entity_backlinks: [{ entity: "Document", id: "DOC-1" }],
snapshot_limitations: [],
...(input.lifecycleDomain
? {
lifecycle_domain: input.lifecycleDomain
}
: {})
};
}
function buildRetrieval(units: ProblemUnit[], extras?: Partial<UnifiedRetrievalResult>): UnifiedRetrievalResult {
return {
fragment_id: "F1",
requirement_ids: ["R1"],
route: "hybrid_store_plus_live",
status: "ok",
result_type: "chain",
items: [
{
source_entity: "Document",
source_id: "DOC-1",
counterparty_id: "CP-1"
}
],
summary: {
broad_query_detected: true,
broad_result_flag: true,
minimum_evidence_failed: false,
degraded_to: "partial",
narrowing_strength: "weak",
semantic_profile: {
domain_scope: ["bank_settlement"],
account_scope: ["60"],
relation_patterns: ["payment_to_settlement"]
}
},
evidence: [
{
evidence_id: "ev-1",
claim_ref: "requirement:R1",
source_type: "retrieval_item",
source_ref: {
schema_version: "evidence_source_ref_v1",
namespace: "snapshot_2020",
entity: "document",
id: "DOC-1",
period: "2020-06",
canonical_ref: "evidence_source_ref_v1|snapshot_2020|document|doc-1|2020-06"
},
pointer: {
fragment_id: "F1",
route: "hybrid_store_plus_live",
source: {
namespace: "snapshot_2020",
entity: "document",
id: "DOC-1",
period: "2020-06"
},
locator: {
field_path: "risk_score",
item_index: 0
}
},
evidence_kind: "mechanism_link",
mechanism_note: "failed_edge:payment_to_settlement",
confidence: "medium",
limitation: {
reason_code: "weak_source_mapping",
note: null
},
payload: {
risk_score: 4
}
}
],
problem_units: units,
problem_unit_summary: {
schema_version: "problem_unit_summary_v0_1",
units_total: units.length,
duplicate_collapses: 0,
unit_types: units.map((unit) => unit.problem_unit_type),
type_distribution: {
[units[0]?.problem_unit_type ?? "broken_chain_segment"]: units.length
},
severity_distribution: {
low: 0,
medium: 0,
high: units.length
},
confidence_distribution: {
low: 0,
medium: units.length,
high: 0
},
primary_unit_type: units[0]?.problem_unit_type ?? null
},
why_included: ["semantic retrieval profile", "route=hybrid_store_plus_live"],
selection_reason: ["domain_scope + relation_patterns + route profile"],
risk_factors: ["broken_chain", "closure_risk"],
business_interpretation: ["problem-first signal"],
confidence: "medium",
limitations: ["Evidence is snapshot-only and may lag source-of-record."],
errors: [],
...extras
};
}
function composeCase(userMessage: string, retrieval: UnifiedRetrievalResult) {
return composeAssistantAnswer({
userMessage,
routeSummary: buildRouteSummary(),
retrievalResults: [retrieval],
requirements: [
{
requirement_id: "R1",
source_fragment_id: "F1",
requirement_text: "Проверить проблемный механизм",
subject_tokens: ["chain"],
status: "covered",
route: "hybrid_store_plus_live"
}
],
coverageReport: buildCoverage(true),
groundingCheck: buildGrounding("partial"),
enableAnswerPolicyV11: true,
enableProblemCentricAnswerV1: true,
enableLifecycleAnswerV1: true
});
}
function extractSection(text: string, title: string): string {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const stopTitles = [
"Коротко",
"Что сломано",
"Почему это похоже на проблему",
"На чем это основано",
"Что проверить первым",
"Ограничения"
];
const stopPattern = stopTitles.map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
const re = new RegExp(`${escaped}:([\\s\\S]*?)(?=(?:${stopPattern}):|$)`, "i");
const match = String(text ?? "").match(re);
return match?.[1]?.trim() ?? "";
}
describe("assistant wave6 problem-first answer contract", () => {
it("enforces leakage guard in direct user-facing answer", () => {
const units = [buildProblemUnit({ id: "pu-1", type: "broken_chain_segment", defect: "failed_edge:payment_to_settlement", account: "60" })];
const output = composeCase("Покажи проблему по расчетам.", buildRetrieval(units));
expect(output.assistant_reply).not.toMatch(
/graph_|domain_scope|relation_patterns|route|profile|hybrid_store_plus_live|store_canonical|semantic_profile|lifecycle_defect_type/i
);
});
it("keeps narrative mechanism-first and avoids entity-list direct answer", () => {
const units = [buildProblemUnit({ id: "pu-1", type: "broken_chain_segment", defect: "failed_edge:payment_to_settlement", account: "60" })];
const output = composeCase("Проверь по 60 счету, где разрыв.", buildRetrieval(units));
const brokenSection = extractSection(output.assistant_reply, "Что сломано");
expect(brokenSection).toMatch(/не подтвержден|разрыв|зависл|закрыти/i);
expect(brokenSection).not.toMatch(/^\s*-\s*(Document|Record|Entity)\b/i);
});
it("does not expose route/profile explanation in user-facing text", () => {
const units = [buildProblemUnit({ id: "pu-1", type: "document_conflict", defect: "posting_mismatch", account: "60" })];
const output = composeCase("Где конфликт документа и проводки?", buildRetrieval(units));
expect(output.assistant_reply).not.toMatch(/route|profile|semantic|domain_scope|relation_patterns|typed_domain_path/i);
});
it("collapses duplicate problem lines for the same mechanism", () => {
const units = [
buildProblemUnit({ id: "pu-1", type: "broken_chain_segment", defect: "failed_edge:payment_to_settlement", account: "60" }),
buildProblemUnit({ id: "pu-2", type: "unresolved_settlement_cluster", defect: "payment_to_settlement", account: "60" })
];
const output = composeCase("Проверь хвост по расчетам.", buildRetrieval(units));
const brokenSection = extractSection(output.assistant_reply, "Что сломано");
const bulletLines = brokenSection
.split(/\r?\n/g)
.map((line) => line.trim())
.filter((line) => line.startsWith("- "));
expect(bulletLines.length).toBe(1);
});
it("shows explicit limitation when period is missing", () => {
const units = [buildProblemUnit({ id: "pu-1", type: "lifecycle_anomaly_node", defect: "missing_expected_transition", account: "97", lifecycleDomain: "deferred_expense" })];
const output = composeCase("Проверь по 97 счету зависание списания.", buildRetrieval(units));
const limitationsSection = extractSection(output.assistant_reply, "Ограничения");
expect(limitationsSection).toMatch(/период/i);
});
it("returns short accountant-readable answers for P0 domains without technical dump", () => {
const cases: Array<{
message: string;
retrieval: UnifiedRetrievalResult;
domainHint: RegExp;
}> = [
{
message: "Проверь хвосты по расчетам 60/62.",
retrieval: buildRetrieval([
buildProblemUnit({ id: "pu-60", type: "broken_chain_segment", defect: "failed_edge:payment_to_settlement", account: "60" })
]),
domainHint: /расчет|оплат|закрыти/i
},
{
message: "Проверь НДС-цепочку по документу.",
retrieval: buildRetrieval([
buildProblemUnit({ id: "pu-vat", type: "cross_branch_inconsistency_cluster", defect: "invoice_linked", account: "68", lifecycleDomain: "vat_flow" })
]),
domainHint: /ндс|регистр|книг/i
},
{
message: "Проверь закрытие месяца и затраты 20-44.",
retrieval: buildRetrieval([
buildProblemUnit({ id: "pu-close", type: "period_risk_cluster", defect: "close_operation_runs", account: "20", lifecycleDomain: "period_close" })
]),
domainHint: /закрыти|месяц|затрат/i
}
];
for (const testCase of cases) {
const output = composeCase(testCase.message, testCase.retrieval);
expect(output.assistant_reply).toMatch(testCase.domainHint);
expect(output.assistant_reply).toContain("Коротко:");
expect(output.assistant_reply).toContain("Что сломано:");
expect(output.assistant_reply).toContain("Почему это похоже на проблему:");
expect(output.assistant_reply).toContain("На чем это основано:");
expect(output.assistant_reply).toContain("Что проверить первым:");
expect(output.assistant_reply).toContain("Ограничения:");
expect(output.assistant_reply.length).toBeLessThan(1800);
expect(output.assistant_reply).not.toMatch(/graph_|domain_scope|relation_patterns|semantic_profile|route|profile/i);
}
});
});
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const PROBLEM_UNITS_FLAG = "FEATURE_ASSISTANT_PROBLEM_UNITS_V1";
const GRAPH_RUNTIME_FLAG = "FEATURE_ASSISTANT_GRAPH_RUNTIME_V1";
const ORIGINAL_PROBLEM_UNITS_FLAG = process.env[PROBLEM_UNITS_FLAG];
const ORIGINAL_GRAPH_RUNTIME_FLAG = process.env[GRAPH_RUNTIME_FLAG];
function restoreFlags(): void {
if (ORIGINAL_PROBLEM_UNITS_FLAG === undefined) {
delete process.env[PROBLEM_UNITS_FLAG];
} else {
process.env[PROBLEM_UNITS_FLAG] = ORIGINAL_PROBLEM_UNITS_FLAG;
}
if (ORIGINAL_GRAPH_RUNTIME_FLAG === undefined) {
delete process.env[GRAPH_RUNTIME_FLAG];
} else {
process.env[GRAPH_RUNTIME_FLAG] = ORIGINAL_GRAPH_RUNTIME_FLAG;
}
}
async function normalizeWithFlags(input: {
problemUnits: "0" | "1";
graphRuntime: "0" | "1";
}) {
process.env[PROBLEM_UNITS_FLAG] = input.problemUnits;
process.env[GRAPH_RUNTIME_FLAG] = input.graphRuntime;
vi.resetModules();
const { normalizeRetrievalResult } = await import("../src/services/retrievalResultNormalizer");
return normalizeRetrievalResult("F1", ["R1"], "hybrid_store_plus_live", {
status: "ok",
result_type: "list",
items: [
{
source_entity: "Document",
source_id: "DOC-1",
risk_score: 5
}
],
summary: {
broad_query_detected: false
},
evidence: [
{
evidence_id: "ev-1",
claim_ref: "requirement:R1",
source_type: "retrieval_item",
pointer: {
fragment_id: "F1",
route: "hybrid_store_plus_live",
source: {
namespace: "snapshot_2020",
entity: "Document",
id: "DOC-1",
period: "2020-06"
},
locator: {
field_path: "risk_score",
item_index: 0
}
},
failed_expected_edge: "payment_to_settlement",
anomaly_patterns: ["broken_lifecycle", "missing_link"],
confidence: "medium"
}
],
why_included: ["synthetic-test"],
selection_reason: ["synthetic-test"],
risk_factors: ["broken_lifecycle"],
business_interpretation: ["synthetic-test"],
confidence: "medium",
limitations: [],
errors: []
});
}
describe.sequential("retrieval dual payload compatibility for graph runtime", () => {
afterEach(() => {
restoreFlags();
vi.resetModules();
});
it("keeps stage2 payload when graph runtime flag is OFF", async () => {
const result = await normalizeWithFlags({
problemUnits: "1",
graphRuntime: "0"
});
expect(Array.isArray(result.problem_units)).toBe(true);
expect(result.problem_units?.length).toBeGreaterThan(0);
expect(result.accounting_graph).toBeUndefined();
expect(result.summary.graph_runtime_enabled).toBeUndefined();
expect(result.problem_units?.some((item) => item.graph_binding)).toBe(false);
});
it("adds graph runtime payload when graph runtime flag is ON", async () => {
const result = await normalizeWithFlags({
problemUnits: "1",
graphRuntime: "1"
});
expect(Array.isArray(result.problem_units)).toBe(true);
expect(result.problem_units?.length).toBeGreaterThan(0);
expect(result.accounting_graph?.schema_version).toBe("accounting_graph_v0_1");
expect((result.accounting_graph?.nodes.length ?? 0) > 0).toBe(true);
expect((result.accounting_graph?.edges.length ?? 0) > 0).toBe(true);
expect(result.summary.graph_runtime_enabled).toBe(true);
expect(typeof result.summary.graph_nodes_count).toBe("number");
expect(typeof result.summary.graph_edges_count).toBe("number");
expect(result.problem_unit_summary?.graph_summary).toBeDefined();
expect(result.problem_units?.some((item) => Boolean(item.graph_binding?.graph_node_id))).toBe(true);
});
});
@@ -38,7 +38,7 @@ describe("routeHintAdapter", () => {
}
expect(summary.fallback.type).toBe("none");
expect(summary.decisions[0]?.execution_readiness).toBe("executable_with_soft_assumptions");
expect(summary.decisions[0]?.route).toBe("store_feature_risk");
expect(summary.decisions[0]?.route).toBe("hybrid_store_plus_live");
});
it("uses explicit v2.0.2 route_status/no_route_reason contract", () => {
@@ -49,11 +49,180 @@ describe("routeHintAdapter", () => {
}
expect(summary.decisions[0]?.route_status).toBe("routed");
expect(summary.decisions[0]?.no_route_reason).toBeNull();
expect(summary.decisions[0]?.route).toBe("store_feature_risk");
expect(summary.decisions[0]?.route).toBe("hybrid_store_plus_live");
const routerInput = toRouterInput(normalizedFixtureV2_0_2());
const first = (routerInput.fragments as Array<Record<string, unknown>>)[0];
expect(first.route_status).toBe("routed");
expect(first.no_route_reason).toBeNull();
});
it("promotes lifecycle chain intent to hybrid route even without multi-entity flag", () => {
const summary = toRouteHintSummary({
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Проверь по 97-му где расходы зависли и не дошли до списания",
message_in_scope: true,
scope_confidence: "high",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "где расходы будущих периодов зависли и не дошли до списания",
normalized_fragment_text: "расходы будущих периодов зависли",
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: [],
account_hints: ["97"],
document_hints: [],
register_hints: [],
time_scope: {
type: "missing",
value: null,
confidence: "low"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: true,
asks_for_ranking_or_top: false,
asks_for_period_summary: false,
asks_for_rule_check: false,
asks_for_anomaly_scan: false,
asks_for_exact_object_trace: false,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: ["cross_entity", "anomaly_probe"],
confidence: "high",
execution_readiness: "executable",
clarification_reason: null,
soft_assumption_used: [],
route_status: "routed",
no_route_reason: null
}
],
discarded_fragments: [],
global_notes: {
needs_clarification: false,
clarification_reason: null
}
});
expect(summary.mode).toBe("deterministic_v2");
if (summary.mode !== "deterministic_v2") {
throw new Error("Expected deterministic_v2 summary");
}
expect(summary.decisions[0]?.route).toBe("hybrid_store_plus_live");
});
it("promotes mixed-ambiguity symptom fragment to hybrid when domain anchors are present", () => {
const summary = toRouteHintSummary({
schema_version: "normalized_query_v2_0_2",
user_message_raw: "2020-06 account 60: payment posted but settlement remains open",
message_in_scope: false,
scope_confidence: "low",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "2020-06 account 60: payment posted but settlement remains open",
normalized_fragment_text: "2020-06 account 60 payment posted but settlement remains open",
domain_relevance: "unclear",
business_scope: "unclear",
entity_hints: [],
account_hints: ["60"],
document_hints: [],
register_hints: [],
time_scope: {
type: "explicit",
value: "2020-06",
confidence: "high"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: false,
asks_for_period_summary: false,
asks_for_rule_check: false,
asks_for_anomaly_scan: false,
asks_for_exact_object_trace: false,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: [],
confidence: "low",
execution_readiness: "needs_clarification",
clarification_reason: "domain_or_scope_unclear",
soft_assumption_used: [],
route_status: "no_route",
no_route_reason: "insufficient_specificity"
}
],
discarded_fragments: [],
global_notes: {
needs_clarification: false,
clarification_reason: null
}
});
expect(summary.mode).toBe("deterministic_v2");
if (summary.mode !== "deterministic_v2") {
throw new Error("Expected deterministic_v2 summary");
}
expect(summary.decisions[0]?.route).toBe("hybrid_store_plus_live");
expect(summary.decisions[0]?.route_status).toBe("routed");
});
it("keeps canonical path only for factual fragments without symptom/lifecycle markers", () => {
const summary = toRouteHintSummary({
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Покажи последний документ по 10 счету за июнь 2020.",
message_in_scope: true,
scope_confidence: "high",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "последний документ по 10 счету за июнь 2020",
normalized_fragment_text: "последний документ по 10 счету июнь 2020",
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: ["документ"],
account_hints: ["10"],
document_hints: [],
register_hints: [],
time_scope: {
type: "explicit",
value: "2020-06",
confidence: "high"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: false,
asks_for_period_summary: false,
asks_for_rule_check: false,
asks_for_anomaly_scan: false,
asks_for_exact_object_trace: false,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: ["simple_factual"],
confidence: "high",
execution_readiness: "executable",
clarification_reason: null,
soft_assumption_used: [],
route_status: "routed",
no_route_reason: null
}
],
discarded_fragments: [],
global_notes: {
needs_clarification: false,
clarification_reason: null
}
});
expect(summary.mode).toBe("deterministic_v2");
if (summary.mode !== "deterministic_v2") {
throw new Error("Expected deterministic_v2 summary");
}
expect(summary.decisions[0]?.route).toBe("store_canonical");
});
});
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import { buildAccountingGraph } from "../src/services/stage4GraphRuntime";
import type { CandidateEvidenceItem, ProblemUnit } from "../src/types/stage2ProblemUnits";
function buildProblemUnit(input: {
id: string;
type: ProblemUnit["problem_unit_type"];
domain?: ProblemUnit["lifecycle_domain"];
accounts?: string[];
current?: string;
expected?: string;
missing?: string;
invalid?: string;
defect?: ProblemUnit["lifecycle_defect_type"];
evidencePack?: string[];
}): ProblemUnit {
return {
schema_version: "problem_unit_v0_1",
problem_unit_id: input.id,
problem_unit_type: input.type,
title: "Synthetic unit",
mechanism_summary: "Synthetic mechanism",
business_defect_class: "broken_lifecycle",
severity: {
score: 0.76,
grade: "high"
},
confidence: {
score: 0.64,
grade: "medium"
},
affected_entities: ["Document:DOC-1"],
affected_documents: ["Document:DOC-1"],
affected_postings: [],
affected_accounts: input.accounts ?? [],
affected_counterparties: ["Counterparty:CP-1"],
affected_contracts: [],
evidence_pack: input.evidencePack ?? ["cand-1"],
entity_backlinks: [{ entity: "Document", id: "DOC-1" }],
snapshot_limitations: [],
...(input.domain
? {
lifecycle_domain: input.domain
}
: {}),
...(input.current
? {
current_lifecycle_state: input.current
}
: {}),
...(input.expected
? {
expected_lifecycle_state: input.expected
}
: {}),
...(input.missing
? {
missing_transition: input.missing
}
: {}),
...(input.invalid
? {
invalid_transition: input.invalid
}
: {}),
...(input.defect
? {
lifecycle_defect_type: input.defect
}
: {})
};
}
function buildCandidate(id: string): CandidateEvidenceItem {
return {
schema_version: "candidate_evidence_v0_1",
candidate_id: id,
route: "hybrid_store_plus_live",
source_ref: {
schema_version: "evidence_source_ref_v1",
namespace: "snapshot_2020",
entity: "Document",
id: "DOC-1",
period: "2020-06",
canonical_ref: "evidence_source_ref_v1|snapshot_2020|document|doc-1|2020-06"
},
relation_pattern_hits: ["payment_to_settlement", "deferred_expense_to_writeoff"],
anomaly_patterns: ["broken_lifecycle"],
entity_backlinks: [{ entity: "Document", id: "DOC-1" }],
confidence_hint: "medium"
};
}
describe("stage4GraphRuntime", () => {
it("builds graph bindings for deferred expense lifecycle with missing transition", () => {
const result = buildAccountingGraph({
route: "hybrid_store_plus_live",
candidateEvidence: [buildCandidate("cand-1")],
problemUnits: [
buildProblemUnit({
id: "pu-97-1",
type: "lifecycle_anomaly_node",
domain: "deferred_expense",
accounts: ["97"],
current: "recognized",
expected: "fully_written_off",
missing: "recognized->partially_written_off"
})
]
});
expect(result.summary.bound_units).toBe(1);
expect(result.summary.domain_distribution.deferred_expense).toBe(1);
expect(result.summary.missing_links_count).toBeGreaterThan(0);
expect(result.edges.some((item) => item.relation_type === "missing_transition")).toBe(true);
expect(result.unit_bindings[0].relation_path.join("|")).toContain("deferred_expense_to_writeoff");
});
it("marks cross-branch conflicts for vat graph branches", () => {
const result = buildAccountingGraph({
route: "store_feature_risk",
candidateEvidence: [buildCandidate("cand-vat")],
problemUnits: [
buildProblemUnit({
id: "pu-vat-1",
type: "cross_branch_inconsistency_cluster",
domain: "vat_flow",
accounts: ["19", "68"],
current: "vat_conflict",
expected: "vat_reflected",
invalid: "cross_branch_conflict_transition",
defect: "cross_branch_state_conflict",
evidencePack: ["cand-vat"]
})
]
});
expect(result.summary.domain_distribution.vat_flow).toBe(1);
expect(result.summary.conflicting_links_count).toBeGreaterThan(0);
expect(result.edges.some((item) => item.flags.includes("conflict_link"))).toBe(true);
expect(result.unit_bindings[0].conflicting_links).toContain("cross_branch_conflict_transition");
});
it("infers 97 domain mapping when lifecycle domain is absent", () => {
const result = buildAccountingGraph({
route: "store_feature_risk",
candidateEvidence: [buildCandidate("cand-no-domain")],
problemUnits: [
buildProblemUnit({
id: "pu-97-2",
type: "lifecycle_anomaly_node",
accounts: ["97"],
current: "recognized",
expected: "fully_written_off"
})
]
});
expect(result.summary.domain_distribution.deferred_expense).toBe(1);
expect(result.summary.graph_coverage_grade).toBe("high");
});
});