Этап 4 / Волна 18 закрытие блокеров по времени, доменной полярности и допуску доказательной базы

This commit is contained in:
2026-03-29 00:40:06 +03:00
parent 7eb1410501
commit d7e145010b
140 changed files with 417053 additions and 42196 deletions
@@ -28,6 +28,11 @@ describe("assistant mode API", () => {
expect(typeof response.body.debug?.answer_grounding_check?.status).toBe("string");
expect(Array.isArray(response.body.debug?.retrieval_status)).toBe(true);
expect(Array.isArray(response.body.debug?.retrieval_results)).toBe(true);
expect(typeof response.body.debug?.temporal_guard_applied).toBe("boolean");
expect(typeof response.body.debug?.temporal_guard_outcome).toBe("string");
expect(response.body.debug?.domain_polarity_guard).toBeTruthy();
expect(response.body.debug?.evidence_admissibility_gate).toBeTruthy();
expect(response.body.debug?.grounded_answer_eligibility_guard).toBeTruthy();
expect(Array.isArray(response.body.conversation)).toBe(true);
expect(response.body.conversation.length).toBe(2);
});
@@ -133,7 +138,7 @@ describe("assistant mode API", () => {
expect(response.status).toBe(200);
expect(response.body.reply_type).not.toBe("route_mismatch_blocked");
expect(response.body.debug?.answer_grounding_check?.status).not.toBe("route_mismatch_blocked");
expect(["partial", "grounded"]).toContain(String(response.body.debug?.answer_grounding_check?.status));
expect(["partial", "grounded", "no_grounded_answer"]).toContain(String(response.body.debug?.answer_grounding_check?.status));
expect(response.body.reply_type).toBe("partial_coverage");
});
@@ -150,7 +155,7 @@ describe("assistant mode API", () => {
expect(["partial_coverage", "clarification_required", "route_mismatch_blocked", "factual_with_explanation"]).toContain(
String(response.body.reply_type)
);
expect(["partial", "grounded", "route_mismatch_blocked"]).toContain(
expect(["partial", "grounded", "route_mismatch_blocked", "no_grounded_answer"]).toContain(
String(response.body.debug?.answer_grounding_check?.status)
);
expect(typeof response.body.debug?.answer_grounding_check?.route_subject_match).toBe("boolean");
@@ -194,6 +194,46 @@ describe.sequential("assistant follow-up state binding", () => {
expect(second.body.debug?.investigation_state_snapshot?.turn_index).toBe(2);
});
it("isolates scope for independent cross-domain turn and does not carry stale period/domain", async () => {
const app = await createAppWithFlags({
state: "1",
binding: "1",
problemUnits: "1",
continuity: "1",
answerPolicy: "1",
problemCentric: "1"
});
const sessionId = `asst-wave17-scope-isolation-${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 за 2020-06 и почему не закрылся долг."
});
expect(first.status).toBe(200);
expect(first.body.debug?.investigation_state_snapshot?.question_scope_id).toContain("d:settlements_60_62");
expect(first.body.debug?.investigation_state_snapshot?.scope_origin).toBe("explicit_from_message");
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).toBeUndefined();
expect(second.body.debug?.investigation_state_snapshot?.scope_origin).toBe("explicit_from_message");
expect(String(second.body.debug?.investigation_state_snapshot?.question_scope_id ?? "")).toContain(
"d:vat_document_register_book"
);
expect(second.body.debug?.investigation_state_snapshot?.focus?.period).not.toBe("2020-06");
expect(String(second.body.debug?.investigation_state_snapshot?.focus?.domain ?? "")).not.toContain("settlements_60_62");
expect(second.body.debug?.investigation_state_snapshot?.followup_context?.question_scope_id).toBeTruthy();
expect(second.body.debug?.investigation_state_snapshot?.followup_context?.scope_origin).toBe("explicit_from_message");
});
it("rebinds follow-up domain away from settlements on fixed-asset amortization query", async () => {
const app = await createAppWithFlags({
state: "1",
@@ -0,0 +1,359 @@
import { describe, expect, it } from "vitest";
import { resolveCompanyAnchors } from "../src/services/companyAnchorResolver";
import {
applyDomainPolarityGuardToRetrievalResults,
applyEligibilityToGroundingCheck,
applyEvidenceAdmissibilityGate,
applyPolarityHintToExecutionPlan,
applyTemporalHintToExecutionPlan,
evaluateGroundedAnswerEligibility,
resolveDomainPolarityGuard,
resolveTemporalGuard
} from "../src/services/assistantRuntimeGuards";
function buildProblemUnit(input: {
id: string;
lifecycleDomain: string;
defect?: string;
account?: string;
}): any {
return {
schema_version: "problem_unit_v0_1",
problem_unit_id: input.id,
problem_unit_type: "unresolved_settlement_cluster",
title: "test unit",
mechanism_summary: input.defect ?? "mechanism",
business_defect_class: input.defect ?? "mechanism",
severity: { score: 0.8, grade: "high" },
confidence: { score: 0.7, 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 ?? "60"],
affected_counterparties: ["Counterparty:CP-1"],
affected_contracts: ["Contract:CTR-1"],
failed_expected_edge: "payment_to_settlement",
period_impact: { is_period_sensitive: true, impact_class: "close_risk" },
evidence_pack: ["cand-1"],
entity_backlinks: [{ entity: "Document", id: "DOC-1" }],
snapshot_limitations: []
};
}
function buildEvidence(input: {
id: string;
period: string;
accountDebit?: string;
accountCredit?: string;
limitationCode?: string | null;
sourceLayer?: string | null;
}): any {
return {
evidence_id: input.id,
claim_ref: "requirement:R1",
source_type: "retrieval_item",
source_ref: {
schema_version: "evidence_source_ref_v1",
namespace: "unknown",
entity: "MCPLiveMovement",
id: input.id,
period: input.period,
canonical_ref: `evidence_source_ref_v1|unknown|mcplivemovement|${String(input.id).toLowerCase()}|${input.period}`
},
pointer: {
fragment_id: "F1",
route: "hybrid_store_plus_live",
source: {
namespace: "unknown",
entity: "MCPLiveMovement",
id: input.id,
period: input.period
},
locator: {
field_path: "amount",
item_index: 0
}
},
evidence_kind: "mechanism_link",
mechanism_note: "payment_to_settlement",
confidence: "medium",
limitation: input.limitationCode
? {
reason_code: input.limitationCode,
note: null
}
: null,
payload: {
period: input.period,
account_debit: input.accountDebit ?? null,
account_credit: input.accountCredit ?? null,
source_layer: input.sourceLayer ?? null
}
};
}
function buildRetrieval(input?: Partial<any>): any {
return {
fragment_id: "F1",
requirement_ids: ["R1"],
route: "hybrid_store_plus_live",
status: "ok",
result_type: "chain",
items: [],
summary: {
semantic_profile: {
account_scope: ["60"],
domain_scope: ["settlements", "customers"],
relation_patterns: ["payment_to_settlement"]
},
live_mcp: {
matched_rows: 0,
account_scope: ["60"]
}
},
evidence: [],
candidate_evidence: [],
problem_units: [],
problem_unit_summary: null,
why_included: ["test"],
selection_reason: ["test"],
risk_factors: ["test"],
business_interpretation: ["test"],
confidence: "medium",
limitations: [],
errors: [],
...input
};
}
describe("stage4 blocker-pack runtime guards", () => {
it("flags temporal anchor drift outside July 2020 snapshot", () => {
const userMessage = "Почему по оплате от 6 июля 2020 долг по поставщику остался?";
const temporal = resolveTemporalGuard({
userMessage,
companyAnchors: resolveCompanyAnchors(userMessage),
normalized: {
schema_version: "normalized_query_v2_0_2",
fragments: [
{
fragment_id: "F1",
time_scope: {
type: "explicit",
value: "2023-07-06",
confidence: "high"
}
}
]
} as any
});
expect(temporal.temporal_guard_applied).toBe(true);
expect(temporal.temporal_guard_outcome).toBe("failed_out_of_snapshot_window");
expect(temporal.resolved_time_anchor).toBe("2020-07-06");
expect(temporal.reason_codes).toContain("normalized_anchor_out_of_snapshot_window");
});
it("locks July month window when question has month-only anchor", () => {
const userMessage = "В июльском срезе почему по счету 60 остался хвост?";
const temporal = resolveTemporalGuard({
userMessage,
companyAnchors: resolveCompanyAnchors(userMessage),
normalized: {
schema_version: "normalized_query_v2_0_2",
fragments: []
} as any
});
const hintedPlan = applyTemporalHintToExecutionPlan(
[
{
should_execute: true,
fragment_text: "проверить зависший долг по поставщику"
}
],
temporal
);
expect(temporal.temporal_guard_applied).toBe(true);
expect(temporal.temporal_guard_outcome).toBe("passed");
expect(temporal.resolved_time_anchor).toBe("2020-07");
expect(hintedPlan[0].fragment_text).toMatch(/июля 2020|2020-07-01/);
});
it("filters customer settlement semantics from supplier/payable case", () => {
const guard = resolveDomainPolarityGuard({
userMessage: "По поставщику и счету 60 долг не закрылся после оплаты.",
focusDomainHint: "settlements_60_62"
});
const withHint = applyPolarityHintToExecutionPlan(
[
{
should_execute: true,
fragment_text: "проверить цепочку закрытия долга"
}
],
guard
);
const result = applyDomainPolarityGuardToRetrievalResults({
guard,
retrievalResults: [
buildRetrieval({
problem_units: [
buildProblemUnit({
id: "pu-supplier",
lifecycleDomain: "bank_settlement",
defect: "payment_to_settlement",
account: "60"
}),
buildProblemUnit({
id: "pu-customer",
lifecycleDomain: "customer_settlement",
defect: "stale_receivable",
account: "62"
})
],
evidence: [
buildEvidence({
id: "ev-supplier",
period: "2020-07",
accountDebit: "60.01"
}),
buildEvidence({
id: "ev-customer",
period: "2020-07",
accountDebit: "62.01"
})
]
})
]
});
expect(guard.polarity).toBe("supplier_payable");
expect(withHint[0].fragment_text).toMatch(/счет 60|поставщиком/i);
expect(result.audit.outcome).toBe("passed");
expect(result.audit.rejected_problem_units).toBeGreaterThan(0);
expect(result.audit.rejected_evidence).toBeGreaterThan(0);
const units = result.retrievalResults[0].problem_units ?? [];
expect(units.some((item: any) => item.lifecycle_domain === "customer_settlement")).toBe(false);
});
it("rejects inadmissible live evidence on zero matched_rows and wrong account/date", () => {
const userMessage = "Почему по поставщику по счету 60 в июле 2020 хвост не закрыт?";
const temporal = resolveTemporalGuard({
userMessage,
companyAnchors: resolveCompanyAnchors(userMessage),
normalized: {
schema_version: "normalized_query_v2_0_2",
fragments: [
{
fragment_id: "F1",
time_scope: {
type: "explicit",
value: "2020-07",
confidence: "high"
}
}
]
} as any
});
const polarity = resolveDomainPolarityGuard({
userMessage,
focusDomainHint: "settlements_60_62"
});
const gated = applyEvidenceAdmissibilityGate({
retrievalResults: [
buildRetrieval({
items: [
{
source_entity: "MCPLiveMovement",
source_layer: "mcp_live_probe",
account_debit: "68.02",
account_credit: "19.04",
period: "2026-02-01"
}
],
evidence: [
buildEvidence({
id: "ev-live-1",
period: "2026-02-01",
accountDebit: "68.02",
accountCredit: "19.04",
limitationCode: "weak_source_mapping",
sourceLayer: "mcp_live_probe"
})
]
})
],
temporal,
polarity: polarity.polarity,
focusDomainHint: "settlements_60_62",
userMessage,
companyAnchors: resolveCompanyAnchors(userMessage)
});
expect(gated.audit.admissible_evidence_count).toBe(0);
expect(gated.audit.rejected_evidence_count).toBe(1);
expect(gated.audit.reject_breakdown.zero_live_match).toBeGreaterThan(0);
expect(gated.audit.reject_breakdown.wrong_account_scope).toBeGreaterThan(0);
expect(gated.audit.reject_breakdown.future_dated_or_out_of_window).toBeGreaterThan(0);
expect(gated.retrievalResults[0].evidence).toHaveLength(0);
expect(gated.retrievalResults[0].items).toHaveLength(0);
});
it("degrades grounded status when eligibility guard fails", () => {
const eligibility = evaluateGroundedAnswerEligibility({
temporal: {
raw_time_anchor: "6 июля 2020",
resolved_time_anchor: "2020-07-06",
temporal_resolution_source: "company_snapshot_july_day_lock",
temporal_guard_applied: true,
temporal_guard_outcome: "failed_out_of_snapshot_window",
primary_period_window: {
from: "2020-07-06",
to: "2020-07-06",
granularity: "day"
},
reason_codes: ["normalized_anchor_out_of_snapshot_window"]
},
polarity: {
applied: true,
polarity: "supplier_payable",
outcome: "passed",
supplier_score: 3,
customer_score: 0,
account_scope: ["60"],
rejected_problem_units: 0,
rejected_evidence: 0,
critical_contradiction: false,
reason_codes: []
},
evidence: {
candidate_evidence_total: 2,
admissible_evidence_count: 0,
rejected_evidence_count: 2,
rejected_item_count: 1,
reject_breakdown: {
wrong_period: 1,
wrong_domain: 0,
wrong_account_scope: 1,
weak_source_mapping: 0,
zero_live_match: 0,
future_dated_or_out_of_window: 1
},
category_breakdown: {
hard_evidence: 0,
supporting_signal: 0,
inadmissible_noise: 2
},
reason_codes: ["no_admissible_evidence_for_grounded_answer"]
}
});
const grounded = applyEligibilityToGroundingCheck(
{
status: "grounded",
reasons: []
},
eligibility
);
expect(eligibility.eligible).toBe(false);
expect(eligibility.reason_codes).toContain("admissible_evidence_count_zero");
expect(grounded.status).toBe("no_grounded_answer");
expect(grounded.reasons.join(" ")).toMatch(/Недостаточно допустимого evidence|Temporal anchor/i);
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
buildConversationExportForCopy,
sanitizeConversationExportText,
type ConversationExportItem
} from "../../frontend/src/utils/conversationExport";
function buildConversation(): ConversationExportItem[] {
return [
{
message_id: "m-user-1",
role: "user",
text: "Почему не закрывается РБП за июль?",
reply_type: null,
created_at: "2026-03-28T18:00:00.000Z",
trace_id: null,
debug: null
},
{
message_id: "m-asst-1",
role: "assistant",
text: [
"Коротко: есть признаки неполного списания РБП.",
"",
"### debug_payload_json",
"```json",
"{\"route_summary\":\"hybrid_store_plus_live\",\"domain_scope\":[\"deferred_expense\"]}",
"```"
].join("\n"),
reply_type: "partial_coverage",
created_at: "2026-03-28T18:00:05.000Z",
trace_id: "tr-1",
debug: {
route_summary: "hybrid_store_plus_live",
coverage_report: { requirements_covered: 0 }
}
}
];
}
describe("conversation export regression", () => {
it("default export excludes debug payload sections and keeps user-facing text", () => {
const exportText = buildConversationExportForCopy("sess-1", buildConversation(), "default");
expect(exportText).toContain("export_mode: default");
expect(exportText).toContain("Коротко: есть признаки неполного списания РБП.");
expect(exportText).not.toContain("### debug_payload_json");
expect(exportText).not.toContain("### technical_debug_payload_json");
expect(exportText).not.toContain("coverage_report");
expect(exportText).not.toContain("domain_scope");
expect(exportText).not.toContain("route_summary");
});
it("technical export includes debug payload in dedicated section only", () => {
const exportText = buildConversationExportForCopy("sess-1", buildConversation(), "technical");
expect(exportText).toContain("export_mode: technical");
expect(exportText).toContain("### technical_debug_payload_json");
expect(exportText).toContain("\"route_summary\": \"hybrid_store_plus_live\"");
expect(exportText).not.toContain("### debug_payload_json");
});
it("sanitizeConversationExportText removes technical tails", () => {
const source = [
"Ответ для пользователя.",
"domain_scope: [vat_flow]",
"coverage_report: {...}",
"### technical_breakdown_json",
"```json",
"{\"internal\":\"yes\"}",
"```"
].join("\n");
const sanitized = sanitizeConversationExportText(source);
expect(sanitized).toContain("Ответ для пользователя.");
expect(sanitized).not.toContain("domain_scope");
expect(sanitized).not.toContain("coverage_report");
expect(sanitized).not.toContain("technical_breakdown_json");
expect(sanitized).not.toContain("internal");
});
});
@@ -17,6 +17,8 @@ describe("investigation_state flow scaffolding", () => {
expect(first.status).toBe(200);
expect(first.body.debug?.investigation_state_snapshot?.schema_version).toBe("investigation_state_v1");
expect(first.body.debug?.investigation_state_snapshot?.question_scope_id).toBeTruthy();
expect(first.body.debug?.investigation_state_snapshot?.scope_origin).toBe("explicit_from_message");
expect(first.body.debug?.answer_structure_v11?.schema_version).toBe("answer_structure_v1_1");
expect(first.body.debug?.followup_state_usage).toBeUndefined();
@@ -38,6 +40,8 @@ describe("investigation_state flow scaffolding", () => {
expect(second.status).toBe(200);
expect(second.body.debug?.investigation_state_snapshot?.turn_index).toBe(2);
expect(second.body.debug?.followup_state_usage?.applied).toBe(true);
expect(second.body.debug?.investigation_state_snapshot?.question_scope_id).toBeTruthy();
expect(second.body.debug?.investigation_state_snapshot?.followup_context?.question_scope_id).toBeTruthy();
const sessionResponse = await request(app).get(`/api/assistant/session/${sessionId}`);
expect(sessionResponse.status).toBe(200);