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

This commit is contained in:
2026-03-29 08:33:54 +03:00
parent 60d8b96a14
commit d461cedf35
19 changed files with 2389 additions and 66 deletions
@@ -1196,7 +1196,9 @@ function collectDateLikeSpans(text: string): Array<{ start: number; end: number
const spans: Array<{ start: number; end: number }> = [];
const patterns = [
/\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b/g,
/\b20\d{2}[./-](?:0[1-9]|1[0-2])[./-](?:0[1-9]|[12]\d|3[01])\b/g
/\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])\s+(?:январ[ьяе]|феврал[ьяе]|март[ае]?|апрел[ьяе]|ма[йея]|июн[ьяе]?|июл[ьяе]?|август[ае]?|сентябр[ьяе]?|октябр[ьяе]?|ноябр[ьяе]?|декабр[ьяе]?|january|february|march|april|may|june|july|august|september|october|november|december)(?:\s+20\d{2})?\b/giu,
/\b(?:январ[ьяе]|феврал[ьяе]|март[ае]?|апрел[ьяе]|ма[йея]|июн[ьяе]?|июл[ьяе]?|август[ае]?|сентябр[ьяе]?|октябр[ьяе]?|ноябр[ьяе]?|декабр[ьяе]?|january|february|march|april|may|june|july|august|september|october|november|december)\s+20\d{2}\b/giu
];
for (const pattern of patterns) {
let match: RegExpExecArray | null = null;
@@ -114,15 +114,81 @@ function accountPrefix(value: string): string | null {
return match ? match[1] : null;
}
function extractAccountsFromText(text: string): string[] {
function collectDateLikeSpans(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+(?:январ[ьяе]|феврал[ьяе]|март[ае]?|апрел[ьяе]|ма[йея]|июн[ьяе]?|июл[ьяе]?|август[ае]?|сентябр[ьяе]?|октябр[ьяе]?|ноябр[ьяе]?|декабр[ьяе]?|january|february|march|april|may|june|july|august|september|october|november|december)(?:\s+20\d{2})?\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 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);
}
interface AccountExtractionAudit {
resolved_account_anchors: string[];
raw_numeric_tokens: string[];
classified_numeric_tokens: Array<{
token: string;
classification: "account_token" | "date_token" | "amount_token" | "percent_token" | "other_numeric";
}>;
rejected_as_non_accounts: string[];
}
function extractAccountsFromTextDetailed(text: string, options?: { forceAccountContext?: boolean }): AccountExtractionAudit {
const lower = String(text ?? "").toLowerCase();
const accounts = new Set<string>();
const dateSpans = collectDateLikeSpans(lower);
const amountSpans = collectAmountLikeSpans(lower);
const percentSpans = collectPercentLikeSpans(lower);
const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans];
const contextualPattern =
/(?:\b(?:СЃС(?:Рµ|С)С(?:Р°|Сѓ|РѕРј|РѕРІ)?|account|schet)\b\s*(?:в|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu;
/(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b\s*(?:|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu;
let contextualMatch: RegExpExecArray | null = null;
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
const token = String(contextualMatch[1] ?? "").trim();
if (token) {
const prefix = token.match(/^(\d{2})/)?.[1] ?? null;
if (token && prefix && KNOWN_ACCOUNT_PREFIXES.has(prefix)) {
accounts.add(token);
}
}
@@ -131,35 +197,139 @@ function extractAccountsFromText(text: string): string[] {
while ((pairMatch = pairPattern.exec(lower)) !== null) {
const left = String(pairMatch[1] ?? "").trim();
const right = String(pairMatch[2] ?? "").trim();
if (left) accounts.add(left);
if (right) accounts.add(right);
const leftPrefix = left.match(/^(\d{2})/)?.[1] ?? null;
const rightPrefix = right.match(/^(\d{2})/)?.[1] ?? null;
if (left && leftPrefix && KNOWN_ACCOUNT_PREFIXES.has(leftPrefix)) accounts.add(left);
if (right && rightPrefix && KNOWN_ACCOUNT_PREFIXES.has(rightPrefix)) accounts.add(right);
}
const genericAccountPattern = /\b(\d{2}(?:\.\d{2})?)\b/g;
let genericMatch: RegExpExecArray | null = null;
const classifiedNumericTokens: Array<{
token: string;
classification: "account_token" | "date_token" | "amount_token" | "percent_token" | "other_numeric";
}> = [];
const rejectedAsNonAccounts = new Set<string>();
while ((genericMatch = genericAccountPattern.exec(lower)) !== null) {
const token = String(genericMatch[1] ?? "").trim();
const start = genericMatch.index;
const end = start + token.length;
const prefix = token.match(/^(\d{2})/)?.[1] ?? null;
if (options?.forceAccountContext === true && prefix && KNOWN_ACCOUNT_PREFIXES.has(prefix)) {
accounts.add(token);
classifiedNumericTokens.push({
token,
classification: "account_token"
});
continue;
}
if (intersectsSpan(start, end, dateSpans)) {
classifiedNumericTokens.push({
token,
classification: "date_token"
});
rejectedAsNonAccounts.add(token);
continue;
}
if (intersectsSpan(start, end, amountSpans)) {
classifiedNumericTokens.push({
token,
classification: "amount_token"
});
rejectedAsNonAccounts.add(token);
continue;
}
if (intersectsSpan(start, end, percentSpans)) {
classifiedNumericTokens.push({
token,
classification: "percent_token"
});
rejectedAsNonAccounts.add(token);
continue;
}
if (!prefix || !KNOWN_ACCOUNT_PREFIXES.has(prefix)) {
classifiedNumericTokens.push({
token,
classification: "other_numeric"
});
rejectedAsNonAccounts.add(token);
continue;
}
accounts.add(token);
classifiedNumericTokens.push({
token,
classification: "account_token"
});
}
return Array.from(accounts);
const rawNumericTokens = uniqueStrings((lower.match(/\b\d{1,4}(?:[.,]\d{1,4})?\b/g) ?? []).map((item) => String(item)));
for (const token of accounts) {
if (!classifiedNumericTokens.some((item) => item.token === token && item.classification === "account_token")) {
classifiedNumericTokens.push({
token,
classification: "account_token"
});
}
}
// Numeric tokens hidden behind blocked spans still need explicit audit markers.
const blockedMatchPattern = /\b\d{2}(?:\.\d{2})?\b/g;
let blockedMatch: RegExpExecArray | null = null;
while ((blockedMatch = blockedMatchPattern.exec(lower)) !== null) {
const token = String(blockedMatch[0] ?? "").trim();
const start = blockedMatch.index;
const end = start + token.length;
if (!intersectsSpan(start, end, blockedSpans)) {
continue;
}
if (classifiedNumericTokens.some((item) => item.token === token)) {
continue;
}
const classification = intersectsSpan(start, end, dateSpans)
? "date_token"
: intersectsSpan(start, end, amountSpans)
? "amount_token"
: "percent_token";
classifiedNumericTokens.push({
token,
classification
});
rejectedAsNonAccounts.add(token);
}
return {
resolved_account_anchors: Array.from(accounts),
raw_numeric_tokens: rawNumericTokens,
classified_numeric_tokens: classifiedNumericTokens,
rejected_as_non_accounts: Array.from(rejectedAsNonAccounts)
};
}
function extractAccountsFromUnknown(value: unknown): string[] {
function extractAccountsFromText(text: string): string[] {
return extractAccountsFromTextDetailed(text).resolved_account_anchors;
}
function extractAccountsFromUnknown(value: unknown, pathKey = ""): string[] {
if (Array.isArray(value)) {
return uniqueStrings(value.flatMap((item) => extractAccountsFromUnknown(item)));
return uniqueStrings(value.flatMap((item) => extractAccountsFromUnknown(item, pathKey)));
}
if (value && typeof value === "object") {
return uniqueStrings(Object.values(value as Record<string, unknown>).flatMap((item) => extractAccountsFromUnknown(item)));
return uniqueStrings(
Object.entries(value as Record<string, unknown>).flatMap(([key, item]) =>
extractAccountsFromUnknown(item, `${pathKey}.${String(key).toLowerCase()}`)
)
);
}
if (typeof value !== "string" && typeof value !== "number") {
return [];
}
const text = String(value);
const matches = text.match(/\b\d{2}(?:\.\d{2})?\b/g) ?? [];
return uniqueStrings(matches);
const contextPath = String(pathKey ?? "").toLowerCase();
if (
contextPath.length > 0 &&
!/(?:account|счет|сч|debit|credit|дт|кт|konto|subkonto|analytics|context)/iu.test(contextPath)
) {
return [];
}
const forceAccountContext = /(?:account|счет|сч|debit|credit|дт|кт|konto|subkonto|analytics|context)/iu.test(contextPath);
return extractAccountsFromTextDetailed(String(value), {
forceAccountContext
}).resolved_account_anchors;
}
function normalizeTwoDigits(value: string): string {
@@ -399,11 +569,16 @@ function resolveJulyAnchor(rawText: string): TemporalAnchorResolution {
}
export type TemporalGuardOutcome = "passed" | "failed_out_of_snapshot_window" | "ambiguous_limited";
export type TemporalAlignmentStatus = "aligned" | "corrected" | "conflicting";
export interface TemporalGuardAudit {
raw_time_anchor: string | null;
raw_time_scope: string | null;
resolved_time_anchor: string | null;
resolved_primary_period: TemporalWindow | null;
temporal_alignment_status: TemporalAlignmentStatus;
temporal_resolution_source: string;
temporal_guard_basis: "resolved_primary_period" | "raw_time_scope_unlocked" | "none";
temporal_guard_applied: boolean;
temporal_guard_outcome: TemporalGuardOutcome;
primary_period_window: TemporalWindow | null;
@@ -414,6 +589,37 @@ export interface TemporalGuardAudit {
reason_codes: string[];
}
function inferPrimaryWindowFromAnchor(anchor: string | null): TemporalWindow | null {
const raw = String(anchor ?? "").trim();
if (/^\d{4}-\d{2}$/.test(raw)) {
return {
from: `${raw}-01`,
to: `${raw}-31`,
granularity: "month"
};
}
const normalized = normalizeEvidenceDate(raw);
if (!normalized) {
return null;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return {
from: normalized,
to: normalized,
granularity: "day"
};
}
const month = normalized.slice(0, 7);
if (!/^\d{4}-\d{2}$/.test(month)) {
return null;
}
return {
from: `${month}-01`,
to: `${month}-31`,
granularity: "month"
};
}
export function resolveTemporalGuard(input: {
userMessage: string;
normalized: NormalizedPayload | null | undefined;
@@ -424,10 +630,15 @@ export function resolveTemporalGuard(input: {
const normalizedAnchor = normalizedAnchorFromFragments(input.normalized);
const reasonCodes: string[] = [];
if (!julyAnchor.applyGuard) {
const resolvedWindow = inferPrimaryWindowFromAnchor(normalizedAnchor.value);
return {
raw_time_anchor: julyAnchor.raw,
raw_time_scope: normalizedAnchor.value,
resolved_time_anchor: normalizedAnchor.value,
resolved_primary_period: resolvedWindow,
temporal_alignment_status: normalizedAnchor.value ? "aligned" : "conflicting",
temporal_resolution_source: normalizedAnchor.source,
temporal_guard_basis: normalizedAnchor.value ? "raw_time_scope_unlocked" : "none",
temporal_guard_applied: false,
temporal_guard_outcome: "passed",
primary_period_window: null,
@@ -435,23 +646,30 @@ export function resolveTemporalGuard(input: {
controlled_temporal_expansion_enabled: false,
context_expansion_reasons_allowed: ["prehistory", "carryover", "post_period_closure", "long_running_contract_context"],
normalized_anchor_drift_detected: false,
reason_codes: []
reason_codes: normalizedAnchor.value ? [] : ["missing_resolved_primary_period"]
};
}
let outcome: TemporalGuardOutcome = "passed";
let normalizedAnchorDriftDetected = false;
let temporalAlignmentStatus: TemporalAlignmentStatus = "aligned";
if (normalizedAnchor.value && julyAnchor.window && !isPeriodWithinWindow(normalizedAnchor.value, julyAnchor.window)) {
normalizedAnchorDriftDetected = true;
temporalAlignmentStatus = "corrected";
reasonCodes.push("normalized_anchor_out_of_primary_window_overridden");
} else if (!normalizedAnchor.value && !julyAnchor.resolved) {
outcome = "ambiguous_limited";
temporalAlignmentStatus = "conflicting";
reasonCodes.push("missing_time_anchor_under_snapshot_lock");
}
const allowedContextWindow = buildAllowedContextWindow(julyAnchor.window);
return {
raw_time_anchor: julyAnchor.raw,
raw_time_scope: normalizedAnchor.value,
resolved_time_anchor: julyAnchor.resolved ?? normalizedAnchor.value,
resolved_primary_period: julyAnchor.window,
temporal_alignment_status: temporalAlignmentStatus,
temporal_resolution_source: julyAnchor.source,
temporal_guard_basis: julyAnchor.window ? "resolved_primary_period" : "none",
temporal_guard_applied: true,
temporal_guard_outcome: outcome,
primary_period_window: julyAnchor.window,
@@ -501,6 +719,13 @@ export interface DomainPolarityGuardAudit {
supplier_score: number;
customer_score: number;
account_scope: string[];
raw_numeric_tokens: string[];
classified_numeric_tokens: Array<{
token: string;
classification: "account_token" | "date_token" | "amount_token" | "percent_token" | "other_numeric";
}>;
rejected_as_non_accounts: string[];
resolved_account_anchors: string[];
rejected_problem_units: number;
rejected_evidence: number;
critical_contradiction: boolean;
@@ -513,7 +738,8 @@ export function resolveDomainPolarityGuard(input: {
focusDomainHint?: string | null;
}): DomainPolarityGuardAudit {
const lower = String(input.userMessage ?? "").toLowerCase();
const accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...extractAccountsFromText(lower)]);
const accountExtraction = extractAccountsFromTextDetailed(lower);
const accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]);
const prefixes = new Set(accounts.map((item) => accountPrefix(item)).filter((item): item is string => Boolean(item)));
const settlementSignal =
input.focusDomainHint === "settlements_60_62" ||
@@ -530,6 +756,10 @@ export function resolveDomainPolarityGuard(input: {
supplier_score: 0,
customer_score: 0,
account_scope: accounts,
raw_numeric_tokens: accountExtraction.raw_numeric_tokens,
classified_numeric_tokens: accountExtraction.classified_numeric_tokens,
rejected_as_non_accounts: accountExtraction.rejected_as_non_accounts,
resolved_account_anchors: accounts,
rejected_problem_units: 0,
rejected_evidence: 0,
critical_contradiction: false,
@@ -559,6 +789,10 @@ export function resolveDomainPolarityGuard(input: {
supplier_score: supplierScore,
customer_score: customerScore,
account_scope: accounts,
raw_numeric_tokens: accountExtraction.raw_numeric_tokens,
classified_numeric_tokens: accountExtraction.classified_numeric_tokens,
rejected_as_non_accounts: accountExtraction.rejected_as_non_accounts,
resolved_account_anchors: accounts,
rejected_problem_units: 0,
rejected_evidence: 0,
critical_contradiction: unresolved,
@@ -1146,6 +1380,8 @@ export function applyEvidenceAdmissibilityGate(input: {
export interface GroundedAnswerEligibilityAudit {
eligible: boolean;
temporal_passed: boolean;
eligibility_time_basis: "resolved_primary_period" | "raw_time_scope_unlocked" | "none";
business_scope_passed: boolean;
polarity_passed: boolean;
claim_anchors_passed: boolean;
claim_anchor_resolution_rate: number | null;
@@ -1163,8 +1399,15 @@ export function evaluateGroundedAnswerEligibility(input: {
evidence: EvidenceAdmissibilityAudit;
claimAnchors?: ClaimBoundAnchorAudit | null;
targetedEvidenceHitRate?: number | null;
businessScopeResolved?: string[] | null;
}): GroundedAnswerEligibilityAudit {
const temporalPassed = input.temporal.temporal_guard_outcome === "passed";
const eligibilityTimeBasis = input.temporal.temporal_guard_basis;
const scopeValues = Array.isArray(input.businessScopeResolved) ? input.businessScopeResolved : [];
const hasCompanyScope = scopeValues.includes("company_specific_accounting");
const hasOnlyGenericScope =
scopeValues.length > 0 && scopeValues.every((item) => String(item ?? "").trim() === "generic_accounting");
const businessScopePassed = scopeValues.length === 0 ? true : hasCompanyScope || !hasOnlyGenericScope;
const polarityPassed =
!input.polarity.applied || input.polarity.outcome === "passed" || input.polarity.outcome === "not_applicable";
const claimAnchorResolutionRate = input.claimAnchors ? Number(input.claimAnchors.claim_anchor_resolution_rate ?? 0) : null;
@@ -1182,6 +1425,7 @@ export function evaluateGroundedAnswerEligibility(input: {
: Number(input.targetedEvidenceHitRate) > 0;
const eligible =
temporalPassed &&
businessScopePassed &&
polarityPassed &&
claimAnchorsPassed &&
admissibleEvidenceCount > 0 &&
@@ -1194,6 +1438,9 @@ export function evaluateGroundedAnswerEligibility(input: {
if (!polarityPassed) {
reasonCodes.push(`polarity_guard_${input.polarity.outcome}`);
}
if (!businessScopePassed) {
reasonCodes.push("business_scope_generic_unresolved");
}
if (!claimAnchorsPassed) {
reasonCodes.push("claim_anchor_coverage_insufficient");
}
@@ -1209,6 +1456,8 @@ export function evaluateGroundedAnswerEligibility(input: {
return {
eligible,
temporal_passed: temporalPassed,
eligibility_time_basis: eligibilityTimeBasis,
business_scope_passed: businessScopePassed,
polarity_passed: polarityPassed,
claim_anchors_passed: claimAnchorsPassed,
claim_anchor_resolution_rate: claimAnchorResolutionRate,
@@ -1229,7 +1478,10 @@ export function applyEligibilityToGroundingCheck<T extends { status: string; rea
return groundingCheck;
}
const status =
eligibility.admissible_evidence_count <= 0 || !eligibility.temporal_passed || !eligibility.claim_anchors_passed
eligibility.admissible_evidence_count <= 0 ||
!eligibility.temporal_passed ||
!eligibility.claim_anchors_passed ||
!eligibility.business_scope_passed
? "no_grounded_answer"
: "partial";
const reasonMap: Record<string, string> = {
@@ -1237,6 +1489,7 @@ export function applyEligibilityToGroundingCheck<T extends { status: string; rea
critical_domain_or_account_contradiction: "Есть критическое противоречие по domain/account scope.",
temporal_guard_failed_out_of_snapshot_window: "Temporal anchor вышел за окно company snapshot (июль 2020).",
temporal_guard_ambiguous_limited: "Temporal anchor не разрешен надежно в пределах company snapshot.",
business_scope_generic_unresolved: "Business scope остался generic и не подтвержден как company-specific для доказательного ответа.",
polarity_guard_limited_unresolved_polarity: "Не удалось надежно определить supplier/customer polarity.",
polarity_guard_blocked_conflict: "Обнаружен конфликт supplier/customer polarity в retrieval-контуре.",
claim_anchor_coverage_insufficient: "Недостаточно покрытия required anchors для claim-bound grounding.",
@@ -82,6 +82,83 @@ function extractExecutionState(normalized) {
};
});
}
function collectBusinessScopesFromNormalized(normalized) {
const scopes = [];
for (const item of extractFragments(normalized)) {
if (!item || typeof item !== "object") {
continue;
}
const scope = String(item.business_scope ?? "").trim();
if (scope) {
scopes.push(scope);
}
}
return Array.from(new Set(scopes));
}
function hasJuly2020SnapshotSignal(userMessage, companyAnchors) {
const lower = String(userMessage ?? "").toLowerCase();
if (/(?:\b2020[-/.]0?7\b|\bиюл[ьяе]?\b(?:\s+20\d{2})?|\bjuly\b(?:\s+20\d{2})?)/i.test(lower)) {
return true;
}
const periods = Array.isArray(companyAnchors?.periods) ? companyAnchors.periods : [];
const dates = Array.isArray(companyAnchors?.dates) ? companyAnchors.dates : [];
return [...periods, ...dates].some((item) => /2020[-/.]0?7|июл|july/i.test(String(item ?? "").toLowerCase()));
}
function hasP0DomainSignal(userMessage, companyAnchors) {
if (inferP0DomainFromMessage(userMessage)) {
return true;
}
const accounts = Array.isArray(companyAnchors?.accounts) ? companyAnchors.accounts : [];
if (accounts.some((item) => /^(?:01|02|08|19|20|21|23|25|26|28|29|44|51|60|62|68|76|97)(?:\.|$)/.test(String(item ?? "").trim()))) {
return true;
}
return /(?:ндс|vat|рбп|deferred|амортиз|supplier|customer|settlement|month\s*close|закрыти[ея]\s+месяц|поставщ|покупат)/i.test(String(userMessage ?? "").toLowerCase());
}
function resolveBusinessScopeAlignment(input) {
const rawScopes = collectBusinessScopesFromNormalized(input.normalized);
const needsCompanyGrounding = hasJuly2020SnapshotSignal(input.userMessage, input.companyAnchors) && hasP0DomainSignal(input.userMessage, input.companyAnchors);
const reasons = [];
if (needsCompanyGrounding) {
reasons.push("july_2020_snapshot_p0_signal");
}
if (!input.routeSummary || input.routeSummary.mode !== "deterministic_v2" || !needsCompanyGrounding) {
return {
business_scope_raw: rawScopes,
business_scope_resolved: rawScopes,
company_grounding_applied: false,
scope_resolution_reason: reasons,
route_summary_resolved: input.routeSummary
};
}
let changed = false;
const decisions = input.routeSummary.decisions.map((decision) => {
const scopeValue = String(decision.business_scope ?? "").trim();
if (scopeValue !== "generic_accounting" && scopeValue !== "unclear") {
return decision;
}
changed = true;
return {
...decision,
business_scope: "company_specific_accounting"
};
});
const resolvedSummary = changed
? {
...input.routeSummary,
decisions
}
: input.routeSummary;
const resolvedScopes = changed
? Array.from(new Set(decisions.map((decision) => String(decision.business_scope ?? "").trim()).filter(Boolean)))
: rawScopes;
return {
business_scope_raw: rawScopes,
business_scope_resolved: resolvedScopes,
company_grounding_applied: changed,
scope_resolution_reason: changed ? [...reasons, "generic_or_unclear_to_company_specific_override"] : reasons,
route_summary_resolved: resolvedSummary
};
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -141,8 +218,9 @@ function extractDiscardedIntentSegments(normalized) {
function collectDateSpans(text) {
const spans = [];
const datePatterns = [
/\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
/\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+(?:январ[ьяе]|феврал[ьяе]|март[ае]?|апрел[ьяе]|ма[йея]|июн[ьяе]?|июл[ьяе]?|август[ае]?|сентябр[ьяе]?|октябр[ьяе]?|ноябр[ьяе]?|декабр[ьяе]?|january|february|march|april|may|june|july|august|september|october|november|december)(?:\s+20\d{2})?\b/giu
];
for (const datePattern of datePatterns) {
let match = null;
@@ -155,6 +233,32 @@ function collectDateSpans(text) {
}
return spans;
}
function collectAmountSpans(text) {
const spans = [];
const amountPatterns = [/\b\d{1,3}(?:[ \u00A0]\d{3})+(?:[.,]\d{2})?\b/g, /\b\d+[.,]\d{2}\b/g];
for (const amountPattern of amountPatterns) {
let match = null;
while ((match = amountPattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
}
return spans;
}
function collectPercentSpans(text) {
const spans = [];
const percentPattern = /\b\d{1,3}(?:[.,]\d+)?\s*%/g;
let match = null;
while ((match = percentPattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
});
}
return spans;
}
function intersectsAnySpan(start, end, spans) {
return spans.some((span) => start < span.end && end > span.start);
}
@@ -230,7 +334,7 @@ function extractAccountTokens(text) {
if (explicitAccounts.size > 0) {
return Array.from(explicitAccounts);
}
const spans = collectDateSpans(lower);
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower)];
const hasAccountingLexeme = /(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расчет|аванс|долг|settlement|payment)/iu.test(lower);
if (!hasAccountingLexeme) {
return [];
@@ -846,7 +950,7 @@ function extractNormalizedPeriodLiteral(text) {
}
function extractFollowupAccountAnchorsLoose(text) {
const lower = String(text ?? "").toLowerCase();
const spans = collectDateSpans(lower);
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower)];
const anchors = [];
const followupAccountPattern = /\b(?:01|02|08|19|20|21|23|25|26|28|29|44|51|60|62|68|76|97)(?:\.\d{2})?\b/g;
let match = null;
@@ -1192,6 +1296,13 @@ export class AssistantService {
};
const normalized = await this.normalizerService.normalize(normalizePayload);
const companyAnchors = (0, companyAnchorResolver_1.resolveCompanyAnchors)(userMessage);
const businessScopeResolution = resolveBusinessScopeAlignment({
userMessage,
companyAnchors,
normalized: normalized.normalized,
routeSummary: normalized.route_hint_summary
});
const resolvedRouteSummary = businessScopeResolution.route_summary_resolved;
const inferredDomainByMessage = inferP0DomainFromMessage(userMessage);
const focusDomainForGuards = inferredDomainByMessage === "settlements_60_62" ||
inferredDomainByMessage === "vat_document_register_book" ||
@@ -1214,8 +1325,8 @@ export class AssistantService {
focusDomainHint: focusDomainForGuards,
primaryPeriod: temporalGuard.primary_period_window
});
const requirementExtraction = extractRequirements(normalized.route_hint_summary, normalized.normalized, userMessage);
let executionPlan = toExecutionPlan(normalized.route_hint_summary, normalized.normalized, userMessage, requirementExtraction.byFragment);
const requirementExtraction = extractRequirements(resolvedRouteSummary, normalized.normalized, userMessage);
let executionPlan = toExecutionPlan(resolvedRouteSummary, normalized.normalized, userMessage, requirementExtraction.byFragment);
executionPlan = (0, assistantRuntimeGuards_1.applyTemporalHintToExecutionPlan)(executionPlan, temporalGuard);
executionPlan = (0, assistantRuntimeGuards_1.applyPolarityHintToExecutionPlan)(executionPlan, domainPolarityGuardInitial);
const retrievalCalls = [];
@@ -1305,7 +1416,8 @@ export class AssistantService {
polarity: polarityGuardResult.audit,
evidence: evidenceGateResult.audit,
claimAnchors: claimAnchorAudit,
targetedEvidenceHitRate: targetedEvidenceResult.audit.targeted_evidence_hit_rate
targetedEvidenceHitRate: targetedEvidenceResult.audit.targeted_evidence_hit_rate,
businessScopeResolved: businessScopeResolution.business_scope_resolved
});
const groundingCheck = (0, assistantRuntimeGuards_1.applyEligibilityToGroundingCheck)(groundingCheckBase, groundedAnswerEligibilityGuard);
const focusDomainHint = followupBinding.usage?.applied
@@ -1317,7 +1429,7 @@ export class AssistantService {
const normalizationPeriodExplicit = hasExplicitPeriodAnchorFromNormalized(normalized.normalized) || hasPeriodInCompanyAnchors;
const composition = (0, answerComposer_1.composeAssistantAnswer)({
userMessage,
routeSummary: normalized.route_hint_summary,
routeSummary: resolvedRouteSummary,
retrievalResults,
requirements: coverageEvaluation.requirements,
coverageReport: coverageEvaluation.coverage,
@@ -1351,7 +1463,7 @@ export class AssistantService {
timestamp: new Date().toISOString(),
questionId: userItem.message_id,
userMessage,
routeSummary: normalized.route_hint_summary,
routeSummary: resolvedRouteSummary,
requirements: coverageEvaluation.requirements,
coverageReport: coverageEvaluation.coverage,
retrievalResults,
@@ -1367,11 +1479,11 @@ export class AssistantService {
prompt_version: normalized.prompt_version,
schema_version: normalized.schema_version,
fallback_type: composition.fallback_type,
route_summary: normalized.route_hint_summary,
route_summary: resolvedRouteSummary,
fragments: extractFragments(normalized.normalized),
requirements_extracted: coverageEvaluation.requirements,
coverage_report: coverageEvaluation.coverage,
routes: toDebugRoutes(normalized.route_hint_summary),
routes: toDebugRoutes(resolvedRouteSummary),
retrieval_status: retrievalResults.map((item) => ({
fragment_id: item.fragment_id,
requirement_ids: item.requirement_ids,
@@ -1384,16 +1496,29 @@ export class AssistantService {
dropped_intent_segments: extractDiscardedIntentSegments(normalized.normalized),
question_type_class: questionTypeClass,
company_anchors: companyAnchors,
business_scope_raw: businessScopeResolution.business_scope_raw,
business_scope_resolved: businessScopeResolution.business_scope_resolved,
company_grounding_applied: businessScopeResolution.company_grounding_applied,
scope_resolution_reason: businessScopeResolution.scope_resolution_reason,
raw_time_anchor: temporalGuard.raw_time_anchor,
raw_time_scope: temporalGuard.raw_time_scope,
resolved_time_anchor: temporalGuard.resolved_time_anchor,
resolved_primary_period: temporalGuard.resolved_primary_period,
temporal_alignment_status: temporalGuard.temporal_alignment_status,
temporal_resolution_source: temporalGuard.temporal_resolution_source,
temporal_guard_basis: temporalGuard.temporal_guard_basis,
temporal_guard_applied: temporalGuard.temporal_guard_applied,
temporal_guard_outcome: temporalGuard.temporal_guard_outcome,
temporal_guard: temporalGuard,
raw_numeric_tokens: polarityGuardResult.audit.raw_numeric_tokens,
classified_numeric_tokens: polarityGuardResult.audit.classified_numeric_tokens,
rejected_as_non_accounts: polarityGuardResult.audit.rejected_as_non_accounts,
resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors,
domain_polarity_guard: polarityGuardResult.audit,
claim_anchor_audit: claimAnchorAudit,
targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit,
eligibility_time_basis: groundedAnswerEligibilityGuard.eligibility_time_basis,
grounded_answer_eligibility_guard: groundedAnswerEligibilityGuard,
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
problem_centric_answer_applied: composition.problem_centric_answer_applied ?? false,
@@ -1438,7 +1563,7 @@ export class AssistantService {
normalizer_output: normalized.normalized,
execution_plan: executionPlan,
resolved_execution_state: extractExecutionState(normalized.normalized),
routes: toDebugRoutes(normalized.route_hint_summary),
routes: toDebugRoutes(resolvedRouteSummary),
retrieval_calls: retrievalCalls,
retrieval_results_raw: retrievalResultsRaw,
retrieval_results_normalized: retrievalResults,
@@ -1460,16 +1585,29 @@ export class AssistantService {
dropped_intent_segments: extractDiscardedIntentSegments(normalized.normalized),
question_type_class: questionTypeClass,
company_anchors: companyAnchors,
business_scope_raw: businessScopeResolution.business_scope_raw,
business_scope_resolved: businessScopeResolution.business_scope_resolved,
company_grounding_applied: businessScopeResolution.company_grounding_applied,
scope_resolution_reason: businessScopeResolution.scope_resolution_reason,
raw_time_anchor: temporalGuard.raw_time_anchor,
raw_time_scope: temporalGuard.raw_time_scope,
resolved_time_anchor: temporalGuard.resolved_time_anchor,
resolved_primary_period: temporalGuard.resolved_primary_period,
temporal_alignment_status: temporalGuard.temporal_alignment_status,
temporal_resolution_source: temporalGuard.temporal_resolution_source,
temporal_guard_basis: temporalGuard.temporal_guard_basis,
temporal_guard_applied: temporalGuard.temporal_guard_applied,
temporal_guard_outcome: temporalGuard.temporal_guard_outcome,
temporal_guard: temporalGuard,
raw_numeric_tokens: polarityGuardResult.audit.raw_numeric_tokens,
classified_numeric_tokens: polarityGuardResult.audit.classified_numeric_tokens,
rejected_as_non_accounts: polarityGuardResult.audit.rejected_as_non_accounts,
resolved_account_anchors: polarityGuardResult.audit.resolved_account_anchors,
domain_polarity_guard: polarityGuardResult.audit,
claim_anchor_audit: claimAnchorAudit,
targeted_evidence_acquisition: targetedEvidenceResult.audit,
evidence_admissibility_gate: evidenceGateResult.audit,
eligibility_time_basis: groundedAnswerEligibilityGuard.eligibility_time_basis,
grounded_answer_eligibility_guard: groundedAnswerEligibilityGuard,
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
problem_centric_answer_applied: composition.problem_centric_answer_applied ?? false,
@@ -51,8 +51,162 @@ function capStrings(values: string[], max: number): string[] {
return uniqueStrings(values).slice(0, max);
}
const INVESTIGATION_ACCOUNT_PREFIXES = new Set([
"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 collectDateLikeSpans(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+(?:январ[ьяе]|феврал[ьяе]|март[ае]?|апрел[ьяе]|ма[йея]|июн[ьяе]?|июл[ьяе]?|август[ае]?|сентябр[ьяе]?|октябр[ьяе]?|ноябр[ьяе]?|декабр[ьяе]?|january|february|march|april|may|june|july|august|september|october|november|december)(?:\s+20\d{2})?\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 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);
}
function hasAccountContextAround(text: string, start: number, end: number): boolean {
const left = text.slice(Math.max(0, start - 32), start);
const right = text.slice(end, Math.min(text.length, end + 32));
return /(?:счет|сч\.?|account|schet|оплат|расчет|расч[её]т|аванс|зачет|зач[её]т|ндс|закрыт|провод|постав|покуп|settlement|payment|vat|close|supplier|customer)/iu.test(
`${left} ${right}`
);
}
function detectAccounts(text: string): string[] {
return capStrings(text.match(/\b\d{2}(?:\.\d{2})?\b/g) ?? [], INVESTIGATION_MAX_PRIMARY_ACCOUNTS);
const lower = String(text ?? "").toLowerCase();
const blockedSpans = [...collectDateLikeSpans(lower), ...collectAmountLikeSpans(lower), ...collectPercentLikeSpans(lower)];
const hasAccountingLexeme =
/(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расчет|расч[её]т|аванс|долг|settlement|payment|supplier|customer|ндс|vat|рбп|deferred|амортиз)/iu.test(
lower
);
const accounts = new Set<string>();
const contextualPattern =
/(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b)\s*(?:|#|:)?\s*(\d{2}(?:\.\d{1,2})?)/giu;
let contextualMatch: RegExpExecArray | null = null;
while ((contextualMatch = contextualPattern.exec(lower)) !== null) {
const token = String(contextualMatch[1] ?? "").trim();
const prefix = token.match(/^(\d{2})/)?.[1] ?? null;
if (prefix && INVESTIGATION_ACCOUNT_PREFIXES.has(prefix)) {
accounts.add(token);
}
}
const pairPattern = /\b(\d{2}\.\d{1,2})\s*\/\s*(\d{2}\.\d{1,2})\b/g;
let pairMatch: RegExpExecArray | null = null;
while ((pairMatch = pairPattern.exec(lower)) !== null) {
const left = String(pairMatch[1] ?? "").trim();
const right = String(pairMatch[2] ?? "").trim();
const leftPrefix = left.match(/^(\d{2})/)?.[1] ?? null;
const rightPrefix = right.match(/^(\d{2})/)?.[1] ?? null;
if (leftPrefix && INVESTIGATION_ACCOUNT_PREFIXES.has(leftPrefix)) {
accounts.add(left);
}
if (rightPrefix && INVESTIGATION_ACCOUNT_PREFIXES.has(rightPrefix)) {
accounts.add(right);
}
}
const genericPattern = /\b\d{2}(?:\.\d{1,2})?\b/g;
let genericMatch: RegExpExecArray | null = null;
while ((genericMatch = genericPattern.exec(lower)) !== null) {
const token = String(genericMatch[0] ?? "").trim();
const start = genericMatch.index;
const end = start + token.length;
if (intersectsSpan(start, end, blockedSpans)) {
continue;
}
const prefix = token.match(/^(\d{2})/)?.[1] ?? null;
if (!prefix || !INVESTIGATION_ACCOUNT_PREFIXES.has(prefix)) {
continue;
}
if (!hasAccountingLexeme && !hasAccountContextAround(lower, start, end)) {
continue;
}
accounts.add(token);
}
return capStrings(Array.from(accounts), INVESTIGATION_MAX_PRIMARY_ACCOUNTS);
}
function detectPeriod(text: string): string | null {
@@ -81,9 +81,43 @@ function computeRetryMaxOutputTokens(current: number, rawModelResponse: unknown)
function collectDateSpans(text: string): Array<{ start: number; end: number }> {
const spans: Array<{ start: number; end: number }> = [];
const datePattern = /\b20\d{2}[-/.](?:0[1-9]|1[0-2])(?:[-/.](?:0[1-9]|[12]\d|3[01]))?\b/g;
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+(?:январ[ьяе]|феврал[ьяе]|март[ае]?|апрел[ьяе]|ма[йея]|июн[ьяе]?|июл[ьяе]?|август[ае]?|сентябр[ьяе]?|октябр[ьяе]?|ноябр[ьяе]?|декабр[ьяе]?|january|february|march|april|may|june|july|august|september|october|november|december)(?:\s+20\d{2})?\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 collectAmountSpans(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 collectPercentSpans(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 = datePattern.exec(text)) !== null) {
while ((match = pattern.exec(text)) !== null) {
spans.push({
start: match.index,
end: match.index + match[0].length
@@ -98,20 +132,72 @@ function intersectsAnySpan(start: number, end: number, spans: Array<{ start: num
function extractAccounts(text: string): string[] {
const lower = String(text ?? "").toLowerCase();
const knownPrefixes = new Set([
"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"
]);
const explicitAccounts = new Set<string>();
const contextualPattern =
/(?:\bсч(?:е|ё)т(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b)\s*(?:|#|:)?\s*(\d{2}(?:\.\d{2})?)/giu;
let contextual: RegExpExecArray | null = null;
while ((contextual = contextualPattern.exec(lower)) !== null) {
if (contextual[1]) {
explicitAccounts.add(contextual[1]);
const token = String(contextual[1]).trim();
const prefix = token.match(/^(\d{2})/)?.[1] ?? null;
if (prefix && knownPrefixes.has(prefix)) {
explicitAccounts.add(token);
}
}
}
if (explicitAccounts.size > 0) {
return Array.from(explicitAccounts);
}
const spans = collectDateSpans(lower);
const spans = [...collectDateSpans(lower), ...collectAmountSpans(lower), ...collectPercentSpans(lower)];
const hasAccountingLexeme =
/(?:\bсчет(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b|оплат|расчет|расч[её]т|аванс|долг|settlement|payment|supplier|customer|ндс|vat|амортиз|рбп|deferred)/iu.test(
lower
);
if (!hasAccountingLexeme) {
return [];
}
const extracted: string[] = [];
const genericPattern = /\b\d{2}(?:\.\d{2})?\b/g;
let generic: RegExpExecArray | null = null;
@@ -122,6 +208,10 @@ function extractAccounts(text: string): string[] {
if (intersectsAnySpan(start, end, spans)) {
continue;
}
const prefix = value.match(/^(\d{2})/)?.[1] ?? null;
if (!prefix || !knownPrefixes.has(prefix)) {
continue;
}
extracted.push(value);
}
return Array.from(new Set(extracted));
@@ -494,6 +584,67 @@ function routeCanBeSelected(fragment: NormalizedFragmentV2): boolean {
return hasBusinessNodeSignals(fragment);
}
function hasJuly2020SnapshotSignal(userMessage: string, sessionContext?: NormalizeRequestPayload["context"]): boolean {
const text = String(userMessage ?? "").toLowerCase();
const contextPeriod = String(sessionContext?.period_hint ?? "").toLowerCase();
const businessContext = String(sessionContext?.business_context ?? "").toLowerCase();
if (/(?:\b2020[-/.]0?7\b|\bиюл[ьяе]?\b(?:\s+20\d{2})?|\bjuly\b(?:\s+20\d{2})?)/i.test(text)) {
return true;
}
return /2020[-/.]0?7|июл|july/.test(`${contextPeriod} ${businessContext}`);
}
function hasP0SignalForCompanyScope(userMessage: string): boolean {
const lower = String(userMessage ?? "").toLowerCase();
return /(?:\b(?:01|02|08|19|20|21|23|25|26|28|29|44|51|60|62|68|76|97)(?:\.\d{1,2})?\b|ндс|vat|supplier|customer|settlement|month\s*close|рбп|deferred|закрыти[ея]\s+месяц|амортиз|поставщ|покупат)/i.test(
lower
);
}
function applyCompanyScopeResolutionV2(
candidate: unknown,
userMessage: string,
sessionContext?: NormalizeRequestPayload["context"]
): unknown {
if (!candidate || typeof candidate !== "object") {
return candidate;
}
const source = candidate as Record<string, unknown>;
if (!Array.isArray(source.fragments)) {
return candidate;
}
const forceCompanyScope = hasJuly2020SnapshotSignal(userMessage, sessionContext) && hasP0SignalForCompanyScope(userMessage);
if (!forceCompanyScope) {
return candidate;
}
let changed = false;
const fragments = source.fragments.map((fragment) => {
if (!fragment || typeof fragment !== "object") {
return fragment;
}
const value = fragment as Record<string, unknown>;
if (value.domain_relevance !== "in_scope") {
return fragment;
}
const scopeValue = String(value.business_scope ?? "").trim();
if (scopeValue !== "generic_accounting" && scopeValue !== "unclear") {
return fragment;
}
changed = true;
return {
...value,
business_scope: "company_specific_accounting"
};
});
if (!changed) {
return candidate;
}
return {
...source,
fragments
};
}
function dedupeSoftAssumptions(input: SoftAssumption[]): SoftAssumption[] {
return Array.from(new Set(input));
}
@@ -945,6 +1096,9 @@ export class NormalizerService {
let validation = { passed: false, errors: ["NO_VALIDATION"] };
try {
normalizedCandidate = safeJsonParse(outputText);
if (schemaVersion !== "v1") {
normalizedCandidate = applyCompanyScopeResolutionV2(normalizedCandidate, payload.userQuestion, payload.context);
}
if (schemaVersion === "v2_0_2") {
normalizedCandidate = applyExecutionStatePolicyV202(normalizedCandidate, payload.userQuestion, payload.context);
} else if (schemaVersion === "v2_0_1") {
@@ -992,6 +1146,9 @@ export class NormalizerService {
usage = retry.usage;
try {
normalizedCandidate = safeJsonParse(outputText);
if (schemaVersion !== "v1") {
normalizedCandidate = applyCompanyScopeResolutionV2(normalizedCandidate, payload.userQuestion, payload.context);
}
if (schemaVersion === "v2_0_2") {
normalizedCandidate = applyExecutionStatePolicyV202(normalizedCandidate, payload.userQuestion, payload.context);
} else if (schemaVersion === "v2_0_1") {
@@ -75,8 +75,16 @@ export interface FollowupStateUsageDebug {
export interface TemporalGuardDebug {
raw_time_anchor: string | null;
raw_time_scope: string | null;
resolved_time_anchor: string | null;
resolved_primary_period: {
from: string;
to: string;
granularity: "day" | "month";
} | null;
temporal_alignment_status: "aligned" | "corrected" | "conflicting";
temporal_resolution_source: string;
temporal_guard_basis: "resolved_primary_period" | "raw_time_scope_unlocked" | "none";
temporal_guard_applied: boolean;
temporal_guard_outcome: "passed" | "failed_out_of_snapshot_window" | "ambiguous_limited";
primary_period_window: {
@@ -147,6 +155,13 @@ export interface DomainPolarityGuardDebug {
supplier_score: number;
customer_score: number;
account_scope: string[];
raw_numeric_tokens: string[];
classified_numeric_tokens: Array<{
token: string;
classification: "account_token" | "date_token" | "amount_token" | "percent_token" | "other_numeric";
}>;
rejected_as_non_accounts: string[];
resolved_account_anchors: string[];
rejected_problem_units: number;
rejected_evidence: number;
critical_contradiction: boolean;
@@ -173,6 +188,8 @@ export interface EvidenceAdmissibilityGateDebug {
export interface GroundedAnswerEligibilityGuardDebug {
eligible: boolean;
temporal_passed: boolean;
eligibility_time_basis: "resolved_primary_period" | "raw_time_scope_unlocked" | "none";
business_scope_passed: boolean;
polarity_passed: boolean;
claim_anchors_passed: boolean;
claim_anchor_resolution_rate: number | null;
@@ -246,16 +263,33 @@ export interface AssistantDebugPayload {
retrieval_results: UnifiedRetrievalResult[];
answer_grounding_check: AnswerGroundingCheck;
dropped_intent_segments: string[];
business_scope_raw?: string[];
business_scope_resolved?: string[];
company_grounding_applied?: boolean;
scope_resolution_reason?: string[];
raw_time_anchor?: string | null;
raw_time_scope?: string | null;
resolved_time_anchor?: string | null;
resolved_primary_period?: {
from: string;
to: string;
granularity: "day" | "month";
} | null;
temporal_alignment_status?: TemporalGuardDebug["temporal_alignment_status"];
temporal_resolution_source?: string;
temporal_guard_basis?: TemporalGuardDebug["temporal_guard_basis"];
temporal_guard_applied?: boolean;
temporal_guard_outcome?: TemporalGuardDebug["temporal_guard_outcome"];
temporal_guard?: TemporalGuardDebug;
raw_numeric_tokens?: string[];
classified_numeric_tokens?: DomainPolarityGuardDebug["classified_numeric_tokens"];
rejected_as_non_accounts?: string[];
resolved_account_anchors?: string[];
domain_polarity_guard?: DomainPolarityGuardDebug;
claim_anchor_audit?: ClaimBoundAnchorAuditDebug;
targeted_evidence_acquisition?: TargetedEvidenceAcquisitionDebug;
evidence_admissibility_gate?: EvidenceAdmissibilityGateDebug;
eligibility_time_basis?: GroundedAnswerEligibilityGuardDebug["eligibility_time_basis"];
grounded_answer_eligibility_guard?: GroundedAnswerEligibilityGuardDebug;
followup_state_usage?: FollowupStateUsageDebug;
problem_centric_answer_applied?: boolean;