Этап 4 / Волна 11: объектный трейс по бизнес-якорям, доменное заземление, cleanup утечки дебага

This commit is contained in:
2026-03-28 13:12:09 +03:00
parent 553a5c407a
commit 8b84f5e989
34 changed files with 5517 additions and 173 deletions
@@ -9,6 +9,8 @@
import type { RouteHintSummary } from "../types/normalizer";
import type { AnswerStructureV11, EvidenceConfidence, EvidenceItem, EvidenceLimitationReasonCode } from "../types/stage1Contracts";
import type { ProblemUnit, ProblemUnitSummary, ProblemUnitType } from "../types/stage2ProblemUnits";
import type { QuestionTypeClass } from "./questionTypeResolver";
import type { CompanyAnchorSet } from "./companyAnchorResolver";
type ProblemAnswerMode = "stage1_policy_v11" | "stage2_problem_centric_v1" | "stage3_lifecycle_aware_v1";
@@ -20,6 +22,8 @@ interface ComposeAnswerInput {
coverageReport: RequirementCoverageReport;
groundingCheck: AnswerGroundingCheck;
focusDomainHint?: string | null;
questionTypeHint?: QuestionTypeClass | null;
companyAnchors?: CompanyAnchorSet | null;
enableAnswerPolicyV11?: boolean;
enableProblemCentricAnswerV1?: boolean;
enableLifecycleAnswerV1?: boolean;
@@ -47,6 +51,123 @@ function uniqueStrings(values: string[], limit = 6): string[] {
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))).slice(0, limit);
}
interface CompanyAnchorUsage {
present: string[];
used: string[];
unused: string[];
}
interface AnswerRenderContext {
questionType: QuestionTypeClass;
focusDomain: P0NarrativeDomain;
anchors: CompanyAnchorUsage;
}
function withUniquePush(target: string[], value: string): void {
const normalized = String(value ?? "").trim();
if (!normalized) {
return;
}
if (!target.includes(normalized)) {
target.push(normalized);
}
}
function normalizeAnchorForMatch(value: string): string {
return String(value ?? "")
.toLowerCase()
.replace(/[^\p{L}\p{N}.:/-]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function collectCompanyAnchorTokens(anchors: CompanyAnchorSet | null | undefined): string[] {
if (!anchors) {
return [];
}
const tokens: string[] = [];
for (const item of anchors.contract_numbers ?? []) withUniquePush(tokens, item);
for (const item of anchors.document_numbers ?? []) withUniquePush(tokens, item);
for (const item of anchors.dates ?? []) withUniquePush(tokens, item);
for (const item of anchors.amounts ?? []) withUniquePush(tokens, item);
for (const item of anchors.accounts ?? []) withUniquePush(tokens, `\u0441\u0447\u0435\u0442 ${item}`);
for (const item of anchors.accounts ?? []) withUniquePush(tokens, item);
for (const item of anchors.periods ?? []) withUniquePush(tokens, item);
for (const item of anchors.document_types ?? []) withUniquePush(tokens, item);
for (const item of anchors.all ?? []) withUniquePush(tokens, item);
return uniqueStrings(tokens, 48);
}
function collectRetrievalCorpus(results: UnifiedRetrievalResult[]): string {
const chunks: string[] = [];
for (const result of results) {
chunks.push(JSON.stringify(result.summary ?? {}));
for (const item of result.items.slice(0, 10)) {
chunks.push(JSON.stringify(item));
}
for (const evidence of result.evidence.slice(0, 16)) {
chunks.push(JSON.stringify(evidence));
}
chunks.push(...result.why_included.slice(0, 16));
chunks.push(...result.selection_reason.slice(0, 16));
chunks.push(...result.business_interpretation.slice(0, 16));
}
return chunks.join(" ").toLowerCase();
}
function isAnchorMatchedInCorpus(anchor: string, corpus: string): boolean {
const normalized = normalizeAnchorForMatch(anchor);
if (!normalized) {
return false;
}
if (normalized.length < 3) {
return false;
}
if (corpus.includes(normalized)) {
return true;
}
const withoutPrefix = normalized
.replace(/^(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|document|account|period|doc_type)\s*[:№#]?\s*/iu, "")
.trim();
if (withoutPrefix.length >= 3 && corpus.includes(withoutPrefix)) {
return true;
}
if (/^\d+(?:[.,]\d{2})?$/.test(withoutPrefix)) {
const normalizedAmount = withoutPrefix.replace(",", ".");
return corpus.includes(withoutPrefix) || corpus.includes(normalizedAmount);
}
return false;
}
function evaluateCompanyAnchorUsage(
anchors: CompanyAnchorSet | null | undefined,
retrievalResults: UnifiedRetrievalResult[]
): CompanyAnchorUsage {
const present = collectCompanyAnchorTokens(anchors);
if (present.length === 0) {
return {
present: [],
used: [],
unused: []
};
}
const corpus = normalizeAnchorForMatch(collectRetrievalCorpus(retrievalResults));
const used: string[] = [];
const unused: string[] = [];
for (const anchor of present) {
if (isAnchorMatchedInCorpus(anchor, corpus)) {
withUniquePush(used, anchor);
} else {
withUniquePush(unused, anchor);
}
}
return {
present: uniqueStrings(present, 24),
used: uniqueStrings(used, 12),
unused: uniqueStrings(unused, 12)
};
}
const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
const LONG_HEX_PATTERN = /\b[0-9a-f]{24,}\b/gi;
const RAW_REF_BLOB_PATTERN = /\bevidence_source_ref_v1\|[^\s,;]+/gi;
@@ -1129,6 +1250,12 @@ function isProblemUnitAlignedWithNarrativeDomain(unit: ProblemUnit, domain: P0Na
}
if (domain === "vat_document_register_book") {
const foreignVatDomain = ["period_close", "deferred_expense", "fixed_asset", "bank_settlement", "customer_settlement"].includes(
String(unit.lifecycle_domain ?? "")
);
if (foreignVatDomain && !hasControlledCrossDomainHandoff(unit)) {
return false;
}
if (unit.lifecycle_domain === "vat_flow") {
return true;
}
@@ -1139,6 +1266,12 @@ function isProblemUnitAlignedWithNarrativeDomain(unit: ProblemUnit, domain: P0Na
}
if (domain === "month_close_costs_20_44") {
const foreignMonthCloseDomain = ["vat_flow", "bank_settlement", "customer_settlement", "fixed_asset"].includes(
String(unit.lifecycle_domain ?? "")
);
if (foreignMonthCloseDomain && !hasControlledCrossDomainHandoff(unit)) {
return false;
}
if (
unit.lifecycle_domain === "period_close" ||
unit.lifecycle_domain === "deferred_expense" ||
@@ -1775,12 +1908,178 @@ function mapDefectTokenToNarrative(value: string): string | null {
return null;
}
const KNOWN_ACCOUNT_PREFIXES = new Set<string>([
"01",
"02",
"07",
"08",
"10",
"13",
"19",
"20",
"21",
"23",
"25",
"26",
"28",
"29",
"41",
"43",
"44",
"45",
"50",
"51",
"52",
"55",
"57",
"58",
"60",
"62",
"66",
"67",
"68",
"69",
"70",
"71",
"73",
"76",
"90",
"91",
"94",
"96",
"97"
]);
function collectDateLikeSpansForNarrative(text: string): Array<{ start: number; end: number }> {
const spans: Array<{ start: number; end: number }> = [];
const patterns = [
/\b20\d{2}[./-](?:0[1-9]|1[0-2])(?:[./-](?:0[1-9]|[12]\d|3[01]))?\b/g,
/\b(?:0?[1-9]|[12]\d|3[01])[./-](?:0?[1-9]|1[0-2])[./-](?:\d{2}|\d{4})\b/g,
/\b(?:0?[1-9]|[12]\d|3[01])\s+(?:января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)\b/giu
];
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 collectAmountLikeSpansForNarrative(text: string): Array<{ start: number; end: number }> {
const spans: Array<{ start: number; end: number }> = [];
const pattern = /\b\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?\b/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 intersectsNarrativeSpan(
start: number,
end: number,
spans: Array<{ start: number; end: number }>
): boolean {
return spans.some((span) => start < span.end && end > span.start);
}
function hasAccountContextMarker(text: string, start: number, end: number): boolean {
const left = text.slice(Math.max(0, start - 24), start);
const right = text.slice(end, Math.min(text.length, end + 24));
return /(?:счет|сч\.?|account|schet|по\s+60|по\s+62|по\s+19|по\s+68|по\s+20|по\s+25|по\s+26|по\s+44|расчет|ндс|закрыти|рбп|амортиз|settlement|vat|close)/iu.test(
`${left} ${right}`
);
}
function toKnownAccountToken(value: string): string | null {
const token = String(value ?? "").trim();
const prefix = token.match(/^(\d{2})/)?.[1];
if (!prefix || !KNOWN_ACCOUNT_PREFIXES.has(prefix)) {
return null;
}
return token;
}
function extractAccountNumbers(values: string[]): string[] {
const numbers = values.flatMap((value) => {
const matches = String(value ?? "").match(/\b\d{2}(?:\.\d{1,2})?\b/g);
return matches ?? [];
});
return uniqueStrings(numbers, 12);
const tokens: string[] = [];
for (const value of values) {
const raw = String(value ?? "");
const matches = raw.match(/\b\d{2}(?:\.\d{1,2})?\b/g) ?? [];
for (const match of matches) {
const account = toKnownAccountToken(match);
if (account) {
tokens.push(account);
}
}
}
return uniqueStrings(tokens, 16);
}
function extractAccountNumbersFromNarrativeText(value: string): string[] {
const text = String(value ?? "").toLowerCase();
if (!text.trim()) {
return [];
}
const result: string[] = [];
const dateSpans = collectDateLikeSpansForNarrative(text);
const amountSpans = collectAmountLikeSpansForNarrative(text);
const blockedSpans = [...dateSpans, ...amountSpans];
const contextualPattern =
/(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b)\s*(?:№|#|:)?\s*([0-9./,\sиand]{2,96})/giu;
let contextualMatch: RegExpExecArray | null = null;
while ((contextualMatch = contextualPattern.exec(text)) !== null) {
const chunk = String(contextualMatch[1] ?? "");
const chunkTokens = chunk.match(/\b\d{2}(?:\.\d{1,2})?\b/g) ?? [];
for (const token of chunkTokens) {
const account = toKnownAccountToken(token);
if (account) {
result.push(account);
}
}
}
const accountPairPattern = /\b(\d{2}(?:\.\d{1,2})?)\s*\/\s*(\d{2}(?:\.\d{1,2})?)\b/g;
let pairMatch: RegExpExecArray | null = null;
while ((pairMatch = accountPairPattern.exec(text)) !== null) {
const left = toKnownAccountToken(String(pairMatch[1] ?? ""));
const right = toKnownAccountToken(String(pairMatch[2] ?? ""));
if (left) {
result.push(left);
}
if (right) {
result.push(right);
}
}
const explicitPattern = /\b\d{2}(?:\.\d{1,2})?\b/g;
let explicitMatch: RegExpExecArray | null = null;
while ((explicitMatch = explicitPattern.exec(text)) !== null) {
const token = String(explicitMatch[0] ?? "");
const account = toKnownAccountToken(token);
if (!account) {
continue;
}
const start = explicitMatch.index;
const end = start + token.length;
if (intersectsNarrativeSpan(start, end, blockedSpans)) {
continue;
}
if (!hasAccountContextMarker(text, start, end)) {
continue;
}
result.push(account);
}
return uniqueStrings(result, 16);
}
function inferP0NarrativeDomain(units: ProblemUnit[]): P0NarrativeDomain {
@@ -1914,8 +2213,8 @@ function collectSemanticProfileScopes(results: UnifiedRetrievalResult[]): { acco
};
}
interface SettlementEvidenceGrounding {
has_settlement_primary: boolean;
interface P0DomainEvidenceGrounding {
has_primary: boolean;
has_foreign_primary: boolean;
foreign_primary_domains: string[];
blocked: boolean;
@@ -1935,10 +2234,28 @@ function isSettlementDomainToken(value: string): boolean {
return /(?:bank_settlement|customer_settlement|settlements?|supplier_payments|suppliers?|customers?)/i.test(String(value ?? ""));
}
function isVatDomainToken(value: string): boolean {
return /(?:vat_flow|vat|nds|taxes?|purchase_book|sales_book|invoice|book_entry|register)/i.test(String(value ?? ""));
}
function isMonthCloseDomainToken(value: string): boolean {
return /(?:period_close|month_close|close_operation|cost_close|cost_allocation|deferred_expense)/i.test(String(value ?? ""));
}
function isForeignToSettlementDomainToken(value: string): boolean {
return /(?:vat_flow|vat|deferred_expense|period_close|fixed_asset|fixed_assets|taxes?)/i.test(String(value ?? ""));
}
function isForeignToVatDomainToken(value: string): boolean {
return /(?:bank_settlement|customer_settlement|settlements?|period_close|deferred_expense|fixed_asset|fixed_assets|month_close)/i.test(
String(value ?? "")
);
}
function isForeignToMonthCloseDomainToken(value: string): boolean {
return /(?:bank_settlement|customer_settlement|settlements?|vat_flow|vat|fixed_asset|fixed_assets)/i.test(String(value ?? ""));
}
function collectResultAccounts(result: UnifiedRetrievalResult): string[] {
const accounts: string[] = [];
const semanticProfile = summaryValue(result, "semantic_profile");
@@ -1985,46 +2302,111 @@ function isSubstantiveResult(result: UnifiedRetrievalResult): boolean {
return result.items.length > 0 || result.evidence.length > 0;
}
function evaluateSettlementEvidenceGrounding(results: UnifiedRetrievalResult[]): SettlementEvidenceGrounding {
const substantive = results.filter((item) => isSubstantiveResult(item));
if (substantive.length === 0) {
function evaluateP0DomainEvidenceGrounding(
results: UnifiedRetrievalResult[],
focusDomain: P0NarrativeDomain
): P0DomainEvidenceGrounding {
if (!focusDomain) {
return {
has_settlement_primary: false,
has_primary: false,
has_foreign_primary: false,
foreign_primary_domains: [],
blocked: false
};
}
const classify = (result: UnifiedRetrievalResult): { settlement: boolean; foreignDomains: string[] } => {
const substantive = results.filter((item) => isSubstantiveResult(item));
if (substantive.length === 0) {
return {
has_primary: false,
has_foreign_primary: false,
foreign_primary_domains: [],
blocked: false
};
}
const classify = (result: UnifiedRetrievalResult): { inDomain: boolean; foreignDomains: string[] } => {
const accounts = collectResultAccounts(result);
const domains = collectResultDomains(result);
const relations = collectResultRelations(result);
const settlement =
accounts.some((item) => isSettlementAccountToken(item) || /^(?:51|76)(?:\.|$)/.test(item)) ||
domains.some((item) => isSettlementDomainToken(item)) ||
relations.some((item) => /payment_to_settlement|statement_to_document|contract_to_documents/.test(item));
const foreignDomains = domains.filter((item) => isForeignToSettlementDomainToken(item));
let inDomain = false;
let foreignDomains: string[] = [];
if (focusDomain === "settlements_60_62") {
inDomain =
accounts.some((item) => isSettlementAccountToken(item) || /^(?:51|76)(?:\.|$)/.test(item)) ||
domains.some((item) => isSettlementDomainToken(item)) ||
relations.some((item) => /payment_to_settlement|statement_to_document|contract_to_documents|linked_to_settlement|settlement_closed/.test(item));
foreignDomains = domains.filter((item) => isForeignToSettlementDomainToken(item));
} else if (focusDomain === "vat_document_register_book") {
inDomain =
accounts.some((item) => isVatAccountToken(item)) ||
domains.some((item) => isVatDomainToken(item)) ||
relations.some((item) =>
/invoice_to_vat|source_doc_present|invoice_linked|book_entry_generated|deduction_posted|register_to_book|vat_/i.test(item)
);
foreignDomains = domains.filter((item) => isForeignToVatDomainToken(item));
} else if (focusDomain === "month_close_costs_20_44") {
inDomain =
accounts.some((item) => isCloseCostsAccountToken(item)) ||
domains.some((item) => isMonthCloseDomainToken(item)) ||
relations.some((item) =>
/costs_accumulated|allocation_rules_resolved|close_operation_runs|residuals_zero|close_operation|period_close|allocation|writeoff/i.test(
item
)
);
foreignDomains = domains.filter((item) => isForeignToMonthCloseDomainToken(item));
}
return {
settlement,
inDomain,
foreignDomains: uniqueStrings(foreignDomains, 8)
};
};
const top = substantive[0];
const topClass = classify(top);
const hasAnySettlement = substantive.some((item) => classify(item).settlement);
const hasForeignPrimary = topClass.foreignDomains.length > 0 && !topClass.settlement;
const blocked = hasForeignPrimary && !hasAnySettlement && !hasControlledCrossDomainHandoffInResult(top);
const hasAnyPrimary = substantive.some((item) => classify(item).inDomain);
const hasForeignPrimary = topClass.foreignDomains.length > 0 && !topClass.inDomain;
const blocked = hasForeignPrimary && !hasAnyPrimary && !hasControlledCrossDomainHandoffInResult(top);
return {
has_settlement_primary: hasAnySettlement,
has_primary: hasAnyPrimary,
has_foreign_primary: hasForeignPrimary,
foreign_primary_domains: topClass.foreignDomains,
blocked
};
}
function hasStrongNarrativeDomainSignalInText(userMessage: string, domain: P0NarrativeDomain): boolean {
if (!domain) {
return false;
}
const text = String(userMessage ?? "").toLowerCase();
const accountTokens = extractAccountNumbersFromNarrativeText(text);
if (domain === "settlements_60_62") {
return (
accountTokens.some((item) => isSettlementAccountToken(item)) ||
/(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)
);
}
if (domain === "month_close_costs_20_44") {
return (
accountTokens.some((item) => isCloseCostsAccountToken(item)) ||
/(закрыти[ея]\s+месяц|закрытие\s+счетов|регламентн|косвенн|затрат|распределени|рбп|амортиз|финансовых\s+результат|month\s*close|period\s*close|close\s+operation)/i.test(
text
)
);
}
return false;
}
function inferP0FocusNarrativeDomain(
userMessage: string,
results: UnifiedRetrievalResult[],
@@ -2032,20 +2414,30 @@ function inferP0FocusNarrativeDomain(
focusDomainHint?: string | null
): P0NarrativeDomain {
const fromHint = p0NarrativeDomainFromHint(focusDomainHint);
const fromMessage = inferNarrativeDomainFromText(userMessage);
const strongFromMessage = Boolean(fromMessage && hasStrongNarrativeDomainSignalInText(userMessage, fromMessage));
const fromDomainGuard = inferP0NarrativeDomainFromDomainGuards(results);
if (fromHint && fromMessage && fromHint !== fromMessage) {
return strongFromMessage ? fromMessage : fromHint;
}
if (fromHint) {
return fromHint;
}
const fromDomainGuard = inferP0NarrativeDomainFromDomainGuards(results);
if (fromDomainGuard && fromMessage && fromDomainGuard !== fromMessage) {
return strongFromMessage ? fromMessage : fromDomainGuard;
}
if (fromDomainGuard) {
return fromDomainGuard;
}
const fromMessage = inferNarrativeDomainFromText(userMessage);
if (strongFromMessage) {
return fromMessage;
}
if (fromMessage) {
return fromMessage;
}
const semanticScopes = collectSemanticProfileScopes(results);
const messageAccounts = extractAccountNumbers([userMessage]);
const messageAccounts = extractAccountNumbersFromNarrativeText(userMessage);
const hasExplicitP0AccountSignal = [...messageAccounts, ...semanticScopes.accounts].some(
(item) => isSettlementAccountToken(item) || isVatAccountToken(item) || isCloseCostsAccountToken(item)
);
@@ -2224,14 +2616,22 @@ function buildDirectAnswer(input: {
mode: PolicyMode;
retrievalResults: UnifiedRetrievalResult[];
policySignals: PolicySignals;
focusDomain: P0NarrativeDomain;
}): string {
const topFact = humanizeFactForDirectAnswer(firstMeaningfulFact(input.retrievalResults));
const domainAnchor = domainNarrativeAnchor(input.focusDomain);
const topFactDomain = topFact ? inferNarrativeDomainFromText(topFact) : null;
const topFactAligned = Boolean(topFact) && (!input.focusDomain || topFactDomain === input.focusDomain);
const preferredFact = topFactAligned ? topFact : null;
if (input.mode === "focused_grounded") {
return topFact ?? "Проблема подтверждена на текущей опоре и готова к точечной проверке.";
return preferredFact ?? domainAnchor ?? "Проблема подтверждена на текущей опоре и готова к точечной проверке.";
}
if (input.mode === "broad_partial") {
if (topFact) {
return `${topFact.replace(/[.!?]+$/u, "")}; подтверждение пока частичное.`;
if (preferredFact) {
return `${preferredFact.replace(/[.!?]+$/u, "")}; подтверждение пока частичное.`;
}
if (domainAnchor) {
return `${domainAnchor.replace(/[.!?]+$/u, "")}; подтверждение пока частичное.`;
}
return "Есть признаки проблемы, но опора частичная и вывод ограничен.";
}
@@ -2338,11 +2738,23 @@ function buildProblemCentricAnswerStructure(input: {
6
);
const evidenceIds = uniqueStrings(input.evidenceItems.map((item) => item.evidence_id), 10);
const aggregateEvidenceConfidence = aggregateConfidence(input.retrievalResults, input.evidenceItems);
const hasCriticalEvidenceLimitation =
input.limitationReasonCodes.includes("weak_source_mapping") ||
input.limitationReasonCodes.includes("insufficient_detail");
const confidenceLimited =
input.mode !== "focused_grounded" ||
weakUnits ||
input.domainLockMiss ||
input.limitationReasonCodes.includes("missing_mechanism") ||
input.limitationReasonCodes.includes("heuristic_inference") ||
hasCriticalEvidenceLimitation ||
aggregateEvidenceConfidence === "low";
const mechanismStatus: AnswerStructureV11["mechanism_block"]["status"] =
unitMechanismNotes.length === 0
? "unresolved"
: weakUnits || input.limitationReasonCodes.includes("missing_mechanism")
: confidenceLimited
? "limited"
: "grounded";
@@ -2453,21 +2865,50 @@ function limitationReasonToUserText(code: EvidenceLimitationReasonCode): string
function inferNarrativeDomainFromText(value: string): P0NarrativeDomain {
const text = String(value ?? "").toLowerCase();
const accountTokens = extractAccountNumbers([text]);
const hasSettlementLexicalSignal = /(оплат|долг|аванс|взаимозач|зачет|зачёт|поставщ|покупат|не\s+сход)/i.test(text);
const accountTokens = extractAccountNumbersFromNarrativeText(text);
if (accountTokens.some((token) => isSettlementAccountToken(token)) || hasSettlementLexicalSignal) {
return "settlements_60_62";
let settlementScore = 0;
let vatScore = 0;
let monthCloseScore = 0;
if (accountTokens.some((token) => isSettlementAccountToken(token))) {
settlementScore += 3;
}
if (accountTokens.some((token) => isVatAccountToken(token)) || /(ндс|счет[-\s]?фактур|регистр|книг)/i.test(text)) {
return "vat_document_register_book";
if (accountTokens.some((token) => isVatAccountToken(token))) {
vatScore += 3;
}
if (accountTokens.some((token) => isCloseCostsAccountToken(token))) {
monthCloseScore += 3;
}
if (/(долг|аванс|взаимозач|зачет|зачёт|62\.01|62\.02|60\.01|60\.02|не\s+сход)/i.test(text)) {
settlementScore += 2;
}
if (/(ндс|vat|счет[-\s]?фактур|сч[её]т[-\s]?фактур|книг[аи]|регистр)/i.test(text)) {
vatScore += 3;
}
if (
accountTokens.some((token) => isCloseCostsAccountToken(token)) ||
/(закрыти[ея]\s+месяц|затрат|распределени|списан)/i.test(text)
/(закрыти[ея]\s+месяц|закрытие\s+счетов|регламентн|косвенн|затрат|распределени|рбп|амортиз|финансовых\s+результат|month\s*close|period\s*close|close\s+operation)/i.test(
text
)
) {
monthCloseScore += 3;
}
const maxScore = Math.max(settlementScore, vatScore, monthCloseScore);
if (maxScore <= 0) {
return null;
}
// Tie-break prioritizes explicit VAT and month-close lexical markers over broad settlement wording.
if (vatScore === maxScore) {
return "vat_document_register_book";
}
if (monthCloseScore === maxScore) {
return "month_close_costs_20_44";
}
if (settlementScore === maxScore) {
return "settlements_60_62";
}
return null;
}
@@ -2578,6 +3019,11 @@ function buildEvidenceSectionLines(structure: AnswerStructureV11): string[] {
const claimLinks = Array.isArray(structure.evidence_block.claim_evidence_links)
? structure.evidence_block.claim_evidence_links.length
: 0;
const reliabilityLimited =
structure.mechanism_block.status !== "grounded" ||
structure.uncertainty_block.limitations.length > 0 ||
structure.uncertainty_block.open_uncertainties.length > 0 ||
structure.evidence_block.coverage_note === "coverage_partial_or_limited";
const lines: string[] = [];
const coverageSplitLines = buildCoverageSplitLines(structure);
@@ -2593,7 +3039,7 @@ function buildEvidenceSectionLines(structure: AnswerStructureV11): string[] {
if (structure.evidence_block.coverage_note === "coverage_partial_or_limited") {
lines.push("Опора частичная: часть требований покрыта не полностью.");
} else if (evidenceCount > 0) {
lines.push("Опора достаточна для первичного вывода.");
lines.push(reliabilityLimited ? "Опора есть, но достаточна только для предварительного вывода." : "Опора достаточна для первичного вывода.");
}
if (lines.length === 0) {
@@ -2678,6 +3124,8 @@ function humanizeLimitationToken(value: string): string | null {
if (normalized === "missing_anchor:account") return "Счет или группа счетов не указаны.";
if (normalized === "missing_anchor:document_or_object") return "Не указан документ или объект для трассировки.";
if (normalized === "missing_anchor:counterparty") return "Не указан контрагент или договор.";
if (normalized === "primary_domain_evidence_not_confirmed")
return "Целевой механизм активного домена подтвержден частично; вывод ограничен.";
if (normalized === "settlement_primary_evidence_not_confirmed")
return "Опора по расчетному контуру не подтверждена: в приоритете были сигналы из смежных доменов.";
if (normalized.includes("snapshot")) return "Вывод сделан по snapshot и может не включать часть цепочки.";
@@ -2733,22 +3181,188 @@ function buildLimitationsSectionLines(structure: AnswerStructureV11): string[] {
return ["Существенных ограничений в текущем срезе не выявлено."];
}
function renderPolicyReply(structure: AnswerStructureV11): string {
function domainNameForQuestionType(domain: P0NarrativeDomain): string {
if (domain === "settlements_60_62") return "\u0440\u0430\u0441\u0447\u0435\u0442\u043d\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0443\u0440\u0430";
if (domain === "vat_document_register_book") return "\u0446\u0435\u043f\u043e\u0447\u043a\u0438 \u041d\u0414\u0421";
if (domain === "month_close_costs_20_44")
return "\u043a\u043e\u043d\u0442\u0443\u0440\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044f \u043c\u0435\u0441\u044f\u0446\u0430";
return "\u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0433\u043e \u0443\u0447\u0430\u0441\u0442\u043a\u0430";
}
function buildQuestionTypeShortLine(context: AnswerRenderContext): string | null {
const domainName = domainNameForQuestionType(context.focusDomain);
if (context.questionType === "where_break_is") {
return `\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u0442\u044c \u0440\u0430\u0437\u0440\u044b\u0432 \u0432\u043d\u0443\u0442\u0440\u0438 ${domainName}.`;
}
if (context.questionType === "prove_or_guess") {
return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u0440\u0430\u0437\u0432\u0435\u0441\u0442\u0438 \u0434\u043e\u043a\u0430\u0437\u0430\u043d\u043e \u0438 \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u0443.";
}
if (context.questionType === "what_is_it_grounded_on") {
return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u044b\u0432\u043e\u0434\u0430 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c.";
}
if (context.questionType === "which_chains_are_complete_vs_incomplete") {
return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u0446\u0435\u043f\u043e\u0447\u043a\u0438.";
}
if (context.questionType === "what_to_check_first") {
return "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043e\u0442\u0432\u0435\u0442\u0430: \u0434\u0430\u0442\u044c \u043f\u0435\u0440\u0432\u044b\u0439 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438.";
}
return null;
}
function buildQuestionTypeBrokenLine(context: AnswerRenderContext): string | null {
if (context.questionType !== "where_break_is") {
return null;
}
if (context.focusDomain === "settlements_60_62") {
return "\u0412\u0435\u0440\u043e\u044f\u0442\u043d\u044b\u0439 \u0443\u0437\u0435\u043b \u0440\u0430\u0437\u0440\u044b\u0432\u0430: \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0430 \u043e\u043f\u043b\u0430\u0442\u044b \u043a \u043e\u0431\u044a\u0435\u043a\u0442\u0443 \u0440\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0443 \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044f.";
}
if (context.focusDomain === "vat_document_register_book") {
return "\u0412\u0435\u0440\u043e\u044f\u0442\u043d\u044b\u0439 \u0443\u0437\u0435\u043b \u0440\u0430\u0437\u0440\u044b\u0432\u0430: \u0441\u0432\u044f\u0437\u043a\u0430 \u0438\u0441\u0445\u043e\u0434\u043d\u043e\u0433\u043e \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430, \u0441\u0447\u0435\u0442\u0430-\u0444\u0430\u043a\u0442\u0443\u0440\u044b \u0438 \u0437\u0430\u043f\u0438\u0441\u0438 \u043a\u043d\u0438\u0433\u0438.";
}
if (context.focusDomain === "month_close_costs_20_44") {
return "\u0412\u0435\u0440\u043e\u044f\u0442\u043d\u044b\u0439 \u0443\u0437\u0435\u043b \u0440\u0430\u0437\u0440\u044b\u0432\u0430: \u043f\u0435\u0440\u0435\u0445\u043e\u0434 \u043e\u0442 \u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0437\u0430\u0442\u0440\u0430\u0442 \u043a \u0440\u0430\u0441\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044e/\u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044e.";
}
return "\u0412\u0435\u0440\u043e\u044f\u0442\u043d\u044b\u0439 \u0443\u0437\u0435\u043b \u0440\u0430\u0437\u0440\u044b\u0432\u0430 \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d \u0447\u0430\u0441\u0442\u0438\u0447\u043d\u043e; \u043d\u0443\u0436\u043d\u0430 \u0442\u043e\u0447\u0435\u0447\u043d\u0430\u044f \u0441\u0432\u0435\u0440\u043a\u0430.";
}
function buildQuestionTypeWhyLine(context: AnswerRenderContext): string | null {
if (context.questionType === "prove_or_guess") {
return "\u0417\u0434\u0435\u0441\u044c \u0447\u0435\u0441\u0442\u043d\u043e \u0440\u0430\u0437\u0432\u043e\u0434\u0438\u0442\u0441\u044f \u0447\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043e \u0438 \u0447\u0442\u043e \u043f\u043e\u043a\u0430 \u043e\u0441\u0442\u0430\u0435\u0442\u0441\u044f \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u043e\u0439.";
}
if (context.questionType === "which_chains_are_complete_vs_incomplete") {
return "\u0426\u0435\u043f\u043e\u0447\u043a\u0438 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u044b \u043d\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u044b\u0435 \u043f\u043e \u0442\u0435\u043a\u0443\u0449\u0435\u0439 \u043e\u043f\u043e\u0440\u0435.";
}
return null;
}
function buildQuestionTypeEvidenceLine(context: AnswerRenderContext): string | null {
if (context.questionType === "what_is_it_grounded_on") {
return "\u0412 \u044d\u0442\u043e\u043c \u043e\u0442\u0432\u0435\u0442\u0435 \u0432 \u043f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u044b \u0438\u043c\u0435\u043d\u043d\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u044f \u0432\u044b\u0432\u043e\u0434\u0430.";
}
if (context.questionType === "prove_or_guess") {
return "\u0421\u0438\u043b\u0430 \u0432\u044b\u0432\u043e\u0434\u0430 \u043e\u0446\u0435\u043d\u0435\u043d\u0430 \u043f\u043e \u043f\u0440\u044f\u043c\u043e\u0439 \u043e\u043f\u043e\u0440\u0435, \u0430 \u043d\u0435 \u043f\u043e \u0434\u043e\u0433\u0430\u0434\u043a\u0430\u043c.";
}
return null;
}
function formatAnchorList(anchors: string[], prefix: string): string | null {
if (anchors.length === 0) {
return null;
}
return `${prefix}: ${anchors.join(", ")}.`;
}
function buildQuestionTypeCheckLine(context: AnswerRenderContext): string | null {
if (context.questionType === "what_to_check_first") {
return "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0441 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043f\u0443\u043d\u043a\u0442\u0430 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043c\u0430\u0440\u0448\u0440\u0443\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u0431\u0435\u0437 \u043f\u0435\u0440\u0435\u0441\u043a\u043e\u043a\u0430.";
}
return null;
}
function buildQuestionTypeLimitationLine(context: AnswerRenderContext): string | null {
if (context.questionType === "prove_or_guess") {
return "\u0414\u043b\u044f \u0444\u043e\u0440\u043c\u0430\u0442\u0430 \u00ab\u0434\u043e\u043a\u0430\u0437\u0430\u043d\u043e \u0438\u043b\u0438 \u0433\u0438\u043f\u043e\u0442\u0435\u0437\u0430\u00bb \u0432\u0441\u0435 \u043d\u0435\u0434\u043e\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0435 \u0447\u0430\u0441\u0442\u0438 \u043e\u0442\u0434\u0435\u043b\u0435\u043d\u044b \u0432 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f.";
}
if (context.questionType === "which_chains_are_complete_vs_incomplete") {
return "\u0414\u0435\u043b\u0435\u043d\u0438\u0435 \u043d\u0430 \u00abcomplete/incomplete\u00bb \u0437\u0430\u0432\u0438\u0441\u0438\u0442 \u043e\u0442 \u043f\u043e\u043b\u043d\u043e\u0442\u044b \u0446\u0435\u043f\u043e\u0447\u043a\u0438 \u0432 \u0442\u0435\u043a\u0443\u0449\u0435\u043c \u0441\u0440\u0435\u0437\u0435.";
}
return null;
}
function applyQuestionTypeAndAnchorPolicy(input: {
shortLine: string;
brokenLines: string[];
whyLines: string[];
evidenceLines: string[];
checkLines: string[];
limitationLines: string[];
context: AnswerRenderContext;
}): {
shortLine: string;
brokenLines: string[];
whyLines: string[];
evidenceLines: string[];
checkLines: string[];
limitationLines: string[];
} {
const nextShort = buildQuestionTypeShortLine(input.context) ?? input.shortLine;
const nextBroken = dedupeNarrativeLines(
[buildQuestionTypeBrokenLine(input.context), ...input.brokenLines].filter((item): item is string => Boolean(item)),
4
);
const nextWhy = dedupeNarrativeLines(
[buildQuestionTypeWhyLine(input.context), ...input.whyLines].filter((item): item is string => Boolean(item)),
4
);
const anchorUsedLine = formatAnchorList(
input.context.anchors.used,
"\u0412 \u043e\u043f\u043e\u0440\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u044b \u044f\u043a\u043e\u0440\u044f \u0432\u043e\u043f\u0440\u043e\u0441\u0430"
);
const anchorUnusedLine = formatAnchorList(
input.context.anchors.unused,
"\u042f\u043a\u043e\u0440\u044f \u0438\u0437 \u0432\u043e\u043f\u0440\u043e\u0441\u0430 \u0431\u0435\u0437 \u043f\u0440\u044f\u043c\u043e\u0433\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f"
);
const nextEvidence = dedupeNarrativeLines(
[buildQuestionTypeEvidenceLine(input.context), ...input.evidenceLines, anchorUsedLine].filter(
(item): item is string => Boolean(item)
),
7
);
const nextChecks = dedupeNarrativeLines(
[buildQuestionTypeCheckLine(input.context), ...input.checkLines].filter((item): item is string => Boolean(item)),
5
);
const nextLimitations = dedupeNarrativeLines(
[buildQuestionTypeLimitationLine(input.context), anchorUnusedLine, ...input.limitationLines].filter(
(item): item is string => Boolean(item)
),
6
);
return {
shortLine: ensureSentence(nextShort),
brokenLines: nextBroken,
whyLines: nextWhy,
evidenceLines: nextEvidence,
checkLines: nextChecks,
limitationLines: nextLimitations
};
}
function renderPolicyReply(structure: AnswerStructureV11, context?: AnswerRenderContext): string {
const shortLine = ensureSentence(buildShortSectionLine(structure));
const brokenLines = buildBrokenSectionLines(structure);
const whyLines = buildWhySectionLines(structure);
const evidenceLines = buildEvidenceSectionLines(structure);
const checkLines = buildChecksSectionLines(structure);
const limitationLines = buildLimitationsSectionLines(structure);
const enriched = context
? applyQuestionTypeAndAnchorPolicy({
shortLine,
brokenLines,
whyLines,
evidenceLines,
checkLines,
limitationLines,
context
})
: {
shortLine,
brokenLines,
whyLines,
evidenceLines,
checkLines,
limitationLines
};
return sanitizeUserFacingReply(
[
`Коротко: ${shortLine}`,
`Что сломано:\n${formatList(brokenLines)}`,
`Почему это похоже на проблему:\n${formatList(whyLines)}`,
`На чем это основано:\n${formatList(evidenceLines)}`,
`Что проверить первым:\n${formatList(checkLines)}`,
`Ограничения:\n${formatList(limitationLines)}`
`Коротко: ${enriched.shortLine}`,
`Что сломано:\n${formatList(enriched.brokenLines)}`,
`Почему это похоже на проблему:\n${formatList(enriched.whyLines)}`,
`На чем это основано:\n${formatList(enriched.evidenceLines)}`,
`Что проверить первым:\n${formatList(enriched.checkLines)}`,
`Ограничения:\n${formatList(enriched.limitationLines)}`
]
.filter(Boolean)
.join("\n\n")
@@ -2757,6 +3371,8 @@ function renderPolicyReply(structure: AnswerStructureV11): string {
function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutput {
const fallbackType = fallbackFromSummary(input.routeSummary);
const questionType: QuestionTypeClass = input.questionTypeHint ?? "unknown";
const anchorUsage = evaluateCompanyAnchorUsage(input.companyAnchors, input.retrievalResults);
const okResults = input.retrievalResults.filter((item) => item.status === "ok");
const partialResults = input.retrievalResults.filter((item) => item.status === "partial");
const emptyResults = input.retrievalResults.filter((item) => item.status === "empty");
@@ -2786,15 +3402,8 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
problemHeavyUnits,
input.focusDomainHint
);
const settlementGrounding = focusNarrativeDomain === "settlements_60_62"
? evaluateSettlementEvidenceGrounding(input.retrievalResults)
: {
has_settlement_primary: false,
has_foreign_primary: false,
foreign_primary_domains: [],
blocked: false
};
const settlementGroundingBlocked = focusNarrativeDomain === "settlements_60_62" && settlementGrounding.blocked;
const focusDomainGrounding = evaluateP0DomainEvidenceGrounding(input.retrievalResults, focusNarrativeDomain);
const focusDomainGroundingBlocked = Boolean(focusNarrativeDomain && focusDomainGrounding.blocked);
const rankedProblemUnits = rankProblemUnitsForAnswer(problemHeavyUnits, lifecycleAnswerEnabled, focusNarrativeDomain);
const domainAlignedProblemUnits =
focusNarrativeDomain === null
@@ -2805,7 +3414,7 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
rankedProblemUnits.length > 0 &&
domainAlignedProblemUnits.length === 0
);
const domainLockMiss = domainLockMissBase || settlementGroundingBlocked;
const domainLockMiss = domainLockMissBase || focusDomainGroundingBlocked;
const selectedProblemUnits = (
focusNarrativeDomain === null ? rankedProblemUnits : domainAlignedProblemUnits
).slice(0, 4);
@@ -2853,7 +3462,7 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
policySignals
});
const guardedDecision: PolicyDecision =
settlementGroundingBlocked &&
focusDomainGroundingBlocked &&
decision.mode !== "out_of_scope" &&
decision.mode !== "route_mismatch" &&
decision.mode !== "backend_error"
@@ -2870,7 +3479,9 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
policySignals.minimum_evidence_failed ||
limitationReasonCodes.includes("missing_mechanism") ||
limitationReasonCodes.includes("weak_source_mapping") ||
limitationReasonCodes.includes("insufficient_detail") ||
aggregateEvidenceConfidence === "low" ||
domainLockMiss ||
lowConfidenceConcentration;
const hardBlockedMode =
guardedDecision.mode === "out_of_scope" ||
@@ -2907,7 +3518,11 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
const lifecycleModeActive = lifecycleAnswerEnabled && selectedProblemUnits.length > 0 && hasLifecycleResolution(selectedProblemUnits);
return {
assistant_reply: renderPolicyReply(problemCentricStructure),
assistant_reply: renderPolicyReply(problemCentricStructure, {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage
}),
fallback_type: guardedDecision.fallback_type,
reply_type: guardedDecision.reply_type,
answer_structure_v11: problemCentricStructure,
@@ -2937,11 +3552,12 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
...limitationReasonCodes.map((code) => limitationReasonToText(code)),
...extractLimitations(input.retrievalResults),
...input.groundingCheck.reasons,
...(settlementGroundingBlocked
...(focusDomainGroundingBlocked
? ["Целевой механизм активного домена подтвержден частично; часть первичной опоры пришла из смежного контура."]
: []),
...(anchorUsage.unused.length > 0
? [
`Primary settlement evidence is not confirmed; foreign domains dominate: ${
settlementGrounding.foreign_primary_domains.join(", ") || "unknown"
}.`
`Часть якорей запроса пока не подтверждена в опоре: ${anchorUsage.unused.slice(0, 5).join(", ")}.`
]
: []),
...(policySignals.minimum_evidence_failed ? ["Minimum evidence gate failed for current scope."] : []),
@@ -2958,15 +3574,24 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
...(guardedDecision.mode === "clarification_required" && missingAnchors.account ? ["missing_anchor:account"] : []),
...(guardedDecision.mode === "clarification_required" && missingAnchors.documentOrObject ? ["missing_anchor:document_or_object"] : []),
...(guardedDecision.mode === "clarification_required" && missingAnchors.counterparty ? ["missing_anchor:counterparty"] : []),
...(settlementGroundingBlocked ? ["settlement_primary_evidence_not_confirmed"] : [])
...(focusDomainGroundingBlocked ? ["primary_domain_evidence_not_confirmed"] : [])
],
8
);
const confidenceLimited =
guardedDecision.mode !== "focused_grounded" ||
limitationReasonCodes.includes("missing_mechanism") ||
limitationReasonCodes.includes("heuristic_inference") ||
limitationReasonCodes.includes("weak_source_mapping") ||
limitationReasonCodes.includes("insufficient_detail") ||
aggregateEvidenceConfidence === "low" ||
focusDomainGroundingBlocked;
const mechanismStatus: AnswerStructureV11["mechanism_block"]["status"] =
mechanismNotes.length === 0
? "unresolved"
: limitationReasonCodes.includes("missing_mechanism") || limitationReasonCodes.includes("heuristic_inference")
: confidenceLimited
? "limited"
: "grounded";
@@ -2976,7 +3601,8 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
direct_answer: buildDirectAnswer({
mode: guardedDecision.mode,
retrievalResults: input.retrievalResults,
policySignals
policySignals,
focusDomain: focusNarrativeDomain
}),
mechanism_block: {
status: mechanismStatus,
@@ -3011,7 +3637,11 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
};
return {
assistant_reply: renderPolicyReply(answerStructure),
assistant_reply: renderPolicyReply(answerStructure, {
questionType,
focusDomain: focusNarrativeDomain,
anchors: anchorUsage
}),
fallback_type: guardedDecision.fallback_type,
reply_type: guardedDecision.reply_type,
answer_structure_v11: answerStructure,
@@ -108,6 +108,11 @@ const ENTITY_SPECIFIC_MARKERS =
/(?:\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|supplier|buyer|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|invoice|posting|register|guid|id[:=\s])/iu;
const EXACT_OBJECT_MARKERS =
/(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\s*(?:#|\u2116)|\bref\b|\bid\b|trx-\d+|inv-\d+)/iu;
const CONTRACT_MARKERS =
/(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440(?:\u0430|\u0443|\u043e\u043c|\u0435)?\s*(?:№|#|n)\s*[a-z\u0430-\u044f0-9./_-]+)/iu;
const DOCUMENT_NUMBER_MARKERS =
/(?:(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:-\u0444\u0430\u043a\u0442\u0443\u0440(?:\u0430|\u044b))?|\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446(?:\u0438\u044f|\u0438\u0438)|\u0430\u043a\u0442)\s*(?:№|#|n)\s*[a-z\u0430-\u044f0-9./_-]+)/iu;
const AMOUNT_MARKERS = /\b(?:\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?|\d+[.,]\d{2})\b/u;
const ROUTE_MIN_EVIDENCE_GATE: Record<string, RouteAwareEvidenceGate> = {
hybrid_store_plus_live: {
@@ -186,6 +191,9 @@ function detectBroadQuery(fragmentText: string, route: string): BroadQueryAssess
const hasEntityAnchor = ENTITY_SPECIFIC_MARKERS.test(lower);
const hasExactObjectAnchor = EXACT_OBJECT_MARKERS.test(lower);
const hasGuidAnchor = extractGuids(lower).length > 0;
const hasContractAnchor = CONTRACT_MARKERS.test(lower);
const hasDocumentNumberAnchor = DOCUMENT_NUMBER_MARKERS.test(lower);
const hasAmountAnchor = AMOUNT_MARKERS.test(lower);
let anchorScore = 0;
if (hasGuidAnchor) anchorScore += 3;
@@ -193,9 +201,16 @@ function detectBroadQuery(fragmentText: string, route: string): BroadQueryAssess
if (hasPeriodAnchor) anchorScore += 1;
if (hasEntityAnchor) anchorScore += 1;
if (hasExactObjectAnchor) anchorScore += 1;
if (hasContractAnchor) anchorScore += 2;
if (hasDocumentNumberAnchor) anchorScore += 2;
if (hasAmountAnchor) anchorScore += 1;
const weakAnchors = anchorScore <= 1;
const strongFocus = hasGuidAnchor || (hasAccountAnchor && hasPeriodAnchor) || anchorScore >= 4;
const strongFocus =
hasGuidAnchor ||
(hasAccountAnchor && hasPeriodAnchor) ||
(hasContractAnchor && hasDocumentNumberAnchor) ||
anchorScore >= 4;
const routeSensitiveBroad = route === "batch_refresh_then_store" || route === "hybrid_store_plus_live";
let broadnessLevel: BroadnessLevel = "low";
@@ -376,9 +391,7 @@ const P0_DOMAIN_CARDS: P0DomainCard[] = [
/\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,
/\b19\b/,
/\b68\b/
/\u0432\u044b\u0447\u0435\u0442/i
]
},
{
@@ -394,19 +407,20 @@ const P0_DOMAIN_CARDS: P0DomainCard[] = [
expected_edges: ["document_to_posting", "deferred_expense_to_writeoff", "contract_to_documents"],
forbidden_cross_domain_leakage: ["vat", "taxes", "bank", "settlements", "suppliers", "customers", "fixed_assets"],
symptom_markers: [
/\b20\b/,
/\b21\b/,
/\b23\b/,
/\b25\b/,
/\b26\b/,
/\b28\b/,
/\b29\b/,
/\b44\b/,
/period\s*close/i,
/\u0437\u0430\u043a\u0440\u044b\u0442/i,
/month\s*close/i,
/close\s+period/i,
/закрыт[а-яё]*\s+период/i,
/close\s+operation/i,
/allocation/i,
/закр/i,
/перио/i,
/\u0437\u0430\u043a\u0440\u044b\u0442(?:\u0438|\u0438\u0435|\u044b|)\s*(?:\u043c\u0435\u0441\u044f\u0446|\u0441\u0447\u0435\u0442)/i,
/\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442/i,
/\u0437\u0430\u0442\u0440\u0430\u0442/i,
/\u0440\u0430\u0441\u043f\u0440\u0435\u0434\u0435\u043b/i,
/\u043e\u0441\u0442\u0430\u0442\u043a/i
/\u0440\u0431\u043f/i,
/\u0430\u043c\u043e\u0440\u0442\u0438\u0437/i
]
}
];
@@ -1241,6 +1255,28 @@ function extractAccountScopeFromText(text: string): string[] {
}
}
const closePairPattern = /\b(?:20|21|23|25|26|28|29|44)\s*[-/]\s*(?:20|21|23|25|26|28|29|44)\b/g;
let closePairMatch: RegExpExecArray | null = null;
while ((closePairMatch = closePairPattern.exec(lower)) !== null) {
const pair = closePairMatch[0];
const pairAccounts = pair.match(/\b\d{2}(?:\.\d{1,2})?\b/g) ?? [];
for (const account of pairAccounts) {
pushAccount(account);
}
}
const suffixAnchorPattern = /\b(?:51|60|62|68|76|97)(?:\.\d{1,2})?(?:-(?:му|й|го|м|х))?\b/giu;
let suffixAnchorMatch: RegExpExecArray | null = null;
while ((suffixAnchorMatch = suffixAnchorPattern.exec(lower)) !== null) {
const token = suffixAnchorMatch[0];
const start = suffixAnchorMatch.index;
const end = start + token.length;
if (intersectsSpan(start, end, dateSpans)) {
continue;
}
pushAccount(token);
}
const explicitPattern = /\b\d{2}(?:\.\d{1,2})?\b/g;
let explicitMatch: RegExpExecArray | null = null;
const settlementLexicalAnchor = /(оплат|расчет|расч[её]т|аванс|долг|постав|покуп|settlement|payment|supplier|customer)/i.test(
@@ -1405,31 +1441,55 @@ function buildSemanticRetrievalProfile(fragmentText: string): SemanticRetrievalP
pushMany(entityTypes, ["counterparty", "contract", "document", "posting"]);
pushMany(relationPatterns, ["payment_to_settlement", "statement_to_document", "document_to_posting"]);
}
if (/постав|постав|supplier|vendor|60\b/i.test(lower)) {
const hasSettlementAccountScope = accountScope.some((item) => item === "51" || item === "60" || item === "62" || item === "76");
const hasVatAccountScope = accountScope.some((item) => item === "19" || item === "68");
const hasFixedAssetAccountScope = accountScope.some((item) => item === "01" || item === "02" || item === "08");
const hasDeferredExpenseAccountScope = accountScope.some((item) => item === "97");
const hasMonthCloseCostsAccountScope = accountScope.some((item) => CLOSE_COST_ACCOUNTS.includes(item));
const hasExplicitMonthCloseLexicalMarker =
/(?:закрыти[ея]\s+месяц|закрыт[а-яё]*\s+период|закрытие\s+счетов|регламентн|косвенн|затрат|распределени|рбп|амортиз|финансовых\s+результат|month\s*close|period\s*close|close\s+period|close\s+operation)/i.test(
lower
) ||
(/закр/i.test(lower) && /перио/i.test(lower));
if (/постав|постав|supplier|vendor/i.test(lower) || hasSettlementAccountScope) {
pushMany(domainScope, ["suppliers", "settlements"]);
pushMany(documentTypes, ["supplier_receipt", "settlement_document"]);
pushMany(entityTypes, ["counterparty", "contract", "document", "posting"]);
pushMany(relationPatterns, ["payment_to_settlement", "contract_to_documents"]);
}
if (/покупат|покупат|customer|buyer|62\b/i.test(lower)) {
if (/покупат|покупат|customer|buyer/i.test(lower) || hasSettlementAccountScope) {
pushMany(domainScope, ["customers", "settlements"]);
pushMany(documentTypes, ["sales_document", "settlement_document"]);
pushMany(entityTypes, ["counterparty", "contract", "document", "posting"]);
pushMany(relationPatterns, ["payment_to_settlement", "contract_to_documents"]);
}
if (/РЅРґСЃ|ндс|vat|РєРЅРёРіР° РїРѕРєСѓРїРѕРє|РєРЅРёРіР° продаж|счет.?фактур|книг[аи]\s+покуп|книг[аи]\s+продаж|сч[её]т.?фактур|19\b|68\b/i.test(lower)) {
if (
/РЅРґСЃ|ндс|vat|РєРЅРёРіР° РїРѕРєСѓРїРѕРє|РєРЅРёРіР° продаж|счет.?фактур|книг[аи]\s+покуп|книг[аи]\s+продаж|сч[её]т.?фактур/i.test(
lower
) ||
hasVatAccountScope
) {
pushMany(domainScope, ["vat", "taxes"]);
pushMany(documentTypes, ["invoice", "vat_document"]);
pushMany(entityTypes, ["document", "tax_entry", "posting"]);
pushMany(relationPatterns, ["invoice_to_vat", "document_to_posting"]);
}
if (/РѕСЃ|РѕСЃРЅРѕРІРЅ(ые|ых)\s+сред|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|основн(ые|ых|ым)?\s+средств|fixed asset|amort|амортиз|амортиз|01\b|02\b|08\b/i.test(lower)) {
if (
/РѕСЃ|РѕСЃРЅРѕРІРЅ(ые|ых)\s+сред|(?:^|[^a-zа-яё])ос(?:$|[^a-zа-яё])|основн(ые|ых|ым)?\s+средств|fixed asset|amort|амортиз|амортиз/i.test(
lower
) ||
hasFixedAssetAccountScope
) {
pushMany(domainScope, ["fixed_assets"]);
pushMany(documentTypes, ["fixed_asset_card", "fixed_asset_acceptance", "depreciation_document"]);
pushMany(entityTypes, ["fixed_asset", "document", "posting"]);
pushMany(relationPatterns, ["asset_card_to_depreciation", "document_to_posting"]);
}
if (/СЂР±Рї|расходы будущих периодов|рбп|расходы\s+будущих\s+периодов|deferred|writeoff|97\b/i.test(lower)) {
if (
/СЂР±Рї|расходы будущих периодов|рбп|расходы\s+будущих\s+периодов|deferred|writeoff/i.test(lower) ||
hasDeferredExpenseAccountScope
) {
pushMany(domainScope, ["deferred_expense", "period_close"]);
pushMany(documentTypes, ["deferred_expense_document", "period_close_document"]);
pushMany(entityTypes, ["document", "posting"]);
@@ -1452,7 +1512,7 @@ function buildSemanticRetrievalProfile(fragmentText: string): SemanticRetrievalP
pushMany(anomalyPatterns, ["repeated_anomaly"]);
pushMany(rankingBasis, ["repeatability"]);
}
if (/закрыт|закрытие|период|закрыт|закрытие|период|month close|period close|closure/i.test(lower)) {
if (hasExplicitMonthCloseLexicalMarker || hasMonthCloseCostsAccountScope || hasDeferredExpenseAccountScope) {
pushMany(domainScope, ["period_close"]);
pushMany(anomalyPatterns, ["closure_risk", "broken_lifecycle"]);
pushMany(documentTypes, ["period_close_document"]);
@@ -8,6 +8,8 @@ import * as assistantDataLayer_1 from "./assistantDataLayer";
import * as assistantSessionLogger_1 from "./assistantSessionLogger";
import * as investigationState_1 from "./investigationState";
import * as retrievalResultNormalizer_1 from "./retrievalResultNormalizer";
import * as questionTypeResolver_1 from "./questionTypeResolver";
import * as companyAnchorResolver_1 from "./companyAnchorResolver";
function retrievalSummaryForRoute(route) {
if (route === "store_canonical")
return "Canonical accounting data path selected.";
@@ -832,6 +834,26 @@ function extractFollowupAccountAnchorsLoose(text) {
}
return Array.from(new Set(anchors));
}
function inferP0DomainFromMessage(text) {
const lower = String(text ?? "").toLowerCase();
const accountTokens = extractAccountTokens(lower);
const hasVatAccount = accountTokens.some((token) => /^(?:19|68)(?:\.|$)/.test(token));
const hasSettlementAccount = accountTokens.some((token) => /^(?:51|60|62|76)(?:\.|$)/.test(token));
const hasMonthCloseAccount = accountTokens.some((token) => /^(?:97|2\d|3\d|4[0-4])(?:\.|$)/.test(token));
const vatLexical = /(?:ндс|vat|счет[\s-]?фактур|сч[её]т[\s-]?фактур|книг[аи]\s+(?:покуп|продаж)|налогов)/i.test(lower);
const settlementLexical = /(?:долг|аванс|зач[её]т|взаимозач|расч[её]т|оплат|платеж|платёж|постав|покупател)/i.test(lower);
const monthCloseLexical = /(?:закрыти[ея]\s+месяц|закрытие счетов|регламентн|косвенн|затрат|распределени|рбп|амортиз|финансовых результат)/i.test(lower);
if (hasVatAccount || vatLexical) {
return "vat_document_register_book";
}
if (monthCloseLexical || hasMonthCloseAccount) {
return "month_close_costs_20_44";
}
if (hasSettlementAccount || settlementLexical) {
return "settlements_60_62";
}
return null;
}
function hasStrongFollowupAnchors(userMessage, state) {
const explicitPeriod = extractNormalizedPeriodLiteral(userMessage);
if (explicitPeriod && state.focus.period && explicitPeriod !== state.focus.period) {
@@ -840,6 +862,14 @@ function hasStrongFollowupAnchors(userMessage, state) {
return true;
}
}
const inferredDomain = inferP0DomainFromMessage(userMessage);
const activeDomain = compactWhitespace(state.followup_context?.active_domain ?? state.focus.domain ?? "");
if (inferredDomain && activeDomain && inferredDomain !== activeDomain) {
const domainLooksLikeFollowupRefinement = hasFollowupMarker(userMessage) && hasReferentialPointer(userMessage);
if (!domainLooksLikeFollowupRefinement) {
return true;
}
}
const explicitAccounts = extractAccountTokens(userMessage);
const followupAccounts = explicitAccounts.length > 0 ? explicitAccounts : extractFollowupAccountAnchorsLoose(userMessage);
if (followupAccounts.length > 0) {
@@ -1155,6 +1185,8 @@ export class AssistantService {
const focusDomainHint = followupBinding.usage?.applied
? session.investigation_state?.followup_context?.active_domain ?? session.investigation_state?.focus.domain ?? null
: null;
const questionTypeClass = (0, questionTypeResolver_1.resolveQuestionType)(userMessage);
const companyAnchors = (0, companyAnchorResolver_1.resolveCompanyAnchors)(userMessage);
const composition = (0, answerComposer_1.composeAssistantAnswer)({
userMessage,
routeSummary: normalized.route_hint_summary,
@@ -1163,6 +1195,8 @@ export class AssistantService {
coverageReport: coverageEvaluation.coverage,
groundingCheck,
focusDomainHint,
questionTypeHint: questionTypeClass,
companyAnchors,
enableAnswerPolicyV11: config_1.FEATURE_ASSISTANT_ANSWER_POLICY_V11,
enableProblemCentricAnswerV1: config_1.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1,
enableLifecycleAnswerV1: config_1.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1
@@ -1213,6 +1247,8 @@ export class AssistantService {
retrieval_results: retrievalResults,
answer_grounding_check: groundingCheck,
dropped_intent_segments: extractDiscardedIntentSegments(normalized.normalized),
question_type_class: questionTypeClass,
company_anchors: companyAnchors,
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
problem_centric_answer_applied: composition.problem_centric_answer_applied ?? false,
problem_units_used_count: composition.problem_units_used_count ?? 0,
@@ -1276,6 +1312,8 @@ export class AssistantService {
route_subject_match: groundingCheck.route_subject_match,
clarification_target: coverageEvaluation.coverage.clarification_needed_for,
dropped_intent_segments: extractDiscardedIntentSegments(normalized.normalized),
question_type_class: questionTypeClass,
company_anchors: companyAnchors,
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
problem_centric_answer_applied: composition.problem_centric_answer_applied ?? false,
problem_units_used_count: composition.problem_units_used_count ?? 0,
@@ -0,0 +1,181 @@
export interface CompanyAnchorSet {
contract_numbers: string[];
document_numbers: string[];
dates: string[];
amounts: string[];
accounts: string[];
periods: string[];
document_types: string[];
all: string[];
}
const CONTRACT_PATTERN =
/(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440(?:\u0430|\u0443|ом|е)?\s*(?:№|#|n)?\s*([a-zа-я0-9./_-]+))/giu;
const DOCUMENT_NUMBER_PATTERN =
/(?:(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:-\u0444\u0430\u043a\u0442\u0443\u0440(?:а|ы))?|\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446(?:ия|ии)|\u0430\u043a\u0442)\s*(?:№|#|n)\s*([a-zа-я0-9./_-]+))/giu;
const DATE_PATTERN =
/\b(?:\d{1,2}[./]\d{1,2}[./]\d{2,4}|\d{1,2}\s+(?:\u044f\u043d\u0432\u0430\u0440\u044f|\u0444\u0435\u0432\u0440\u0430\u043b\u044f|\u043c\u0430\u0440\u0442\u0430|\u0430\u043f\u0440\u0435\u043b\u044f|\u043c\u0430\u044f|\u0438\u044e\u043d\u044f|\u0438\u044e\u043b\u044f|\u0430\u0432\u0433\u0443\u0441\u0442\u0430|\u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044f|\u043e\u043a\u0442\u044f\u0431\u0440\u044f|\u043d\u043e\u044f\u0431\u0440\u044f|\u0434\u0435\u043a\u0430\u0431\u0440\u044f))\b/giu;
const AMOUNT_PATTERN =
/\b(?:\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?|\d+[.,]\d{2})\b/gu;
const CONTEXTUAL_ACCOUNT_PATTERN =
/(?:\b(?:\u0441\u0447(?:\u0435|\u0451)\u0442(?:а|у|ом|ов)?|account|schet)\b\s*(?:№|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu;
const ACCOUNT_PAIR_PATTERN = /\b(\d{2}\.\d{2})\s*\/\s*(\d{2}\.\d{2})\b/gu;
const PERIOD_PATTERN =
/\b(?:20\d{2}(?:[-./](?:0?[1-9]|1[0-2]))?|(?:\u0438\u044e\u043b\u044c|\u0438\u044e\u043d\u044c|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c|\u043e\u043a\u0442\u044f\u0431\u0440\u044c|\u043d\u043e\u044f\u0431\u0440\u044c|\u0434\u0435\u043a\u0430\u0431\u0440\u044c|\u044f\u043d\u0432\u0430\u0440\u044c|\u0444\u0435\u0432\u0440\u0430\u043b\u044c|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b\u044c|\u043c\u0430\u0439)\s+20\d{2})\b/giu;
const DOCUMENT_TYPE_PATTERNS: Array<{ name: string; pattern: RegExp }> = [
{ name: "invoice", pattern: /\b(?:\u0441\u0447(?:\u0435|\u0451)\u0442-\u0444\u0430\u043a\u0442\u0443\u0440|invoice)\b/iu },
{ name: "realization", pattern: /\b(?:\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446|realization)\b/iu },
{ name: "payment", pattern: /\b(?:\u043e\u043f\u043b\u0430\u0442|payment|\u043f\u043b\u0430\u0442\u0435\u0436)\b/iu },
{ name: "receipt", pattern: /\b(?:\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d|receipt)\b/iu },
{ name: "close", pattern: /\b(?:\u0437\u0430\u043a\u0440\u044b\u0442\u0438|\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442)\b/iu },
{ name: "rbp_writeoff", pattern: /\b(?:\u0440\u0431\u043f|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435)\b/iu },
{ name: "amortization", pattern: /\b(?:\u0430\u043c\u043e\u0440\u0442\u0438\u0437|amortization)\b/iu }
];
const KNOWN_ACCOUNT_PREFIXES = new Set<string>([
"01",
"02",
"07",
"08",
"10",
"13",
"19",
"20",
"21",
"23",
"25",
"26",
"41",
"43",
"44",
"45",
"50",
"51",
"52",
"55",
"57",
"58",
"60",
"62",
"66",
"67",
"68",
"69",
"70",
"71",
"73",
"76",
"90",
"91",
"94",
"96",
"97"
]);
function uniqueStrings(values: string[], limit = 48): string[] {
return Array.from(new Set(values.map((item) => String(item ?? "").trim()).filter(Boolean))).slice(0, limit);
}
function normalizeAnchorToken(value: string): string {
return String(value ?? "")
.replace(/\s+/g, " ")
.trim();
}
function collectMatches(text: string, pattern: RegExp, useCaptures = true): string[] {
const values: string[] = [];
pattern.lastIndex = 0;
for (const match of text.matchAll(pattern)) {
if (!match) continue;
if (useCaptures && match.length > 1) {
for (let i = 1; i < match.length; i += 1) {
const token = normalizeAnchorToken(match[i] ?? "");
if (token) values.push(token);
}
continue;
}
const token = normalizeAnchorToken(match[0] ?? "");
if (token) values.push(token);
}
return uniqueStrings(values);
}
function isKnownAccount(value: string): boolean {
const token = String(value ?? "").trim();
const match = token.match(/^(\d{2})/);
if (!match) {
return false;
}
return KNOWN_ACCOUNT_PREFIXES.has(match[1]);
}
function collectAccountAnchors(text: string): string[] {
const tokens = new Set<string>();
for (const token of collectMatches(text, CONTEXTUAL_ACCOUNT_PATTERN, true)) {
if (isKnownAccount(token)) {
tokens.add(token);
}
}
ACCOUNT_PAIR_PATTERN.lastIndex = 0;
for (const match of text.matchAll(ACCOUNT_PAIR_PATTERN)) {
const left = normalizeAnchorToken(match[1] ?? "");
const right = normalizeAnchorToken(match[2] ?? "");
if (left && isKnownAccount(left)) {
tokens.add(left);
}
if (right && isKnownAccount(right)) {
tokens.add(right);
}
}
return Array.from(tokens).slice(0, 24);
}
function collectDocumentTypeAnchors(text: string): string[] {
return uniqueStrings(
DOCUMENT_TYPE_PATTERNS.filter((entry) => entry.pattern.test(text)).map((entry) => entry.name),
12
);
}
function flattenAnchors(input: Omit<CompanyAnchorSet, "all">): string[] {
return uniqueStrings(
[
...input.contract_numbers,
...input.document_numbers,
...input.dates,
...input.amounts,
...input.accounts.map((item) => `account:${item}`),
...input.periods.map((item) => `period:${item}`),
...input.document_types.map((item) => `doc_type:${item}`)
],
64
);
}
export function resolveCompanyAnchors(input: string): CompanyAnchorSet {
const text = String(input ?? "");
const contractNumbers = collectMatches(text, CONTRACT_PATTERN, true).map((item) => `\u0434\u043e\u0433\u043e\u0432\u043e\u0440 № ${item}`);
const documentNumbers = collectMatches(text, DOCUMENT_NUMBER_PATTERN, true).map((item) => `\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 № ${item}`);
const dates = collectMatches(text, DATE_PATTERN, false);
const amounts = collectMatches(text, AMOUNT_PATTERN, false);
const accounts = collectAccountAnchors(text);
const periods = collectMatches(text, PERIOD_PATTERN, false);
const documentTypes = collectDocumentTypeAnchors(text);
const resultBase: Omit<CompanyAnchorSet, "all"> = {
contract_numbers: uniqueStrings(contractNumbers, 12),
document_numbers: uniqueStrings(documentNumbers, 16),
dates: uniqueStrings(dates, 16),
amounts: uniqueStrings(amounts, 16),
accounts: uniqueStrings(accounts, 24),
periods: uniqueStrings(periods, 12),
document_types: documentTypes
};
return {
...resultBase,
all: flattenAnchors(resultBase)
};
}
@@ -627,8 +627,14 @@ function inferLifecycleDomain(input: LifecycleResolverInput): LifecycleDomain {
.join(" ")
.toLowerCase();
const hasExplicitVatHint = includesAny(unitTokens, [/domain_hint:vat_flow/]);
const hasExplicitDeferredHint = includesAny(unitTokens, [/domain_hint:deferred_expense/]);
const hasExplicitFixedAssetHint = includesAny(unitTokens, [/domain_hint:fixed_asset/]);
const hasExplicitPeriodCloseHint = includesAny(unitTokens, [/domain_hint:period_close/]);
const hasCustomerSettlementHint = includesAny(unitTokens, [/domain_hint:customer_settlement/]);
const hasBankSettlementHint = includesAny(unitTokens, [/domain_hint:bank_settlement/]);
const hasVatMarkers = includesAny(unitTokens, [
/domain_hint:vat_flow/,
/\binvoice_to_vat\b/,
/\bvat_chain_conflict\b/,
/(^|[^a-z0-9])nds([^a-z0-9]|$)/,
@@ -637,7 +643,6 @@ function inferLifecycleDomain(input: LifecycleResolverInput): LifecycleDomain {
/\baccount[_:\s-]?(19|68)\b/
]);
const hasDeferredMarkers = includesAny(unitTokens, [
/domain_hint:deferred_expense/,
/\bdeferred(?:_expense)?\b/,
/\bdeferred_expense_to_writeoff\b/,
/\bwriteoff\b/,
@@ -646,7 +651,6 @@ function inferLifecycleDomain(input: LifecycleResolverInput): LifecycleDomain {
/\baccount[_:\s-]?97\b/
]);
const hasFixedAssetMarkers = includesAny(unitTokens, [
/domain_hint:fixed_asset/,
/\bfixed[_\s-]?asset(?:s)?\b/,
/\basset_card_to_depreciation\b/,
/\bdepreciation(?:_active)?\b/,
@@ -655,7 +659,6 @@ function inferLifecycleDomain(input: LifecycleResolverInput): LifecycleDomain {
/\baccount[_:\s-]?(01|02|08)\b/
]);
const hasPeriodCloseMarkers = includesAny(unitTokens, [
/domain_hint:period_close/,
/\bperiod[_\s-]?close\b/,
/\bperiod_close_risk\b/,
/\bclose[_\s-]?risk\b/,
@@ -665,6 +668,25 @@ function inferLifecycleDomain(input: LifecycleResolverInput): LifecycleDomain {
/\bperiod_risk\b/
]);
if (hasExplicitDeferredHint) {
return "deferred_expense";
}
if (hasExplicitFixedAssetHint) {
return "fixed_asset";
}
if (hasExplicitVatHint) {
return "vat_flow";
}
if (hasExplicitPeriodCloseHint) {
return "period_close";
}
if (hasCustomerSettlementHint) {
return "customer_settlement";
}
if (hasBankSettlementHint) {
return "bank_settlement";
}
if (hasDeferredMarkers) {
return "deferred_expense";
}
@@ -106,14 +106,67 @@ function stringArrayFromPayload(item: EvidenceItem, key: string): string[] {
return stringArrayFromUnknown(item.payload[key]);
}
function domainHintsFromSummary(summary: Record<string, unknown>): string[] {
const hints: string[] = [];
const purityGuard = toObject(summary.domain_purity_guard);
const domainCardId = String(purityGuard?.domain_card_id ?? "").trim();
if (domainCardId === "settlements_60_62") {
return ["bank_settlement", "customer_settlement"];
}
if (domainCardId === "vat_document_register_book") {
return ["vat_flow"];
}
if (domainCardId === "month_close_costs_20_44") {
return ["period_close"];
}
const semanticProfile = toObject(summary.semantic_profile);
const domainScope = stringArrayFromUnknown(semanticProfile?.domain_scope);
for (const domain of domainScope) {
const normalized = domain.toLowerCase();
if (
normalized === "bank" ||
normalized === "settlements" ||
normalized === "suppliers" ||
normalized === "supplier_payments" ||
normalized === "other_settlements"
) {
hints.push("bank_settlement");
continue;
}
if (normalized === "customers") {
hints.push("customer_settlement");
continue;
}
if (normalized === "vat" || normalized === "taxes") {
hints.push("vat_flow");
continue;
}
if (normalized === "period_close") {
hints.push("period_close");
continue;
}
if (normalized === "deferred_expense") {
hints.push("deferred_expense");
continue;
}
if (normalized === "fixed_assets") {
hints.push("fixed_asset");
}
}
return uniqueStrings(hints);
}
function extractSemanticProfile(summary: Record<string, unknown>): {
relation_patterns: string[];
anomaly_patterns: string[];
} {
const semanticProfile = toObject(summary.semantic_profile);
const domainHints = domainHintsFromSummary(summary).map((item) => `domain_hint:${item}`);
return {
relation_patterns: stringArrayFromUnknown(semanticProfile?.relation_patterns),
anomaly_patterns: stringArrayFromUnknown(semanticProfile?.anomaly_patterns)
relation_patterns: uniqueStrings([...stringArrayFromUnknown(semanticProfile?.relation_patterns), ...domainHints]),
anomaly_patterns: uniqueStrings([...stringArrayFromUnknown(semanticProfile?.anomaly_patterns), ...domainHints])
};
}
@@ -0,0 +1,60 @@
export type QuestionTypeClass =
| "why_breaks"
| "where_break_is"
| "prove_or_guess"
| "what_is_it_grounded_on"
| "which_chains_are_complete_vs_incomplete"
| "what_to_check_first"
| "unknown";
const QUESTION_TYPE_RULES: Array<{ type: QuestionTypeClass; pattern: RegExp }> = [
{
type: "what_to_check_first",
pattern:
/(?:\bwhat\s+to\s+check\s+first\b|\bfirst\s+check\b|\bcheck\s+first\b|\u0441\s+\u0447\u0435\u0433\u043e\s+\u043d\u0430\u0447\u0430\u0442\u044c\s+\u043f\u0440\u043e\u0432\u0435\u0440\u043a|\u0447\u0442\u043e\s+\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\s+\u043f\u0435\u0440\u0432)/iu
},
{
type: "what_is_it_grounded_on",
pattern:
/(?:\bwhat\s+is\s+it\s+grounded\s+on\b|\bgrounded\s+on\b|\bbased\s+on\b|\bwhat\s+evidence\b|\u043d\u0430\s+\u0447(?:\u0435|\u0451)\u043c\s+\u044d\u0442\u043e\s+\u043e\u0441\u043d\u043e\u0432\u0430\u043d|\u0447\u0435\u043c\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434)/iu
},
{
type: "prove_or_guess",
pattern:
/(?:\bprove\b|\bguess\b|\bprove\s+or\s+guess\b|\bis\s+it\s+proven\b|\u044d\u0442\u043e\s+\u0434\u043e\u043a\u0430\u0437\u0430\u043d|\u0438\u043b\u0438\s+\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0433\u0438\u043f\u043e\u0442\u0435\u0437|\u0434\u043e\u043a\u0430\u0437\u0430\u043d|\u0434\u043e\u0433\u0430\u0434|\u0435\u0441\u0442\u044c\s+\u043b\u0438|\u043c\u043e\u0436\u0435\u0442\s+\u043b\u0438|\u044d\u0442\u043e\s+\u0443\u0436\u0435.*\u0438\u043b\u0438)/iu
},
{
type: "which_chains_are_complete_vs_incomplete",
pattern:
/(?:\bcomplete(?:d)?\b.*\bincomplete\b|\bwhich\s+chains?\b|\bcomplete\s+vs\s+incomplete\b|\u043a\u0430\u043a\u0438\u0435\s+\u0446\u0435\u043f\u043e\u0447\u043a[аи]\s+.*\u0437\u0430\u0432\u0435\u0440\u0448|\u0447\u0442\u043e\s+\u0437\u0430\u043a\u0440\u044b\u0442\u043e.*\u0447\u0442\u043e\s+\u043d\u0435\u0442)/iu
},
{
type: "where_break_is",
pattern:
/(?:\bwhere\s+is\s+the\s+break\b|\bwhere\s+exactly\b|\blocate\b|\u0433\u0434\u0435\s+\u0438\u043c\u0435\u043d\u043d\u043e|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0432\s+\u043a\u0430\u043a\u043e\u043c\s+\u043c\u0435\u0441\u0442\u0435)/iu
},
{
type: "why_breaks",
pattern:
/(?:\bwhy\b|\bwhy\s+does\s+it\s+break\b|\u043f\u043e\u0447\u0435\u043c\u0443|\u0432\s+\u0447(?:\u0435|\u0451)\u043c\s+\u043f\u0440\u0438\u0447\u0438\u043d\u0430|\u0438\u0437-\u0437\u0430\s+\u0447\u0435\u0433\u043e)/iu
}
];
export function resolveQuestionType(input: string): QuestionTypeClass {
const text = String(input ?? "").trim();
if (!text) {
return "unknown";
}
for (const rule of QUESTION_TYPE_RULES) {
if (rule.pattern.test(text)) {
return rule.type;
}
}
if (/[??]/u.test(text)) {
return "why_breaks";
}
return "unknown";
}