Этап 4 / Волна 16: добивка остаточных ошибок и устранение шаблонных ответов

This commit is contained in:
2026-03-28 16:23:41 +03:00
parent 014ff65188
commit 6123fafd5b
8 changed files with 787 additions and 51 deletions
@@ -1262,7 +1262,9 @@ function isProblemUnitAlignedWithNarrativeDomain(unit: ProblemUnit, domain: P0Na
if (accounts.some((item) => isVatAccountToken(item))) {
return true;
}
return /(vat|ндс|invoice|book_entry|register|книг|счет[\s-]?фактур|сч[её]т[\s-]?фактур)/i.test(corpus);
return /(vat|ндс|invoice|book_entry|register|книг|сч[её]т(?:а|у|ом|е)?[\s-]?фактур(?:а|ы|е|у|ой)?|вычет|налогов(?:ый|ого)?\s+эффект)/i.test(
corpus
);
}
if (domain === "month_close_costs_20_44") {
@@ -2387,13 +2389,15 @@ function hasStrongNarrativeDomainSignalInText(userMessage: string, domain: P0Nar
if (domain === "settlements_60_62") {
return (
accountTokens.some((item) => isSettlementAccountToken(item)) ||
/(60\.0[12]|62\.0[12]|долг|аванс|зач[её]т|взаимозач|расч[её]т)/i.test(text)
/(60\.0[12]|62\.0[12]|долг|аванс|зач[её]т|взаимозач|расч[её]т|оплат|плат[её]ж|деньг[аи])/i.test(text)
);
}
if (domain === "vat_document_register_book") {
return (
accountTokens.some((item) => isVatAccountToken(item)) ||
/(ндс|vat|счет[-\s]?фактур|сч[её]т[-\s]?фактур|книг[аи]|регистр)/i.test(text)
/(ндс|vat|сч[её]т(?:а|у|ом|е)?[-\s]?фактур(?:а|ы|е|у|ой)?|книг[аи]|регистр|вычет|налогов(?:ый|ого)?\s+эффект)/i.test(
text
)
);
}
if (domain === "month_close_costs_20_44") {
@@ -2855,7 +2859,7 @@ function buildProblemCentricAnswerStructure(input: {
}
function limitationReasonToUserText(code: EvidenceLimitationReasonCode): string {
if (code === "snapshot_only") return "Вывод сделан по snapshot и может не включать самые свежие изменения.";
if (code === "snapshot_only") return "Оценка построена на snapshot-срезе и может не включать самые свежие изменения.";
if (code === "heuristic_inference") return "Часть вывода построена эвристически и требует проверки в базе.";
if (code === "missing_mechanism") return "Механизм проблемы подтвержден не полностью.";
if (code === "weak_source_mapping") return "Связка между источниками подтверждена частично.";
@@ -2884,7 +2888,14 @@ function inferNarrativeDomainFromText(value: string): P0NarrativeDomain {
if (/(долг|аванс|взаимозач|зачет|зачёт|62\.01|62\.02|60\.01|60\.02|не\s+сход)/i.test(text)) {
settlementScore += 2;
}
if (/(ндс|vat|счет[-\s]?фактур|сч[её]т[-\s]?фактур|книг[аи]|регистр)/i.test(text)) {
if (/(расч[её]т|оплат|плат[её]ж|деньг[аи]|закрыти[ея]\s+расч)/i.test(text)) {
settlementScore += 2;
}
if (
/(ндс|vat|сч[её]т(?:а|у|ом|е)?[-\s]?фактур(?:а|ы|е|у|ой)?|книг[аи]|регистр|вычет|налогов(?:ый|ого)?\s+эффект)/i.test(
text
)
) {
vatScore += 3;
}
if (
@@ -3080,8 +3091,8 @@ function buildEvidenceSectionLines(
function buildDefaultChecksByDomain(domain: P0NarrativeDomain): string[] {
if (domain === "settlements_60_62") {
return [
"Проверьте договор, объект расчетов, регистр расчетов и документ закрытия (зачет аванса или взаимозачет).",
"Сверьте связку платеж -> расчетный документ -> проводки по 60/62/76 и подтверждение закрытия хвоста."
"Сверьте договор и объект расчетов, затем подтвердите запись в регистре расчетов и документ зачета.",
"Проверьте связку платеж -> расчетный документ -> проводки по 60/62/76 и факт закрытия хвоста."
];
}
if (domain === "vat_document_register_book") {
@@ -3248,10 +3259,12 @@ function buildChecksSectionLines(structure: AnswerStructureV11, context?: Answer
if (domain === "settlements_60_62") {
lines.push(...domainFallback.slice(0, 2));
lines.push(...actionLines.slice(0, 2));
} else if (actionLines.length > 0) {
lines.push(...actionLines.slice(0, 2));
} else {
lines.push(...domainFallback.slice(0, 2));
lines.push(...domainFallback.slice(0, 1));
lines.push(...actionLines.slice(0, 2));
if (lines.length < 2) {
lines.push(...domainFallback.slice(1, 2));
}
}
}
if (hasMissingPeriod) {
@@ -3283,7 +3296,7 @@ function humanizeLimitationToken(value: string): string | null {
return "Целевой механизм активного домена подтвержден частично; вывод ограничен.";
if (normalized === "settlement_primary_evidence_not_confirmed")
return "Опора по расчетному контуру не подтверждена: в приоритете были сигналы из смежных доменов.";
if (normalized.includes("snapshot")) return "Вывод сделан по snapshot и может не включать часть цепочки.";
if (normalized.includes("snapshot")) return "Оценка сделана на snapshot-срезе и может не включать часть цепочки.";
if (normalized.includes("heuristic")) return "Часть вывода основана на эвристике.";
if (normalized.includes("weak_source_mapping")) return "Связка между источниками подтверждена частично.";
if (normalized.includes("missing_mechanism")) return "Механизм проблемы подтвержден не полностью.";
@@ -3297,7 +3310,7 @@ function humanizeLimitationToken(value: string): string | null {
if (/broad query support is limited/i.test(raw)) return "Запрос широкий, поэтому вывод ограничен частичной опорой.";
if (/broad ranking output was tightened/i.test(raw)) return "Часть ранжирования ограничена, чтобы избежать ложной точности.";
if (/weak mechanism evidence/i.test(raw)) return "Доказательность механизма слабая, нужен ручной контроль.";
if (/evidence is snapshot-only/i.test(raw)) return "Вывод сделан по snapshot и может не включать самые свежие изменения.";
if (/evidence is snapshot-only/i.test(raw)) return "Оценка сделана на snapshot-срезе и может не включать самые свежие изменения.";
if (/source-of-record/i.test(raw)) return "Часть цепочки нужно подтвердить в исходной учетной базе.";
if (/[a-z]/i.test(raw) && !/[а-яё]/iu.test(raw)) return null;
@@ -3361,6 +3374,18 @@ function buildQuestionTypeShortLine(context: AnswerRenderContext): string | null
if (context.questionType === "what_to_check_first") {
return `\u041a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u0435\u0440\u0432\u044b\u0445 \u043f\u0440\u043e\u0432\u0435\u0440\u043e\u043a \u0432\u043d\u0443\u0442\u0440\u0438 ${domainName}.`;
}
if (context.questionType === "why_breaks") {
if (context.focusDomain === "settlements_60_62") {
return "Наиболее вероятная причина: переход от оплаты к закрытию расчета подтвержден не полностью.";
}
if (context.focusDomain === "vat_document_register_book") {
return "Наиболее вероятная причина: переход документа НДС к регистру и книге подтвержден частично.";
}
if (context.focusDomain === "month_close_costs_20_44") {
return "Наиболее вероятная причина: цепочка распределения затрат и закрытия месяца подтверждена не полностью.";
}
return "Наиболее вероятный механизм проблемы подтвержден частично и требует первичной проверки.";
}
return null;
}
@@ -388,10 +388,11 @@ const P0_DOMAIN_CARDS: P0DomainCard[] = [
symptom_markers: [
/\bvat\b/i,
/\u043d\u0434\u0441/i,
/\u0441\u0447[её]т.?фактур/i,
/\u0441\u0447[её]т(?:а|у|ом|е)?.?фактур/i,
/\u043a\u043d\u0438\u0433[аи]\s+\u043f\u043e\u043a\u0443\u043f/i,
/\u043a\u043d\u0438\u0433[аи]\s+\u043f\u0440\u043e\u0434\u0430\u0436/i,
/\u0432\u044b\u0447\u0435\u0442/i
/\u0432\u044b\u0447\u0435\u0442/i,
/\u043d\u0430\u043b\u043e\u0433\u043e\u0432(?:\u044b\u0439|\u043e\u0433\u043e)?\s+\u044d\u0444\u0444\u0435\u043a\u0442/i
]
},
{
@@ -1209,6 +1210,37 @@ function collectDateLikeSpans(text: string): Array<{ start: number; end: number
return spans;
}
function collectAmountLikeSpans(text: string): Array<{ start: number; end: number }> {
const spans: Array<{ start: number; end: number }> = [];
const patterns = [
/\b\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?\b/g,
/\b\d+[.,]\d{2}\b/g
];
for (const pattern of patterns) {
let match: RegExpExecArray | null = null;
while ((match = pattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
}
return spans;
}
function collectPercentLikeSpans(text: string): Array<{ start: number; end: number }> {
const spans: Array<{ start: number; end: number }> = [];
const pattern = /\b\d{1,3}(?:[.,]\d+)?\s*%/g;
let match: RegExpExecArray | null = null;
while ((match = pattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
return spans;
}
function intersectsSpan(start: number, end: number, spans: Array<{ start: number; end: number }>): boolean {
return spans.some((span) => start < span.end && end > span.start);
}
@@ -1221,7 +1253,11 @@ function hasAccountContextAround(text: string, start: number, end: number): bool
function extractAccountScopeFromText(text: string): string[] {
const lower = String(text ?? "").toLowerCase();
const dateSpans = collectDateLikeSpans(lower);
const blockedSpans = [
...collectDateLikeSpans(lower),
...collectAmountLikeSpans(lower),
...collectPercentLikeSpans(lower)
];
const accounts: string[] = [];
const pushAccount = (raw: string): void => {
const prefix = String(raw ?? "").trim().match(/^(\d{2})/)?.[1];
@@ -1271,7 +1307,7 @@ function extractAccountScopeFromText(text: string): string[] {
const token = suffixAnchorMatch[0];
const start = suffixAnchorMatch.index;
const end = start + token.length;
if (intersectsSpan(start, end, dateSpans)) {
if (intersectsSpan(start, end, blockedSpans)) {
continue;
}
pushAccount(token);
@@ -1286,7 +1322,7 @@ function extractAccountScopeFromText(text: string): string[] {
const token = explicitMatch[0];
const start = explicitMatch.index;
const end = start + token.length;
if (intersectsSpan(start, end, dateSpans)) {
if (intersectsSpan(start, end, blockedSpans)) {
continue;
}
const prefix = token.match(/^(\d{2})/)?.[1];
@@ -1465,7 +1501,7 @@ function buildSemanticRetrievalProfile(fragmentText: string): SemanticRetrievalP
pushMany(relationPatterns, ["payment_to_settlement", "contract_to_documents"]);
}
if (
/РЅРґСЃ|ндс|vat|РєРЅРёРіР° РїРѕРєСѓРїРѕРє|РєРЅРёРіР° продаж|счет.?фактур|книг[аи]\s+покуп|книг[аи]\s+продаж|сч[её]т.?фактур/i.test(
/РЅРґСЃ|ндс|vat|РєРЅРёРіР° РїРѕРєСѓРїРѕРє|РєРЅРёРіР° продаж|счет.?фактур|книг[аи]\s+покуп|книг[аи]\s+продаж|сч[её]т(?:а|у|ом|е)?[-\s]?фактур(?:а|ы|е|у|ой)?|вычет|налогов(?:ый|ого)?\s+эффект/i.test(
lower
) ||
hasVatAccountScope
@@ -1599,7 +1635,8 @@ function cardResolutionScore(card: P0DomainCard, fragmentText: string, profile:
return 0;
}
const hasHardAnchor = accountMatches.length > 0 || markerHit;
const hasVatSoftAnchor = card.id === "vat_document_register_book" && hasStrongVatDomainSignal(fragmentText, profile);
const hasHardAnchor = accountMatches.length > 0 || markerHit || hasVatSoftAnchor;
if (!hasHardAnchor) {
return 0;
}
@@ -1607,6 +1644,26 @@ function cardResolutionScore(card: P0DomainCard, fragmentText: string, profile:
return accountMatches.length * 4 + domainMatches.length * 3 + (markerHit ? 2 : 0);
}
function hasStrongVatDomainSignal(fragmentText: string, profile: SemanticRetrievalProfile): boolean {
const text = String(fragmentText ?? "");
const hasVatLexicalAnchor =
/(?:ндс|vat|сч[её]т(?:а|у|ом|е)?[-\s]?фактур(?:а|ы|е|у|ой)?|книг[аи]\s+(?:покуп|продаж)|вычет|налогов(?:ый|ого)?\s+эффект)/iu.test(
text
);
return (
hasVatLexicalAnchor ||
profile.account_scope.some((account) => account === "19" || account === "68") ||
profile.domain_scope.some((domain) => domain === "vat" || domain === "taxes") ||
profile.relation_patterns.some((pattern) =>
["invoice_to_vat", "register_to_book", "book_entry_generated", "deduction_posted"].includes(pattern)
)
);
}
function hasStrongSettlementAccountSignal(profile: SemanticRetrievalProfile): boolean {
return profile.account_scope.some((account) => account === "51" || account === "60" || account === "62" || account === "76");
}
function resolveP0DomainCard(fragmentText: string, profile: SemanticRetrievalProfile): P0DomainResolution | null {
const resolved = P0_DOMAIN_CARDS.map((card) => ({
card,
@@ -1620,6 +1677,11 @@ function resolveP0DomainCard(fragmentText: string, profile: SemanticRetrievalPro
}
const [first, second] = resolved;
if (second && second.score === first.score) {
const pair = new Set<P0DomainCardId>([first.card.id, second.card.id]);
const hasVatSettlementTie = pair.has("vat_document_register_book") && pair.has("settlements_60_62");
if (hasVatSettlementTie && hasStrongVatDomainSignal(fragmentText, profile) && !hasStrongSettlementAccountSignal(profile)) {
return resolved.find((item) => item.card.id === "vat_document_register_book") ?? null;
}
return null;
}
return first;
@@ -3443,7 +3505,39 @@ export class AssistantDataLayer {
private executeBatch(fragmentText: string, data: DatasetBundle): RawRetrievalResult {
const semanticProfile = buildSemanticRetrievalProfile(fragmentText);
const source = [...data.problemCases, ...data.keyFields, ...data.docs];
const resolvedDomain = resolveP0DomainCard(fragmentText, semanticProfile);
const domainCard = resolvedDomain?.card ?? null;
const fallbackSources: DatasetSourceName[] = ["problemCases", "keyFields", "docs"];
const sourceScope = domainCard
? (uniqueStrings([...domainCard.allowed_evidence_sources.risk, ...domainCard.allowed_evidence_sources.canonical]) as DatasetSourceName[])
: fallbackSources;
const sourcePool = collectSourceRecords(data, sourceScope);
const strictForbidden = Boolean(domainCard);
let sourceGate = domainCard
? applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: strictForbidden })
: {
accepted: sourcePool.map((item) => ({
...item,
signals: inferRecordSignals(item.record),
purity: {
allowed: true,
account_match: true,
domain_match: true,
entity_match: true,
edge_match: true,
forbidden_domains: [],
cross_domain_overlap: []
}
})),
rejected_total: 0,
rejected_forbidden: 0
};
let sourceStrictFallbackUsed = false;
if (domainCard && strictForbidden && sourceGate.accepted.length === 0 && sourcePool.length > 0) {
sourceGate = applyDomainPuritySourceGate(sourcePool, domainCard, semanticProfile, { strict_forbidden: false });
sourceStrictFallbackUsed = true;
}
const source = sourceGate.accepted.map((item) => item.record);
const byEntity = new Map<string, number>();
for (const record of source) {
byEntity.set(record.source_entity, (byEntity.get(record.source_entity) ?? 0) + 1);
@@ -3458,29 +3552,56 @@ export class AssistantDataLayer {
records_count: count
}));
const puritySummary = {
enabled: Boolean(domainCard),
domain_card_id: domainCard?.id ?? null,
domain_card_title: domainCard?.title ?? null,
source_scope: sourceScope,
source_pool_records: sourcePool.length,
source_selection_allowed: sourceGate.accepted.length,
source_selection_rejected: sourceGate.rejected_total,
source_selection_rejected_forbidden: sourceGate.rejected_forbidden,
top1_pure: domainCard ? topOnePurityHolds(sourceGate.accepted) : true,
top3_pure: domainCard ? topThreePurityHolds(sourceGate.accepted) : true,
strict_forbidden_mode: strictForbidden,
strict_forbidden_fallback_source: sourceStrictFallbackUsed
};
return {
status: items.length > 0 ? "ok" : "empty",
result_type: "ranking",
items,
summary: {
checked_records: source.length,
checked_records: sourcePool.length,
ranked_entities: items.length,
query_subject: semanticProfile.query_subject,
semantic_profile: semanticProfile,
ranking_basis: semanticProfile.ranking_basis
ranking_basis: semanticProfile.ranking_basis,
domain_purity_guard: puritySummary
},
evidence: items.slice(0, 5).map((item) => ({
entity: item.entity,
records_count: item.records_count
})),
why_included: items.length > 0 ? ["Показаны сущности с максимальным количеством записей."] : [],
selection_reason: ["Ранжирование выполнено по records_count по убыванию."],
why_included: items.length > 0
? [
"Показаны сущности с максимальным количеством записей.",
domainCard ? `P0 domain purity enforced for ${domainCard.id}.` : "P0 domain purity was not enforced."
]
: [],
selection_reason: [
"Ранжирование выполнено по records_count по убыванию.",
domainCard ? `Domain gate source scope: ${sourceScope.join(", ")}.` : "Domain gate source scope not applied."
],
risk_factors: uniqueStrings(["entity_volume_spike", ...semanticProfile.anomaly_patterns]),
business_interpretation: [
"Top entities by volume highlight where lifecycle-focused review should start first."
],
confidence: "medium",
limitations: ["Ранжирование по объему не всегда эквивалентно бизнес-риску."],
limitations: [
"Ранжирование по объему не всегда эквивалентно бизнес-риску.",
domainCard ? "Domain purity guardrail может исключить cross-domain записи на batch-слое." : "Domain purity guardrail не применялся."
],
errors: []
};
}
@@ -78,12 +78,33 @@ function countRuleHits(text: string, rule: QuestionTypeRule): number {
return hits;
}
function hasProofIntent(text: string): boolean {
return /(?:\bprove\b|\bguess\b|доказан|доказано|доказуем|гипотез|догад|связан\s+ли|зач[её]л(?:ся|ось)\s+ли)/iu.test(text);
}
function hasExplicitChainSplitIntent(text: string): boolean {
return /(?:какие(?:\s+\S+){0,4}\s+цепочк[аи]|which\s+chains?|complete\s+vs\s+incomplete|что\s+закрыто.*что\s+нет)/iu.test(
text
);
}
function hasGroundingIntent(text: string): boolean {
return /(?:на\s+ч(?:е|ё)м[^?!.]{0,40}основан|чем\s+подтвержда|какие\s+основани|what\s+evidence|grounded\s+on|based\s+on)/iu.test(
text
);
}
export function resolveQuestionType(input: string): QuestionTypeClass {
const text = String(input ?? "").trim();
if (!text) {
return "unknown";
}
// Guard against collapsing proof-intent questions into chain classification.
if (hasProofIntent(text) && !hasExplicitChainSplitIntent(text) && !hasGroundingIntent(text)) {
return "prove_or_guess";
}
let bestType: QuestionTypeClass = "unknown";
let bestHits = 0;
let bestPriority = Number.POSITIVE_INFINITY;