432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
import fs from "fs";
|
||
import os from "os";
|
||
import path from "path";
|
||
import { afterEach, describe, expect, it } from "vitest";
|
||
import { AssistantDataLayer } from "../src/services/assistantDataLayer";
|
||
import { composeAssistantAnswer } from "../src/services/answerComposer";
|
||
import { resolveQuestionType } from "../src/services/questionTypeResolver";
|
||
import type { AnswerGroundingCheck, RequirementCoverageReport, UnifiedRetrievalResult } from "../src/types/assistant";
|
||
import type { ProblemUnit } from "../src/types/stage2ProblemUnits";
|
||
|
||
const TEMP_DIRS: string[] = [];
|
||
|
||
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-wave16-"));
|
||
TEMP_DIRS.push(root);
|
||
const payload = JSON.stringify({ records }, null, 2);
|
||
const write = (name: string, content = payload): void => {
|
||
fs.writeFileSync(path.resolve(root, name), content, "utf-8");
|
||
};
|
||
write("09_samples_key_fields_Recorder_Ref_Supplier_Buyer_Responsible.json");
|
||
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 buildMixedDomainRecord(id: string, description: string, account?: string): Record<string, unknown> {
|
||
return {
|
||
source_entity: "Document",
|
||
source_id: id,
|
||
display_name: description,
|
||
unknown_link_count: 0,
|
||
attributes: {
|
||
Description: description,
|
||
Account: account ?? "",
|
||
Period: "2020-07-15T00:00:00",
|
||
Amount: 276873.6
|
||
},
|
||
links: [
|
||
{
|
||
relation: "document_has_counterparty",
|
||
target_entity: "Counterparty",
|
||
target_id: `CP-${id}`,
|
||
source_field: "Counterparty"
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
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: 1,
|
||
requirements_covered: 1,
|
||
requirements_uncovered: [],
|
||
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: [],
|
||
reasons: [],
|
||
why_included_summary: ["wave16-test"],
|
||
selection_reason_summary: ["wave16-test"],
|
||
...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: "Wave16 problem unit",
|
||
mechanism_summary: `Mechanism candidate: ${input.defect}.`,
|
||
business_defect_class: input.defect,
|
||
severity: {
|
||
score: 0.73,
|
||
grade: "high"
|
||
},
|
||
confidence: {
|
||
score: 0.62,
|
||
grade: "medium"
|
||
},
|
||
lifecycle_domain: input.lifecycleDomain,
|
||
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: []
|
||
};
|
||
}
|
||
|
||
function buildRetrieval(input: {
|
||
status: UnifiedRetrievalResult["status"];
|
||
accountScope?: string[];
|
||
domainScope?: string[];
|
||
relationPatterns?: string[];
|
||
units?: ProblemUnit[];
|
||
}): UnifiedRetrievalResult {
|
||
const accountScope = input.accountScope ?? [];
|
||
const domainScope = input.domainScope ?? ["settlements"];
|
||
const relationPatterns = input.relationPatterns ?? ["payment_to_settlement"];
|
||
const units = input.units ?? [];
|
||
return {
|
||
fragment_id: "F1",
|
||
requirement_ids: ["R1"],
|
||
route: "hybrid_store_plus_live",
|
||
status: input.status,
|
||
result_type: "chain",
|
||
items:
|
||
input.status === "empty"
|
||
? []
|
||
: [
|
||
{
|
||
source_entity: "Document",
|
||
source_id: "DOC-1",
|
||
display_name: "Счет №4 от 07.07.20",
|
||
account_context: accountScope,
|
||
graph_domain_scope: domainScope,
|
||
relation_pattern_hits: relationPatterns,
|
||
period: "2020-07",
|
||
amount: "276 873,60"
|
||
}
|
||
],
|
||
summary: {
|
||
semantic_profile: {
|
||
account_scope: accountScope,
|
||
domain_scope: domainScope,
|
||
relation_patterns: relationPatterns,
|
||
period_scope: {
|
||
from: "2020-07-01",
|
||
to: "2020-07-31",
|
||
granularity: "month"
|
||
}
|
||
},
|
||
domain_purity_guard: {
|
||
domain_card_id: domainScope.includes("vat") ? "vat_document_register_book" : "settlements_60_62"
|
||
},
|
||
broad_query_detected: false,
|
||
broad_result_flag: false,
|
||
minimum_evidence_failed: false,
|
||
narrowing_strength: "strong",
|
||
degraded_to: null
|
||
},
|
||
evidence:
|
||
input.status === "empty"
|
||
? []
|
||
: [
|
||
{
|
||
evidence_id: "ev-R1",
|
||
claim_ref: "requirement:R1",
|
||
source_type: "retrieval_item",
|
||
source_ref: {
|
||
schema_version: "evidence_source_ref_v1",
|
||
namespace: "snapshot_2020_07",
|
||
entity: "document",
|
||
id: "DOC-1",
|
||
period: "2020-07",
|
||
canonical_ref: "evidence_source_ref_v1|snapshot_2020_07|document|doc-1|2020-07"
|
||
},
|
||
pointer: {
|
||
fragment_id: "F1",
|
||
route: "hybrid_store_plus_live",
|
||
source: {
|
||
namespace: "snapshot_2020_07",
|
||
entity: "document",
|
||
id: "DOC-1",
|
||
period: "2020-07"
|
||
},
|
||
locator: {
|
||
field_path: "risk_score",
|
||
item_index: 0
|
||
}
|
||
},
|
||
evidence_kind: "mechanism_link",
|
||
mechanism_note: relationPatterns[0],
|
||
confidence: "medium",
|
||
limitation: null,
|
||
payload: {
|
||
contract: "договор № 01/19-ПТ"
|
||
}
|
||
}
|
||
],
|
||
candidate_evidence: [],
|
||
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: ["wave16-test"],
|
||
selection_reason: ["wave16-test"],
|
||
risk_factors: ["wave16"],
|
||
business_interpretation: ["wave16"],
|
||
confidence: input.status === "ok" ? "medium" : "low",
|
||
limitations: [],
|
||
errors: []
|
||
};
|
||
}
|
||
|
||
afterEach(() => {
|
||
cleanupTempDirs();
|
||
});
|
||
|
||
describe("wave16 residual fail cleanup", () => {
|
||
it("q10-style VAT query must not derive account scope from percent/amount fragments", () => {
|
||
const dataLayer = new AssistantDataLayer(
|
||
createSnapshotRoot([
|
||
buildMixedDomainRecord("SET-1", "Оплата покупателя по договору", "62.01"),
|
||
buildMixedDomainRecord("VAT-1", "Счет-фактура и НДС в книге продаж", "68.02")
|
||
])
|
||
);
|
||
const result = dataLayer.executeRoute(
|
||
"hybrid_store_plus_live",
|
||
"По оплате от 13 июля на 276 873,60 указан НДС 20% = 46 145,60. Докажи отражение НДС."
|
||
);
|
||
|
||
expect(result.status).toBe("ok");
|
||
const summary = result.summary as Record<string, unknown>;
|
||
const semanticProfile = (summary.semantic_profile ?? {}) as Record<string, unknown>;
|
||
const accountScope = Array.isArray(semanticProfile.account_scope) ? semanticProfile.account_scope : [];
|
||
expect(accountScope).not.toContain("20");
|
||
expect(accountScope).not.toContain("60");
|
||
const domainGuard = (summary.domain_purity_guard ?? {}) as Record<string, unknown>;
|
||
expect(domainGuard.domain_card_id).toBe("vat_document_register_book");
|
||
});
|
||
|
||
it("q13-style broad VAT query on batch route must stay VAT-domain", () => {
|
||
const dataLayer = new AssistantDataLayer(
|
||
createSnapshotRoot([
|
||
buildMixedDomainRecord("SET-2", "Платеж и расчеты с покупателем", "62.02"),
|
||
buildMixedDomainRecord("VAT-2", "Полученный счет-фактура и налоговый эффект", "19.03")
|
||
])
|
||
);
|
||
const result = dataLayer.executeRoute(
|
||
"batch_refresh_then_store",
|
||
"Есть ли в июльском срезе покупки, где есть товар/услуга, но не видно счета-фактуры или налогового эффекта?"
|
||
);
|
||
|
||
const summary = result.summary as Record<string, unknown>;
|
||
const domainGuard = (summary.domain_purity_guard ?? {}) as Record<string, unknown>;
|
||
expect(domainGuard.domain_card_id).toBe("vat_document_register_book");
|
||
});
|
||
|
||
it("q06 first-check should be settlement-specific for prove_or_guess without explicit account tokens", () => {
|
||
const settlementUnit = buildProblemUnit({
|
||
id: "pu-settlement-wave16",
|
||
type: "broken_chain_segment",
|
||
account: "62.02",
|
||
defect: "failed_edge:payment_to_settlement",
|
||
lifecycleDomain: "customer_settlement"
|
||
});
|
||
|
||
const output = composeAssistantAnswer({
|
||
userMessage:
|
||
"Есть ли в июльском срезе ситуация, где деньги уже пришли, но закрытие расчётов не подтверждено тем документом?",
|
||
routeSummary: buildRouteSummary(),
|
||
retrievalResults: [buildRetrieval({ status: "ok", units: [settlementUnit], domainScope: ["settlements"] })],
|
||
requirements: [
|
||
{
|
||
requirement_id: "R1",
|
||
source_fragment_id: "F1",
|
||
requirement_text: "Wave16 requirement",
|
||
subject_tokens: [],
|
||
status: "covered",
|
||
route: "hybrid_store_plus_live"
|
||
}
|
||
],
|
||
coverageReport: buildCoverage(),
|
||
groundingCheck: buildGrounding(),
|
||
focusDomainHint: null,
|
||
questionTypeHint: "prove_or_guess",
|
||
enableAnswerPolicyV11: true,
|
||
enableProblemCentricAnswerV1: true,
|
||
enableLifecycleAnswerV1: true
|
||
});
|
||
|
||
expect(output.assistant_reply).toMatch(/регистр расчет|60\/62\/76|договор|объект расчет/i);
|
||
});
|
||
|
||
it("q12-like question should resolve prove_or_guess instead of chain classification", () => {
|
||
const resolved = resolveQuestionType(
|
||
"Связан ли полученный 31 июля счёт-фактура с услугой так, чтобы вычет был корректен, и это доказано или только предположение?"
|
||
);
|
||
expect(resolved).toBe("prove_or_guess");
|
||
});
|
||
|
||
it("why_breaks short line should avoid generic collapse and keep domain-specific mechanism", () => {
|
||
const settlementUnit = buildProblemUnit({
|
||
id: "pu-settlement-wave16-2",
|
||
type: "broken_chain_segment",
|
||
account: "62.01",
|
||
defect: "failed_edge:payment_to_settlement",
|
||
lifecycleDomain: "customer_settlement"
|
||
});
|
||
const output = composeAssistantAnswer({
|
||
userMessage: "Почему по расчетам долг остался после оплаты?",
|
||
routeSummary: buildRouteSummary(),
|
||
retrievalResults: [buildRetrieval({ status: "ok", units: [settlementUnit], domainScope: ["settlements"] })],
|
||
requirements: [
|
||
{
|
||
requirement_id: "R1",
|
||
source_fragment_id: "F1",
|
||
requirement_text: "Wave16 requirement",
|
||
subject_tokens: [],
|
||
status: "covered",
|
||
route: "hybrid_store_plus_live"
|
||
}
|
||
],
|
||
coverageReport: buildCoverage({
|
||
requirements_covered: 0,
|
||
requirements_partially_covered: ["R1"]
|
||
}),
|
||
groundingCheck: buildGrounding({
|
||
status: "partial",
|
||
reasons: ["Mechanism is unresolved for part of the evidence."]
|
||
}),
|
||
focusDomainHint: "settlements_60_62",
|
||
questionTypeHint: "why_breaks",
|
||
enableAnswerPolicyV11: true,
|
||
enableProblemCentricAnswerV1: true,
|
||
enableLifecycleAnswerV1: true
|
||
});
|
||
|
||
expect(output.assistant_reply).toMatch(/наиболее вероятн/i);
|
||
expect(output.assistant_reply).not.toContain("Коротко: Проблема с закрытием расчета подтверждается частично.");
|
||
});
|
||
|
||
it("VAT why_breaks first-check must include VAT-specific checks", () => {
|
||
const vatUnit = buildProblemUnit({
|
||
id: "pu-vat-wave16",
|
||
type: "cross_branch_inconsistency_cluster",
|
||
account: "68.02",
|
||
defect: "invoice_to_vat",
|
||
lifecycleDomain: "vat_flow"
|
||
});
|
||
const output = composeAssistantAnswer({
|
||
userMessage: "Почему по НДС не видно ожидаемого налогового эффекта?",
|
||
routeSummary: buildRouteSummary(),
|
||
retrievalResults: [buildRetrieval({ status: "ok", units: [vatUnit], domainScope: ["vat", "taxes"] })],
|
||
requirements: [
|
||
{
|
||
requirement_id: "R1",
|
||
source_fragment_id: "F1",
|
||
requirement_text: "Wave16 requirement",
|
||
subject_tokens: [],
|
||
status: "covered",
|
||
route: "hybrid_store_plus_live"
|
||
}
|
||
],
|
||
coverageReport: buildCoverage(),
|
||
groundingCheck: buildGrounding(),
|
||
focusDomainHint: "vat_document_register_book",
|
||
questionTypeHint: "why_breaks",
|
||
enableAnswerPolicyV11: true,
|
||
enableProblemCentricAnswerV1: true,
|
||
enableLifecycleAnswerV1: true
|
||
});
|
||
|
||
expect(output.assistant_reply).toMatch(/счет-?фактур|регистр НДС|19\/68|книг/i);
|
||
});
|
||
});
|
||
|