398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
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(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: "grounded",
|
|
route_subject_match: true,
|
|
missing_requirements: [],
|
|
reasons: [],
|
|
why_included_summary: ["wave12-test"],
|
|
selection_reason_summary: ["wave12-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: "Wave12 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[];
|
|
relationPatterns?: string[];
|
|
limitations?: string[];
|
|
confidence?: UnifiedRetrievalResult["confidence"];
|
|
withEvidence?: boolean;
|
|
}): UnifiedRetrievalResult {
|
|
const units = input.units ?? [];
|
|
const withEvidence = input.withEvidence ?? input.status !== "empty";
|
|
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"],
|
|
relation_pattern_hits: input.relationPatterns ?? ["payment_to_settlement"]
|
|
}
|
|
],
|
|
summary: {
|
|
broad_query_detected: false,
|
|
broad_result_flag: false,
|
|
minimum_evidence_failed: false,
|
|
degraded_to: null,
|
|
narrowing_strength: "strong",
|
|
semantic_profile: {
|
|
account_scope: input.accountScope ?? ["60", "62"],
|
|
domain_scope: input.domainScope ?? ["bank_settlement", "customer_settlement"],
|
|
relation_patterns: input.relationPatterns ?? ["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-07",
|
|
canonical_ref: "evidence_source_ref_v1|snapshot_2020|document|doc-1|2020-07"
|
|
},
|
|
pointer: {
|
|
fragment_id: `F-${input.requirementId}`,
|
|
route: "hybrid_store_plus_live",
|
|
source: {
|
|
namespace: "snapshot_2020",
|
|
entity: "document",
|
|
id: "DOC-1",
|
|
period: "2020-07"
|
|
},
|
|
locator: {
|
|
field_path: "risk_score",
|
|
item_index: 0
|
|
}
|
|
},
|
|
evidence_kind: "mechanism_link",
|
|
mechanism_note: (input.relationPatterns ?? ["payment_to_settlement"])[0],
|
|
confidence: input.status === "ok" ? "medium" : "low",
|
|
limitation: null,
|
|
payload: {
|
|
risk_score: 4
|
|
}
|
|
}
|
|
],
|
|
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: ["wave12-test"],
|
|
selection_reason: ["wave12-test"],
|
|
risk_factors: ["wave12"],
|
|
business_interpretation: ["wave12"],
|
|
confidence: input.confidence ?? (input.status === "ok" ? "medium" : "low"),
|
|
limitations: input.limitations ?? [],
|
|
errors: []
|
|
};
|
|
}
|
|
|
|
function composeCase(input: {
|
|
userMessage: string;
|
|
focusDomainHint: string | null;
|
|
retrievalResults: UnifiedRetrievalResult[];
|
|
coverage?: Partial<RequirementCoverageReport>;
|
|
grounding?: Partial<AnswerGroundingCheck>;
|
|
}) {
|
|
return composeAssistantAnswer({
|
|
userMessage: input.userMessage,
|
|
routeSummary: buildRouteSummary(),
|
|
retrievalResults: input.retrievalResults,
|
|
requirements: [
|
|
{
|
|
requirement_id: "R1",
|
|
source_fragment_id: "F-R1",
|
|
requirement_text: "Wave 12 requirement",
|
|
subject_tokens: [],
|
|
status: "covered",
|
|
route: "hybrid_store_plus_live"
|
|
}
|
|
],
|
|
coverageReport: buildCoverage(input.coverage),
|
|
groundingCheck: buildGrounding(input.grounding),
|
|
focusDomainHint: input.focusDomainHint,
|
|
enableAnswerPolicyV11: true,
|
|
enableProblemCentricAnswerV1: true,
|
|
enableLifecycleAnswerV1: true
|
|
});
|
|
}
|
|
|
|
describe("wave12 vat/month-close consistency + confidence reconciliation", () => {
|
|
it("vat_query_with_strong_signal_must_override_stale_settlement_focus_hint", () => {
|
|
const vatUnit = buildProblemUnit({
|
|
id: "pu-vat-1",
|
|
type: "cross_branch_inconsistency_cluster",
|
|
account: "68",
|
|
defect: "invoice_to_vat",
|
|
lifecycleDomain: "vat_flow"
|
|
});
|
|
const output = composeCase({
|
|
userMessage: "VAT chain July: invoice exists, but purchase book is empty for accounts 19/68. Why?",
|
|
focusDomainHint: "settlements_60_62",
|
|
retrievalResults: [
|
|
buildRetrieval({
|
|
requirementId: "R1",
|
|
status: "ok",
|
|
units: [vatUnit],
|
|
accountScope: ["19", "68"],
|
|
domainScope: ["vat_flow"],
|
|
relationPatterns: ["invoice_to_vat"]
|
|
})
|
|
]
|
|
});
|
|
|
|
expect(output.assistant_reply).toMatch(/НДС|vat|книг|счет[-\s]?фактур/i);
|
|
expect(output.assistant_reply).not.toMatch(/60\/62|закрыти[ея]\s+расч[её]т/i);
|
|
});
|
|
|
|
it("month_close_query_with_strong_signal_must_override_stale_vat_focus_hint", () => {
|
|
const closeUnit = buildProblemUnit({
|
|
id: "pu-close-1",
|
|
type: "period_risk_cluster",
|
|
account: "25",
|
|
defect: "close_operation_runs_missing",
|
|
lifecycleDomain: "period_close"
|
|
});
|
|
const output = composeCase({
|
|
userMessage: "Month close July: costs on accounts 20/44 were allocated partially. Where is the break?",
|
|
focusDomainHint: "vat_document_register_book",
|
|
retrievalResults: [
|
|
buildRetrieval({
|
|
requirementId: "R1",
|
|
status: "ok",
|
|
units: [closeUnit],
|
|
accountScope: ["25", "26"],
|
|
domainScope: ["period_close"],
|
|
relationPatterns: ["close_operation_runs"]
|
|
})
|
|
]
|
|
});
|
|
|
|
expect(output.assistant_reply).toMatch(/закрыти|месяц|затрат|распредел|month close|20\/44/i);
|
|
expect(output.assistant_reply).not.toMatch(/НДС|счет[-\s]?фактур|книг/i);
|
|
});
|
|
|
|
it("vat_domain_with_foreign_primary_evidence_must_degrade_to_clarification", () => {
|
|
const output = composeCase({
|
|
userMessage: "Проверь НДС-цепочку по июлю: документ -> регистр -> книга.",
|
|
focusDomainHint: "vat_document_register_book",
|
|
retrievalResults: [
|
|
buildRetrieval({
|
|
requirementId: "R1",
|
|
status: "ok",
|
|
units: [],
|
|
accountScope: ["20"],
|
|
domainScope: ["period_close", "fixed_asset"],
|
|
relationPatterns: ["allocation_rules_resolved"]
|
|
})
|
|
]
|
|
});
|
|
|
|
expect(output.reply_type).toBe("clarification_required");
|
|
expect(output.assistant_reply).toContain("Ограничения:");
|
|
expect(output.assistant_reply).not.toContain("Опора достаточна для первичного вывода.");
|
|
});
|
|
|
|
it("month_close_domain_with_vat_primary_evidence_must_degrade_to_clarification", () => {
|
|
const output = composeCase({
|
|
userMessage: "Проверь закрытие месяца и контур затрат 20-44 за июль.",
|
|
focusDomainHint: "month_close_costs_20_44",
|
|
retrievalResults: [
|
|
buildRetrieval({
|
|
requirementId: "R1",
|
|
status: "ok",
|
|
units: [],
|
|
accountScope: ["19", "68"],
|
|
domainScope: ["vat_flow"],
|
|
relationPatterns: ["invoice_to_vat"]
|
|
})
|
|
]
|
|
});
|
|
|
|
expect(output.reply_type).toBe("clarification_required");
|
|
expect(output.assistant_reply).toContain("Ограничения:");
|
|
expect(output.assistant_reply).not.toContain("Опора достаточна для первичного вывода.");
|
|
});
|
|
|
|
it("confidence_limitation_must_not_contradict_each_other", () => {
|
|
const vatUnit = buildProblemUnit({
|
|
id: "pu-vat-2",
|
|
type: "lifecycle_anomaly_node",
|
|
account: "68",
|
|
defect: "invoice_to_vat",
|
|
lifecycleDomain: "vat_flow"
|
|
});
|
|
const output = composeCase({
|
|
userMessage: "Почему по НДС есть сигнал, но механизм выглядит неполным?",
|
|
focusDomainHint: "vat_document_register_book",
|
|
retrievalResults: [
|
|
buildRetrieval({
|
|
requirementId: "R1",
|
|
status: "ok",
|
|
units: [vatUnit],
|
|
accountScope: ["19", "68"],
|
|
domainScope: ["vat_flow"],
|
|
relationPatterns: ["invoice_to_vat"],
|
|
limitations: ["Source mapping is weak for part of the evidence."]
|
|
})
|
|
],
|
|
grounding: {
|
|
status: "partial",
|
|
reasons: ["Mechanism is unresolved for part of the evidence."]
|
|
}
|
|
});
|
|
|
|
expect(output.answer_structure_v11?.mechanism_block?.status).toBe("limited");
|
|
expect(output.assistant_reply).toContain("Ограничения:");
|
|
expect(output.assistant_reply).not.toContain("Опора достаточна для первичного вывода.");
|
|
});
|
|
|
|
it("settlement_regression_must_remain_pass", () => {
|
|
const settlementUnit = buildProblemUnit({
|
|
id: "pu-settlement-1",
|
|
type: "broken_chain_segment",
|
|
account: "62",
|
|
defect: "failed_edge:payment_to_settlement",
|
|
lifecycleDomain: "customer_settlement"
|
|
});
|
|
const output = composeCase({
|
|
userMessage: "Оплата есть, но 62.01/62.02 не сходятся. Почему долг остался?",
|
|
focusDomainHint: "settlements_60_62",
|
|
retrievalResults: [
|
|
buildRetrieval({
|
|
requirementId: "R1",
|
|
status: "ok",
|
|
units: [settlementUnit],
|
|
accountScope: ["62.01", "62.02"],
|
|
domainScope: ["customer_settlement"],
|
|
relationPatterns: ["payment_to_settlement"]
|
|
})
|
|
]
|
|
});
|
|
|
|
expect(output.assistant_reply).toMatch(/62\.01|62\.02|расч[её]т|зач[её]т/i);
|
|
expect(output.assistant_reply).not.toMatch(/НДС|книг|закрыти[ея]\s+месяц/i);
|
|
});
|
|
});
|