Stage_04_Wave_15_Question_Type_Contract_First_Check_Relevance

This commit is contained in:
2026-03-28 14:09:08 +03:00
parent 8b84f5e989
commit 014ff65188
8 changed files with 952 additions and 115 deletions
@@ -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 } from "../src/types/stage2ProblemUnits";
@@ -116,7 +116,7 @@ function buildRetrieval(input: {
{
source_entity: "Document",
source_id: "DOC-1",
display_name: "Счет № 4 от 07.07.20",
display_name: "Счет № 4 от 07.07.20",
account_context: accountScope,
graph_domain_scope: domainScope,
relation_pattern_hits: relationPatterns,
@@ -177,7 +177,7 @@ function buildRetrieval(input: {
limitation: null,
payload: {
notes: input.notes ?? [],
contract: "договор № 01/19-ПТ",
contract: "РґРѕРіРѕРІРѕСЂ в„– 01/19-РџРў",
amount: "276 873,60",
date: "07.07.20"
}
@@ -245,21 +245,21 @@ function composeCase(input: {
focusDomainHint: input.focusDomainHint,
questionTypeHint: input.questionType,
companyAnchors: {
contract_numbers: ["договор № 01/19-ПТ"],
document_numbers: ["документ № 4"],
contract_numbers: ["РґРѕРіРѕРІРѕСЂ в„– 01/19-РџРў"],
document_numbers: ["документ № 4"],
dates: ["07.07.20", "13.07.20"],
amounts: ["276 873,60"],
accounts: ["62.02"],
periods: ["июль 2020"],
periods: ["июль 2020"],
document_types: ["payment", "invoice"],
all: [
"договор № 01/19-ПТ",
"документ № 4",
"РґРѕРіРѕРІРѕСЂ в„– 01/19-РџРў",
"документ № 4",
"07.07.20",
"13.07.20",
"276 873,60",
"account:62.02",
"period:июль 2020"
"period:июль 2020"
]
},
enableAnswerPolicyV11: true,
@@ -279,7 +279,7 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", ()
});
const output = composeCase({
userMessage:
"Почему по договору № 01/19-ПТ от 09.01.2019 оплата 276 873,60 есть, а 62.01/62.02 все равно не сходятся?",
"Почему по договору № 01/19-ПТ от 09.01.2019 оплата 276 873,60 есть, а 62.01/62.02 все равно не сходятся?",
questionType: "why_breaks",
focusDomainHint: "vat_document_register_book",
retrievalResults: [
@@ -294,8 +294,8 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", ()
]
});
expect(output.assistant_reply).toMatch(/расчет|62\.01|62\.02|зачет|зачёт/i);
expect(output.assistant_reply).not.toMatch(/переход от документа к регистру и книге|цепочке ндс/i);
expect(output.assistant_reply).toMatch(/расчет|62\.01|62\.02|зачет|зачёт/i);
expect(output.assistant_reply).not.toMatch(/переход от документа к регистру и книге|цепочке ндс/i);
});
it("question_type_where_break_is_must_produce_localization_style_line", () => {
@@ -308,13 +308,13 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", ()
});
const output = composeCase({
userMessage:
"Где именно разрыв по договору № 01/19-ПТ: в договоре, объекте расчетов или в связке документов?",
"Где именно разрыв по договору № 01/19-ПТ: в договоре, объекте расчетов или в связке документов?",
questionType: "where_break_is",
focusDomainHint: "settlements_60_62",
retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })]
});
expect(output.assistant_reply).toMatch(/локализ|узел разрыва|где именно/i);
expect(output.assistant_reply).toMatch(/локализ|узел разрыва|где/i);
});
it("question_type_prove_or_guess_must_explicitly_separate_proven_vs_hypothesis", () => {
@@ -327,7 +327,7 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", ()
});
const output = composeCase({
userMessage:
"По НДС это доказано по данным или это только гипотеза? На чем основано утверждение?",
"По НДС это доказано по данным или это только гипотеза? На чем основано утверждение?",
questionType: "prove_or_guess",
focusDomainHint: "vat_document_register_book",
retrievalResults: [
@@ -346,26 +346,26 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", ()
}
});
expect(output.assistant_reply).toMatch(/доказан|гипотез|ограничени/i);
expect(output.assistant_reply).toMatch(/доказ|гипотез|огранич/i);
});
it("anchor_usage_lines_must_be_present_when_company_anchors_are_used", () => {
const output = composeCase({
userMessage:
"Оплата по счету № 4 от 07.07.20 на 276 873,60 пришла 13 июля. Что проверить первым по 62.02?",
"Оплата по счету № 4 от 07.07.20 на 276 873,60 пришла 13 июля. Что проверить первым по 62.02?",
questionType: "what_to_check_first",
focusDomainHint: "settlements_60_62",
retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok" })]
});
expect(output.assistant_reply).toMatch(/якоря вопроса/i);
expect(output.assistant_reply).toMatch(/договор|07\.07\.20|276 873,60|62\.02/i);
expect(output.assistant_reply).toMatch(/якоря вопроса|в опоре использованы|якоря из вопроса/i);
expect(output.assistant_reply).toMatch(/РґРѕРіРѕРІРѕСЂ|07\.07\.20|276 873,60|62\.02/i);
});
it("anchor_usage_must_be_honest_when_part_of_anchors_not_confirmed", () => {
const output = composeCase({
userMessage:
"Почему по договору № 01/19-ПТ не сходится 62.02 в июле 2020, если была оплата 276 873,60?",
"Почему по договору № 01/19-ПТ не сходится 62.02 в июле 2020, если была оплата 276 873,60?",
questionType: "what_is_it_grounded_on",
focusDomainHint: "settlements_60_62",
retrievalResults: [
@@ -380,19 +380,19 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", ()
]
});
expect(output.assistant_reply).toMatch(/без прямого подтверждения|ограничени/i);
expect(output.assistant_reply).toMatch(/без прямого подтверждения|огранич/i);
});
it("answers_for_different_question_types_must_not_collapse_to_same_generic_pattern", () => {
const baseRetrieval = [buildRetrieval({ requirementId: "R1", status: "ok" })];
const whereOutput = composeCase({
userMessage: "Где именно разрыв по 62.01/62.02?",
userMessage: "Где именно разрыв по 62.01/62.02?",
questionType: "where_break_is",
focusDomainHint: "settlements_60_62",
retrievalResults: baseRetrieval
});
const checkFirstOutput = composeCase({
userMessage: "Что проверить первым по 62.01/62.02?",
userMessage: "Что проверить первым по 62.01/62.02?",
questionType: "what_to_check_first",
focusDomainHint: "settlements_60_62",
retrievalResults: baseRetrieval
@@ -400,6 +400,9 @@ describe("wave13 domain fit + question-type fit + company-anchor grounding", ()
expect(whereOutput.assistant_reply).not.toEqual(checkFirstOutput.assistant_reply);
expect(whereOutput.assistant_reply).toMatch(/локализ|разрыв/i);
expect(checkFirstOutput.assistant_reply).toMatch(/первый маршрут проверки|начните с первого пункта/i);
expect(checkFirstOutput.assistant_reply).toMatch(
/маршрут первых проверок|проверьте пункты по порядку|начните с первого пункта/i
);
});
});
@@ -0,0 +1,334 @@
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";
import type { QuestionTypeClass } from "../src/services/questionTypeResolver";
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: ["wave15-test"],
selection_reason_summary: ["wave15-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: "Wave15 problem unit",
mechanism_summary: `Mechanism candidate: ${input.defect}.`,
business_defect_class: input.defect,
severity: {
score: 0.74,
grade: "high"
},
confidence: {
score: 0.66,
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: {
requirementId: string;
status: UnifiedRetrievalResult["status"];
units?: ProblemUnit[];
accountScope?: string[];
domainScope?: string[];
relationPatterns?: string[];
}): UnifiedRetrievalResult {
const units = input.units ?? [];
const accountScope = input.accountScope ?? ["60", "62"];
const domainScope = input.domainScope ?? ["customer_settlement"];
const relationPatterns = input.relationPatterns ?? ["payment_to_settlement"];
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",
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: {
broad_query_detected: false,
broad_result_flag: false,
minimum_evidence_failed: false,
degraded_to: null,
narrowing_strength: "strong",
semantic_profile: {
account_scope: accountScope,
domain_scope: domainScope,
relation_patterns: relationPatterns,
period_scope: {
from: "2020-07-01",
to: "2020-07-31",
granularity: "month"
}
}
},
evidence:
input.status === "empty"
? []
: [
{
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_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: `F-${input.requirementId}`,
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: {
notes: ["wave15"],
contract: "договор № 01/19-ПТ",
amount: "276 873,60",
date: "07.07.20"
}
}
],
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: ["wave15-test"],
selection_reason: ["wave15-test"],
risk_factors: ["wave15"],
business_interpretation: ["wave15"],
confidence: "medium",
limitations: [],
errors: []
};
}
function composeCase(input: {
userMessage: string;
questionType: QuestionTypeClass;
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: "Wave15 requirement",
subject_tokens: [],
status: "covered",
route: "hybrid_store_plus_live"
}
],
coverageReport: buildCoverage(input.coverage),
groundingCheck: buildGrounding(input.grounding),
focusDomainHint: input.focusDomainHint,
questionTypeHint: input.questionType,
companyAnchors: {
contract_numbers: ["договор № 01/19-ПТ"],
document_numbers: ["документ № 4"],
dates: ["07.07.20"],
amounts: ["276 873,60"],
accounts: ["62.02"],
periods: ["июль 2020"],
document_types: ["payment"],
all: ["договор № 01/19-ПТ", "документ № 4", "07.07.20", "276 873,60", "account:62.02", "period:июль 2020"]
},
enableAnswerPolicyV11: true,
enableProblemCentricAnswerV1: true,
enableLifecycleAnswerV1: true
});
}
describe("wave15 question-type contract + first-check relevance", () => {
const settlementUnit = buildProblemUnit({
id: "pu-settlement-wave15",
type: "broken_chain_segment",
account: "62.02",
defect: "failed_edge:payment_to_settlement",
lifecycleDomain: "customer_settlement"
});
it("prove_or_guess answer keeps proof-vs-hypothesis framing", () => {
const output = composeCase({
userMessage: "Это по расчетам доказано или пока гипотеза?",
questionType: "prove_or_guess",
focusDomainHint: "settlements_60_62",
retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })],
grounding: {
status: "partial",
reasons: ["Mechanism is unresolved for part of the evidence."]
}
});
expect(output.assistant_reply).toMatch(/доказ|гипотез|ограничен/i);
});
it("grounded_on answer is basis-oriented, not generic mechanism dump", () => {
const output = composeCase({
userMessage: "На чем это основано по 62.02?",
questionType: "what_is_it_grounded_on",
focusDomainHint: "settlements_60_62",
retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })]
});
expect(output.assistant_reply).toMatch(/основан|опор|документ|регистр|60\/62\/76/i);
});
it("chains question provides complete-vs-incomplete framing", () => {
const output = composeCase({
userMessage: "Какие цепочки завершены, а какие нет по 62.01/62.02?",
questionType: "which_chains_are_complete_vs_incomplete",
focusDomainHint: "settlements_60_62",
retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })],
coverage: {
requirements_covered: 0,
requirements_partially_covered: ["R1"],
requirements_uncovered: []
}
});
expect(output.assistant_reply).toMatch(/цепочк|подтвержден|частично|не подтвержден/i);
});
it("where_break_is answer localizes break node", () => {
const output = composeCase({
userMessage: "Где именно разрыв по расчетной цепочке?",
questionType: "where_break_is",
focusDomainHint: "settlements_60_62",
retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })]
});
expect(output.assistant_reply).toMatch(/узел разрыва|локализ|где/i);
});
it("what_to_check_first answer gives short operational route", () => {
const output = composeCase({
userMessage: "Что проверить первым по 62.01/62.02?",
questionType: "what_to_check_first",
focusDomainHint: "settlements_60_62",
retrievalResults: [buildRetrieval({ requirementId: "R1", status: "ok", units: [settlementUnit] })]
});
expect(output.assistant_reply).toMatch(/что проверить первым/i);
expect(output.assistant_reply).toMatch(/договор|регистр|60\/62\/76/i);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { resolveQuestionType } from "../src/services/questionTypeResolver";
describe("questionTypeResolver", () => {
it("resolves what_to_check_first for operational check phrasing", () => {
expect(resolveQuestionType("Что проверить первым по 62.01/62.02?"))
.toBe("what_to_check_first");
});
it("resolves where_break_is for localization phrasing", () => {
expect(resolveQuestionType("Где именно разрыв: в договоре или в связке документ -> регистр?"))
.toBe("where_break_is");
});
it("resolves prove_or_guess without collapsing to generic yes/no", () => {
expect(resolveQuestionType("Это доказано или пока только гипотеза?"))
.toBe("prove_or_guess");
});
it("resolves grounded_on when question asks for evidence basis", () => {
expect(resolveQuestionType("На чем это основано и чем подтверждается вывод?"))
.toBe("what_is_it_grounded_on");
});
it("resolves chains completeness classification questions", () => {
expect(resolveQuestionType("Какие цепочки подтверждены, а какие не завершены?"))
.toBe("which_chains_are_complete_vs_incomplete");
});
it("falls back to why_breaks for generic why-question", () => {
expect(resolveQuestionType("Почему не сходится 62.01/62.02?"))
.toBe("why_breaks");
});
});