diff --git a/llm_normalizer/backend/dist/services/assistantDataLayer.js b/llm_normalizer/backend/dist/services/assistantDataLayer.js index 96b8788..c6e9a10 100644 --- a/llm_normalizer/backend/dist/services/assistantDataLayer.js +++ b/llm_normalizer/backend/dist/services/assistantDataLayer.js @@ -845,7 +845,9 @@ function collectDateLikeSpans(text) { const spans = []; 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 = null; diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js index 1a44146..dce5949 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js @@ -102,14 +102,66 @@ function accountPrefix(value) { const match = token.match(/^(\d{2})/); return match ? match[1] : null; } -function extractAccountsFromText(text) { +function collectDateLikeSpans(text) { + const spans = []; + 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 = null; + while ((match = pattern.exec(text)) !== null) { + spans.push({ + start: match.index, + end: match.index + match[0].length + }); + } + } + return spans; +} +function collectAmountLikeSpans(text) { + const spans = []; + 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 = null; + while ((match = pattern.exec(text)) !== null) { + spans.push({ + start: match.index, + end: match.index + match[0].length + }); + } + } + return spans; +} +function collectPercentLikeSpans(text) { + const spans = []; + const pattern = /\b\d{1,3}(?:[.,]\d+)?\s*%/g; + let match = null; + while ((match = pattern.exec(text)) !== null) { + spans.push({ + start: match.index, + end: match.index + match[0].length + }); + } + return spans; +} +function intersectsSpan(start, end, spans) { + return spans.some((span) => start < span.end && end > span.start); +} +function extractAccountsFromTextDetailed(text, options) { const lower = String(text ?? "").toLowerCase(); const accounts = new Set(); - const contextualPattern = /(?:\b(?:СЃС‡(?:Рµ|С‘)С‚(?:Р°|Сѓ|РѕРј|РѕРІ)?|account|schet)\b\s*(?:в„–|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu; + const dateSpans = collectDateLikeSpans(lower); + const amountSpans = collectAmountLikeSpans(lower); + const percentSpans = collectPercentLikeSpans(lower); + const blockedSpans = [...dateSpans, ...amountSpans, ...percentSpans]; + const contextualPattern = /(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b\s*(?:№|#|:)?\s*)(\d{2}(?:\.\d{2})?)/giu; let contextualMatch = 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); } } @@ -118,36 +170,130 @@ function extractAccountsFromText(text) { while ((pairMatch = pairPattern.exec(lower)) !== null) { const left = String(pairMatch[1] ?? "").trim(); const right = String(pairMatch[2] ?? "").trim(); - if (left) + 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) + if (right && rightPrefix && KNOWN_ACCOUNT_PREFIXES.has(rightPrefix)) accounts.add(right); } const genericAccountPattern = /\b(\d{2}(?:\.\d{2})?)\b/g; let genericMatch = null; + const classifiedNumericTokens = []; + const rejectedAsNonAccounts = new Set(); 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 = 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) { +function extractAccountsFromText(text) { + return extractAccountsFromTextDetailed(text).resolved_account_anchors; +} +function extractAccountsFromUnknown(value, pathKey = "") { 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).flatMap((item) => extractAccountsFromUnknown(item))); + return uniqueStrings(Object.entries(value).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) { return String(value).padStart(2, "0"); @@ -360,16 +506,51 @@ function resolveJulyAnchor(rawText) { applyGuard: true }; } +function inferPrimaryWindowFromAnchor(anchor) { + 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" + }; +} function resolveTemporalGuard(input) { const rawAnchorText = collectRawTemporalAnchorText(input.userMessage, input.companyAnchors); const julyAnchor = resolveJulyAnchor(rawAnchorText); const normalizedAnchor = normalizedAnchorFromFragments(input.normalized); const reasonCodes = []; 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, @@ -377,24 +558,31 @@ 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 = "passed"; let normalizedAnchorDriftDetected = false; + let 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, @@ -428,7 +616,8 @@ function applyTemporalHintToExecutionPlan(executionPlan, temporal) { } function resolveDomainPolarityGuard(input) { 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) => Boolean(item))); const settlementSignal = input.focusDomainHint === "settlements_60_62" || prefixes.has("60") || @@ -444,6 +633,10 @@ 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, @@ -471,6 +664,10 @@ 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, @@ -959,6 +1156,11 @@ function applyEvidenceAdmissibilityGate(input) { } function evaluateGroundedAnswerEligibility(input) { 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; const missingRequiredAnchors = input.claimAnchors ? Number(input.claimAnchors.missing_anchors?.length ?? 0) : 0; @@ -972,6 +1174,7 @@ function evaluateGroundedAnswerEligibility(input) { ? true : Number(input.targetedEvidenceHitRate) > 0; const eligible = temporalPassed && + businessScopePassed && polarityPassed && claimAnchorsPassed && admissibleEvidenceCount > 0 && @@ -984,6 +1187,9 @@ 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"); } @@ -999,6 +1205,8 @@ 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, @@ -1014,7 +1222,10 @@ function applyEligibilityToGroundingCheck(groundingCheck, eligibility) { if (eligibility.eligible) { return groundingCheck; } - const status = eligibility.admissible_evidence_count <= 0 || !eligibility.temporal_passed || !eligibility.claim_anchors_passed + const status = eligibility.admissible_evidence_count <= 0 || + !eligibility.temporal_passed || + !eligibility.claim_anchors_passed || + !eligibility.business_scope_passed ? "no_grounded_answer" : "partial"; const reasonMap = { @@ -1022,6 +1233,7 @@ function applyEligibilityToGroundingCheck(groundingCheck, eligibility) { 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.", diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 0340e54..187f2c5 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -120,6 +120,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, "\\$&"); } @@ -179,8 +256,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; @@ -193,6 +271,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); } @@ -268,7 +372,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 []; @@ -884,7 +988,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; @@ -1230,6 +1334,13 @@ 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" || @@ -1252,8 +1363,8 @@ 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 = []; @@ -1343,7 +1454,8 @@ 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 @@ -1355,7 +1467,7 @@ 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, @@ -1389,7 +1501,7 @@ 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, @@ -1405,11 +1517,11 @@ 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, @@ -1422,16 +1534,29 @@ 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, @@ -1476,7 +1601,7 @@ 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, @@ -1498,16 +1623,29 @@ 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, diff --git a/llm_normalizer/backend/dist/services/investigationState.js b/llm_normalizer/backend/dist/services/investigationState.js index ec51d8e..902bb1e 100644 --- a/llm_normalizer/backend/dist/services/investigationState.js +++ b/llm_normalizer/backend/dist/services/investigationState.js @@ -11,8 +11,146 @@ function uniqueStrings(values) { function capStrings(values, max) { 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) { + const spans = []; + 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 = null; + while ((match = pattern.exec(text)) !== null) { + spans.push({ + start: match.index, + end: match.index + match[0].length + }); + } + } + return spans; +} +function collectAmountLikeSpans(text) { + const spans = []; + 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 = null; + while ((match = pattern.exec(text)) !== null) { + spans.push({ + start: match.index, + end: match.index + match[0].length + }); + } + } + return spans; +} +function collectPercentLikeSpans(text) { + const spans = []; + const pattern = /\b\d{1,3}(?:[.,]\d+)?\s*%/g; + let match = null; + while ((match = pattern.exec(text)) !== null) { + spans.push({ + start: match.index, + end: match.index + match[0].length + }); + } + return spans; +} +function intersectsSpan(start, end, spans) { + return spans.some((span) => start < span.end && end > span.start); +} +function hasAccountContextAround(text, start, end) { + 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) { - return capStrings(text.match(/\b\d{2}(?:\.\d{2})?\b/g) ?? [], stage1Contracts_1.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(); + const contextualPattern = /(?:\b(?:счет(?:а|у|ом|ов)?|сч\.?|account(?:s)?|schet(?:a|u|om|ov)?)\b)\s*(?:№|#|:)?\s*(\d{2}(?:\.\d{1,2})?)/giu; + let contextualMatch = 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 = 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 = 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), stage1Contracts_1.INVESTIGATION_MAX_PRIMARY_ACCOUNTS); } function detectPeriod(text) { const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/); diff --git a/llm_normalizer/backend/dist/services/normalizerService.js b/llm_normalizer/backend/dist/services/normalizerService.js index 82cba5e..8675452 100644 --- a/llm_normalizer/backend/dist/services/normalizerService.js +++ b/llm_normalizer/backend/dist/services/normalizerService.js @@ -60,9 +60,41 @@ function computeRetryMaxOutputTokens(current, rawModelResponse) { } function collectDateSpans(text) { const spans = []; - 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 = null; + while ((match = pattern.exec(text)) !== null) { + spans.push({ + start: match.index, + end: match.index + match[0].length + }); + } + } + return spans; +} +function collectAmountSpans(text) { + const spans = []; + 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 = null; + while ((match = pattern.exec(text)) !== null) { + spans.push({ + start: match.index, + end: match.index + match[0].length + }); + } + } + return spans; +} +function collectPercentSpans(text) { + const spans = []; + const pattern = /\b\d{1,3}(?:[.,]\d+)?\s*%/g; let match = null; - while ((match = datePattern.exec(text)) !== null) { + while ((match = pattern.exec(text)) !== null) { spans.push({ start: match.index, end: match.index + match[0].length @@ -75,18 +107,67 @@ function intersectsAnySpan(start, end, spans) { } function extractAccounts(text) { 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(); const contextualPattern = /(?:\bсч(?:е|ё)т(?:а|у|ом|ов)?\b|\bсч\.?\b|\baccount(?:s)?\b|\bschet(?:a|u|om|ov)?\b)\s*(?:№|#|:)?\s*(\d{2}(?:\.\d{2})?)/giu; let contextual = 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 = []; const genericPattern = /\b\d{2}(?:\.\d{2})?\b/g; let generic = null; @@ -97,6 +178,10 @@ function extractAccounts(text) { 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)); @@ -424,6 +509,58 @@ function routeCanBeSelected(fragment) { } return hasBusinessNodeSignals(fragment); } +function hasJuly2020SnapshotSignal(userMessage, sessionContext) { + 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) { + 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, userMessage, sessionContext) { + if (!candidate || typeof candidate !== "object") { + return candidate; + } + const source = candidate; + 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; + 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) { return Array.from(new Set(input)); } @@ -787,6 +924,9 @@ 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); } @@ -831,6 +971,9 @@ 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); } diff --git a/llm_normalizer/backend/scripts/wave19_1LiveAlignmentPack.js b/llm_normalizer/backend/scripts/wave19_1LiveAlignmentPack.js new file mode 100644 index 0000000..5a272db --- /dev/null +++ b/llm_normalizer/backend/scripts/wave19_1LiveAlignmentPack.js @@ -0,0 +1,539 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const request = require("supertest"); + +const CASES = [ + { + case_id: "L1", + label: "vat_chain_furniture_july2020", + expected_mode: "grounded_positive", + user_message: + "VAT chain july 2020 for furniture purchase and realization: prove document -> invoice -> register -> book linkage and show where chain is complete." + }, + { + case_id: "L2", + label: "rbp_writeoff_31_july", + expected_mode: "limited", + user_message: + "RBP writeoff at 31 july 2020: confirm whether residual tail on account 97 is normal residual or unresolved writeoff gap." + }, + { + case_id: "L3", + label: "fa_amortization_three_amounts", + expected_mode: "limited", + user_message: + "Fixed asset amortization in july 2020 by three amounts 12000.00, 8000.00, 233.33: detect if any object missed depreciation posting." + }, + { + case_id: "L4", + label: "settlement_supplier_60_closure", + expected_mode: "grounded_positive", + user_message: + "Supplier settlement on account 60 in july 2020: payment exists but tail remains open. prove contract/object/closure mechanism." + }, + { + case_id: "L5", + label: "month_close_20_44_july", + expected_mode: "grounded_positive", + user_message: + "Month close july 2020 on accounts 20 and 44: prove close operation and distribution chain, separate normal residual from contradiction." + } +]; + +function ratio(num, den) { + if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0) { + return 0; + } + return Number((num / den).toFixed(4)); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeJson(filePath, payload) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); +} + +function writeText(filePath, text) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, text, "utf8"); +} + +function clearBackendDistCache() { + const marker = `${path.sep}backend${path.sep}dist${path.sep}`; + for (const key of Object.keys(require.cache)) { + if (key.includes(marker)) { + delete require.cache[key]; + } + } +} + +function extractLiveCallsFromDebug(debug) { + const calls = []; + const retrievalResults = Array.isArray(debug?.retrieval_results) ? debug.retrieval_results : []; + for (const result of retrievalResults) { + const live = result?.summary?.live_mcp; + if (!live || typeof live !== "object") { + continue; + } + calls.push({ + fragment_id: result?.fragment_id ?? null, + route: result?.route ?? null, + method: "execute_query", + args_summary: { + account_scope: Array.isArray(live.account_scope) ? live.account_scope : [], + route: String(live.route ?? result?.route ?? ""), + channel: String(live.channel ?? ""), + proxy: String(live.proxy ?? "") + }, + fetched_rows: Number(live.fetched_rows ?? 0), + matched_rows: Number(live.matched_rows ?? 0), + returned_rows: Number(live.returned_rows ?? 0), + status: String(live.status ?? "unknown"), + error: live.error ? String(live.error) : null + }); + } + return calls; +} + +function summarizeCase(caseInput, responseBody, suiteMode) { + const debug = responseBody?.debug ?? {}; + const temporal = debug?.temporal_guard ?? {}; + const eligibility = debug?.grounded_answer_eligibility_guard ?? {}; + const evidenceGate = debug?.evidence_admissibility_gate ?? {}; + const liveCalls = extractLiveCallsFromDebug(debug); + const classified = Array.isArray(debug?.classified_numeric_tokens) ? debug.classified_numeric_tokens : []; + const resolvedAccounts = Array.isArray(debug?.resolved_account_anchors) ? debug.resolved_account_anchors : []; + const polluted = resolvedAccounts.some((token) => + classified.some( + (entry) => + String(entry?.token ?? "").trim() === String(token ?? "").trim() && + String(entry?.classification ?? "").trim() !== "account_token" + ) + ); + const julySignal = /(?:2020[-/.]0?7|july|июл)/i.test(String(caseInput.user_message ?? "")); + return { + case_id: caseInput.case_id, + label: caseInput.label, + expected_mode: caseInput.expected_mode, + suite_mode: suiteMode, + trace_id: String(debug?.trace_id ?? ""), + reply_type: String(responseBody?.reply_type ?? ""), + assistant_reply: String(responseBody?.assistant_reply ?? ""), + temporal: { + raw_time_scope: temporal?.raw_time_scope ?? null, + resolved_primary_period: temporal?.resolved_primary_period ?? null, + temporal_alignment_status: temporal?.temporal_alignment_status ?? null, + temporal_guard_basis: temporal?.temporal_guard_basis ?? null, + temporal_guard_outcome: temporal?.temporal_guard_outcome ?? null + }, + anchor_pollution: { + raw_numeric_tokens: Array.isArray(debug?.raw_numeric_tokens) ? debug.raw_numeric_tokens : [], + classified_numeric_tokens: classified, + rejected_as_non_accounts: Array.isArray(debug?.rejected_as_non_accounts) ? debug.rejected_as_non_accounts : [], + resolved_account_anchors: resolvedAccounts, + pollution_detected: polluted + }, + business_scope: { + business_scope_raw: Array.isArray(debug?.business_scope_raw) ? debug.business_scope_raw : [], + business_scope_resolved: Array.isArray(debug?.business_scope_resolved) ? debug.business_scope_resolved : [], + company_grounding_applied: Boolean(debug?.company_grounding_applied), + scope_resolution_reason: Array.isArray(debug?.scope_resolution_reason) ? debug.scope_resolution_reason : [], + july_snapshot_signal: julySignal + }, + evidence: { + candidate_evidence_total: Number(evidenceGate?.candidate_evidence_total ?? 0), + admissible_evidence_count: Number(evidenceGate?.admissible_evidence_count ?? 0), + rejected_evidence_count: Number(evidenceGate?.rejected_evidence_count ?? 0) + }, + eligibility: { + eligible: Boolean(eligibility?.eligible), + grounding_mode: String(eligibility?.grounding_mode ?? ""), + outcome: String(eligibility?.outcome ?? ""), + reason_codes: Array.isArray(eligibility?.reason_codes) ? eligibility.reason_codes : [], + temporal_passed: Boolean(eligibility?.temporal_passed), + eligibility_time_basis: String(eligibility?.eligibility_time_basis ?? ""), + business_scope_passed: Boolean(eligibility?.business_scope_passed) + }, + live_calls: liveCalls, + debug + }; +} + +function computeMetrics(rows) { + const positiveCases = rows.filter((row) => row.expected_mode === "grounded_positive"); + const temporalChecked = rows.filter((row) => row.temporal.temporal_guard_basis === "resolved_primary_period"); + const alignmentGood = temporalChecked.filter((row) => + ["aligned", "corrected"].includes(String(row.temporal.temporal_alignment_status)) + ); + const anchorPollutionFree = rows.filter((row) => !row.anchor_pollution.pollution_detected); + const companyScopeResolved = rows.filter( + (row) => + !row.business_scope.july_snapshot_signal || + row.business_scope.business_scope_resolved.includes("company_specific_accounting") + ); + const livePositive = positiveCases.filter((row) => row.eligibility.grounding_mode === "grounded_positive"); + const falseGrounded = rows.filter( + (row) => row.eligibility.grounding_mode === "grounded_positive" && row.evidence.admissible_evidence_count <= 0 + ); + const liveInventoryCovered = rows.filter((row) => row.live_calls.length > 0); + return { + case_count: rows.length, + temporal_alignment_correctness_rate: ratio(alignmentGood.length, Math.max(1, temporalChecked.length)), + anchor_pollution_free_rate: ratio(anchorPollutionFree.length, Math.max(1, rows.length)), + company_scope_resolution_rate: ratio(companyScopeResolved.length, Math.max(1, rows.length)), + live_positive_grounding_rate: ratio(livePositive.length, Math.max(1, positiveCases.length)), + false_grounded_answer_rate: ratio(falseGrounded.length, Math.max(1, rows.length)), + real_live_inventory_coverage_rate: ratio(liveInventoryCovered.length, Math.max(1, rows.length)) + }; +} + +function computeParity(mockRows, liveRows) { + const byMock = new Map(mockRows.map((row) => [row.case_id, row])); + const rows = liveRows.map((live) => { + const mock = byMock.get(live.case_id); + if (!mock) { + return { + case_id: live.case_id, + label: live.label, + parity_score: 0, + parity_status: "missing_mock_case", + checks: [] + }; + } + const checks = [ + { + key: "temporal_basis", + passed: String(mock.temporal.temporal_guard_basis) === String(live.temporal.temporal_guard_basis) + }, + { + key: "anchor_pollution", + passed: Boolean(mock.anchor_pollution.pollution_detected) === Boolean(live.anchor_pollution.pollution_detected) + }, + { + key: "business_scope", + passed: + mock.business_scope.business_scope_resolved.includes("company_specific_accounting") === + live.business_scope.business_scope_resolved.includes("company_specific_accounting") + }, + { + key: "eligibility_outcome", + passed: + String(live.eligibility.outcome) === String(mock.eligibility.outcome) || + (String(mock.eligibility.grounding_mode) === "limited_or_insufficient_evidence" && + String(live.eligibility.grounding_mode) === "grounded_positive") + } + ]; + const parityScore = ratio( + checks.filter((item) => item.passed).length, + Math.max(1, checks.length) + ); + return { + case_id: live.case_id, + label: live.label, + parity_score: parityScore, + parity_status: parityScore >= 0.75 ? "match_or_improved" : "diverged", + checks + }; + }); + return { + rows, + mock_live_parity_rate: ratio( + rows.reduce((acc, item) => acc + Number(item.parity_score ?? 0), 0), + Math.max(1, rows.length) + ) + }; +} + +async function runSuite(input) { + process.env.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = input.mcpEnabled ? "1" : "0"; + clearBackendDistCache(); + const { createApp } = require("../dist/server.js"); + const app = createApp(); + const results = []; + for (const testCase of CASES) { + const res = await request(app).post("/api/assistant/message").send({ + useMock: true, + promptVersion: "normalizer_v2_0_2", + user_message: testCase.user_message + }); + if (res.status !== 200) { + throw new Error(`Suite ${input.suiteName}, case ${testCase.case_id} failed with status=${res.status}`); + } + results.push(summarizeCase(testCase, res.body, input.suiteName)); + } + return results; +} + +function toMarkdownTable(header, rows) { + return [header, ...rows].join("\n"); +} + +async function main() { + const runDir = process.argv[2]; + if (!runDir) { + throw new Error("Usage: node wave19_1LiveAlignmentPack.js "); + } + + const mockRows = await runSuite({ + suiteName: "mock_baseline_mcp_off", + mcpEnabled: false + }); + const liveRows = await runSuite({ + suiteName: "live_alignment_mcp_on", + mcpEnabled: true + }); + + const mockMetrics = computeMetrics(mockRows); + const liveMetrics = computeMetrics(liveRows); + const parity = computeParity(mockRows, liveRows); + + const beforeAfter = { + baseline: "mock_baseline_mcp_off", + after: "live_alignment_mcp_on", + metrics_before: { + temporal_alignment_correctness_rate: mockMetrics.temporal_alignment_correctness_rate, + anchor_pollution_free_rate: mockMetrics.anchor_pollution_free_rate, + company_scope_resolution_rate: mockMetrics.company_scope_resolution_rate, + live_positive_grounding_rate: mockMetrics.live_positive_grounding_rate, + mock_live_parity_rate: 1, + real_live_inventory_coverage_rate: mockMetrics.real_live_inventory_coverage_rate, + false_grounded_answer_rate: mockMetrics.false_grounded_answer_rate + }, + metrics_after: { + temporal_alignment_correctness_rate: liveMetrics.temporal_alignment_correctness_rate, + anchor_pollution_free_rate: liveMetrics.anchor_pollution_free_rate, + company_scope_resolution_rate: liveMetrics.company_scope_resolution_rate, + live_positive_grounding_rate: liveMetrics.live_positive_grounding_rate, + mock_live_parity_rate: parity.mock_live_parity_rate, + real_live_inventory_coverage_rate: liveMetrics.real_live_inventory_coverage_rate, + false_grounded_answer_rate: liveMetrics.false_grounded_answer_rate + } + }; + writeJson(path.join(runDir, "before_after_metrics.json"), beforeAfter); + + writeJson(path.join(runDir, "artifacts", "mock_probe_live5.json"), { + generated_at: new Date().toISOString(), + suite: "mock_baseline_mcp_off", + cases: mockRows.map((row) => ({ ...row, debug: undefined })) + }); + writeJson(path.join(runDir, "artifacts", "live_probe_live5.json"), { + generated_at: new Date().toISOString(), + suite: "live_alignment_mcp_on", + cases: liveRows.map((row) => ({ ...row, debug: undefined })) + }); + + for (const row of liveRows) { + writeJson(path.join(runDir, "debug_payloads", `${row.case_id}_${row.label}.json`), { + case_id: row.case_id, + label: row.label, + suite_mode: row.suite_mode, + debug: row.debug + }); + } + + const temporalAudit = { + generated_at: new Date().toISOString(), + cases: liveRows.map((row) => ({ + case_id: row.case_id, + label: row.label, + raw_time_scope: row.temporal.raw_time_scope, + resolved_primary_period: row.temporal.resolved_primary_period, + temporal_alignment_status: row.temporal.temporal_alignment_status, + temporal_guard_basis: row.temporal.temporal_guard_basis, + eligibility_time_basis: row.eligibility.eligibility_time_basis, + temporal_guard_outcome: row.temporal.temporal_guard_outcome + })), + metric: { + temporal_alignment_correctness_rate: liveMetrics.temporal_alignment_correctness_rate + } + }; + writeJson(path.join(runDir, "temporal_alignment_audit.json"), temporalAudit); + + const anchorAudit = { + generated_at: new Date().toISOString(), + cases: liveRows.map((row) => ({ + case_id: row.case_id, + label: row.label, + raw_numeric_tokens: row.anchor_pollution.raw_numeric_tokens, + classified_numeric_tokens: row.anchor_pollution.classified_numeric_tokens, + rejected_as_non_accounts: row.anchor_pollution.rejected_as_non_accounts, + resolved_account_anchors: row.anchor_pollution.resolved_account_anchors, + pollution_detected: row.anchor_pollution.pollution_detected + })), + metric: { + anchor_pollution_free_rate: liveMetrics.anchor_pollution_free_rate + } + }; + writeJson(path.join(runDir, "anchor_pollution_audit.json"), anchorAudit); + + const scopeAudit = { + generated_at: new Date().toISOString(), + cases: liveRows.map((row) => ({ + case_id: row.case_id, + label: row.label, + business_scope_raw: row.business_scope.business_scope_raw, + business_scope_resolved: row.business_scope.business_scope_resolved, + company_grounding_applied: row.business_scope.company_grounding_applied, + scope_resolution_reason: row.business_scope.scope_resolution_reason, + july_snapshot_signal: row.business_scope.july_snapshot_signal + })), + metric: { + company_scope_resolution_rate: liveMetrics.company_scope_resolution_rate + } + }; + writeJson(path.join(runDir, "business_scope_resolution_audit.json"), scopeAudit); + + const liveInventory = { + generated_at: new Date().toISOString(), + mcp_runtime_enabled: true, + suite_mode: "live_alignment_mcp_on", + cases: liveRows.map((row) => ({ + case_id: row.case_id, + label: row.label, + expected_mode: row.expected_mode, + live_calls: row.live_calls.map((call) => ({ + ...call, + used_for_admissible_evidence: row.evidence.admissible_evidence_count > 0, + rejected_reason: + row.evidence.admissible_evidence_count > 0 + ? null + : row.eligibility.reason_codes.length > 0 + ? row.eligibility.reason_codes + : ["insufficient_admissible_evidence"] + })) + })) + }; + writeJson(path.join(runDir, "real_live_call_inventory.json"), liveInventory); + + const parityHeader = + "# Mock vs Live Parity Matrix\n\n| Case | Label | Parity Score | Status | Temporal Basis | Anchor Pollution | Business Scope | Eligibility |\n| --- | --- | ---: | --- | --- | --- | --- | --- |"; + const parityRows = parity.rows.map((row) => { + const lookup = new Map(row.checks.map((item) => [item.key, item.passed ? "pass" : "fail"])); + return `| ${row.case_id} | ${row.label} | ${row.parity_score} | ${row.parity_status} | ${lookup.get("temporal_basis") ?? "n/a"} | ${lookup.get("anchor_pollution") ?? "n/a"} | ${lookup.get("business_scope") ?? "n/a"} | ${lookup.get("eligibility_outcome") ?? "n/a"} |`; + }); + writeText(path.join(runDir, "mock_vs_live_parity_matrix.md"), toMarkdownTable(parityHeader, parityRows)); + + const chatLines = ["# Chat Export Live-5", ""]; + for (const row of liveRows) { + const trimmed = row.assistant_reply.replace(/\s+/g, " ").trim(); + chatLines.push(`## ${row.case_id} | ${row.label}`); + chatLines.push(`user: ${CASES.find((item) => item.case_id === row.case_id)?.user_message ?? ""}`); + chatLines.push(`assistant(reply_type=${row.reply_type}): ${trimmed}`); + chatLines.push(""); + } + writeText(path.join(runDir, "chat_export_live5.md"), chatLines.join("\n")); + + const groundedHeader = + "# Grounded Positive vs Limited (Live)\n\n| Case | Label | Expected | Grounding Mode | Admissible Evidence | Eligibility | Reply Type |\n| --- | --- | --- | --- | ---: | --- | --- |"; + const groundedRows = liveRows.map( + (row) => + `| ${row.case_id} | ${row.label} | ${row.expected_mode} | ${row.eligibility.grounding_mode} | ${row.evidence.admissible_evidence_count} | ${row.eligibility.outcome} | ${row.reply_type} |` + ); + writeText(path.join(runDir, "grounded_positive_vs_limited_live.md"), toMarkdownTable(groundedHeader, groundedRows)); + + const liveAlignmentReport = `# Live Alignment Report (Wave 19.1) + +## Scope +- Temporal alignment sync: raw_time_scope -> resolved_primary_period -> guard/eligibility basis. +- Anchor pollution cleanup: date/amount/percent numeric tokens excluded from account anchors. +- Business scope resolution: generic -> company-specific for July 2020 P0 signals. +- Live parity check: mock baseline (MCP OFF) vs live-alignment (MCP ON). + +## Constraints +- Normalizer was executed in \`useMock=true\` because OPENAI API key is unavailable in this environment. +- MCP runtime was toggled ON for live-alignment suite; inventory contains actual MCP overlay summaries from runtime. + +## Key Metrics (Live) +- temporal_alignment_correctness_rate: ${liveMetrics.temporal_alignment_correctness_rate} +- anchor_pollution_free_rate: ${liveMetrics.anchor_pollution_free_rate} +- company_scope_resolution_rate: ${liveMetrics.company_scope_resolution_rate} +- live_positive_grounding_rate: ${liveMetrics.live_positive_grounding_rate} +- mock_live_parity_rate: ${parity.mock_live_parity_rate} +- real_live_inventory_coverage_rate: ${liveMetrics.real_live_inventory_coverage_rate} +- false_grounded_answer_rate: ${liveMetrics.false_grounded_answer_rate} +`; + writeText(path.join(runDir, "live_alignment_report.md"), liveAlignmentReport); + + const thresholds = { + temporal_alignment_correctness_rate: 0.95, + anchor_pollution_free_rate: 0.95, + company_scope_resolution_rate: 0.95, + mock_live_parity_rate: 0.85, + false_grounded_answer_rate: 0 + }; + const temporalFixed = liveMetrics.temporal_alignment_correctness_rate >= thresholds.temporal_alignment_correctness_rate; + const anchorFixed = liveMetrics.anchor_pollution_free_rate >= thresholds.anchor_pollution_free_rate; + const companyScopeFixed = liveMetrics.company_scope_resolution_rate >= thresholds.company_scope_resolution_rate; + const parityReached = + parity.mock_live_parity_rate >= thresholds.mock_live_parity_rate && + liveMetrics.false_grounded_answer_rate <= thresholds.false_grounded_answer_rate; + const overallStatus = + temporalFixed && anchorFixed && companyScopeFixed && parityReached + ? "WAVE19_1_ACCEPTED" + : liveMetrics.false_grounded_answer_rate <= 0 + ? "WAVE19_1_ACCEPTED_WITH_LIMITATIONS" + : "WAVE19_1_NOT_ACCEPTED"; + + const runSummary = { + run_id: path.basename(runDir), + stage: "Stage_04", + wave: "Wave_19_1", + scope: "live_alignment_fix_claim_bound_runtime", + execution: { + mock_baseline_suite: "MCP runtime OFF, useMock=true", + live_alignment_suite: "MCP runtime ON, useMock=true" + }, + thresholds, + metrics_live: liveMetrics, + metrics_parity: { + mock_live_parity_rate: parity.mock_live_parity_rate + }, + verdicts: { + TEMPORAL_ALIGNMENT_FIXED: temporalFixed ? "FIXED" : "NOT_FIXED", + ANCHOR_POLLUTION_FIXED: anchorFixed ? "FIXED" : "NOT_FIXED", + COMPANY_SCOPE_FIXED: companyScopeFixed ? "FIXED" : "NOT_FIXED", + LIVE_PARITY_REACHED: parityReached ? "REACHED" : "NOT_REACHED", + overall_status: overallStatus + } + }; + writeJson(path.join(runDir, "run_summary.json"), runSummary); + + const readme = `# Stage 4 / Wave 19.1 - Live Alignment Fix (Claim-Bound Runtime) + +## What was executed +- Backend build + full tests. +- Two control suites on same 5 cases: + - \`mock_baseline_mcp_off\`: MCP runtime disabled. + - \`live_alignment_mcp_on\`: MCP runtime enabled. +- Normalizer used \`useMock=true\` due missing OPENAI API key in environment. + +## Output artifacts +- run_summary.json +- before_after_metrics.json +- live_alignment_report.md +- mock_vs_live_parity_matrix.md +- chat_export_live5.md +- debug_payloads/ +- real_live_call_inventory.json +- temporal_alignment_audit.json +- anchor_pollution_audit.json +- business_scope_resolution_audit.json +- grounded_positive_vs_limited_live.md + +## Final verdict +- TEMPORAL_ALIGNMENT_FIXED: ${temporalFixed ? "FIXED" : "NOT_FIXED"} +- ANCHOR_POLLUTION_FIXED: ${anchorFixed ? "FIXED" : "NOT_FIXED"} +- COMPANY_SCOPE_FIXED: ${companyScopeFixed ? "FIXED" : "NOT_FIXED"} +- LIVE_PARITY_REACHED: ${parityReached ? "REACHED" : "NOT_REACHED"} +- Overall: ${overallStatus} +`; + writeText(path.join(runDir, "README.md"), readme); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/llm_normalizer/backend/src/services/assistantDataLayer.ts b/llm_normalizer/backend/src/services/assistantDataLayer.ts index e6fc6fe..dbe07ee 100644 --- a/llm_normalizer/backend/src/services/assistantDataLayer.ts +++ b/llm_normalizer/backend/src/services/assistantDataLayer.ts @@ -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; diff --git a/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts b/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts index 1c4ef22..3ed1f33 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeGuards.ts @@ -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(); + 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(); 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).flatMap((item) => extractAccountsFromUnknown(item))); + return uniqueStrings( + Object.entries(value as Record).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 = { @@ -1237,6 +1489,7 @@ export function applyEligibilityToGroundingCheck /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, diff --git a/llm_normalizer/backend/src/services/investigationState.ts b/llm_normalizer/backend/src/services/investigationState.ts index 43760b2..17d9eba 100644 --- a/llm_normalizer/backend/src/services/investigationState.ts +++ b/llm_normalizer/backend/src/services/investigationState.ts @@ -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(); + + 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 { diff --git a/llm_normalizer/backend/src/services/normalizerService.ts b/llm_normalizer/backend/src/services/normalizerService.ts index a239dd3..4d361c3 100644 --- a/llm_normalizer/backend/src/services/normalizerService.ts +++ b/llm_normalizer/backend/src/services/normalizerService.ts @@ -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(); 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; + 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; + 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") { diff --git a/llm_normalizer/backend/src/types/assistant.ts b/llm_normalizer/backend/src/types/assistant.ts index ad1d2b3..bcb685e 100644 --- a/llm_normalizer/backend/src/types/assistant.ts +++ b/llm_normalizer/backend/src/types/assistant.ts @@ -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; diff --git a/llm_normalizer/backend/tests/assistantEndpoint.test.ts b/llm_normalizer/backend/tests/assistantEndpoint.test.ts index b7448db..cbb46ab 100644 --- a/llm_normalizer/backend/tests/assistantEndpoint.test.ts +++ b/llm_normalizer/backend/tests/assistantEndpoint.test.ts @@ -30,9 +30,18 @@ describe("assistant mode API", () => { expect(Array.isArray(response.body.debug?.retrieval_results)).toBe(true); expect(typeof response.body.debug?.temporal_guard_applied).toBe("boolean"); expect(typeof response.body.debug?.temporal_guard_outcome).toBe("string"); + expect(typeof response.body.debug?.temporal_alignment_status).toBe("string"); + expect(typeof response.body.debug?.temporal_guard_basis).toBe("string"); expect(response.body.debug?.domain_polarity_guard).toBeTruthy(); + expect(Array.isArray(response.body.debug?.raw_numeric_tokens)).toBe(true); + expect(Array.isArray(response.body.debug?.classified_numeric_tokens)).toBe(true); + expect(Array.isArray(response.body.debug?.rejected_as_non_accounts)).toBe(true); + expect(Array.isArray(response.body.debug?.resolved_account_anchors)).toBe(true); expect(response.body.debug?.evidence_admissibility_gate).toBeTruthy(); expect(response.body.debug?.grounded_answer_eligibility_guard).toBeTruthy(); + expect(typeof response.body.debug?.eligibility_time_basis).toBe("string"); + expect(typeof response.body.debug?.grounded_answer_eligibility_guard?.eligibility_time_basis).toBe("string"); + expect(typeof response.body.debug?.grounded_answer_eligibility_guard?.business_scope_passed).toBe("boolean"); expect(Array.isArray(response.body.conversation)).toBe(true); expect(response.body.conversation.length).toBe(2); }); diff --git a/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts b/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts index 311aff4..cf7597d 100644 --- a/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts +++ b/llm_normalizer/backend/tests/assistantRuntimeGuardsStage4Pack.test.ts @@ -149,7 +149,10 @@ describe("stage4 blocker-pack runtime guards", () => { }); expect(temporal.temporal_guard_applied).toBe(true); expect(temporal.temporal_guard_outcome).toBe("passed"); + expect(temporal.raw_time_scope).toBe("2023-07-06"); expect(temporal.resolved_time_anchor).toBe("2020-07-06"); + expect(temporal.temporal_alignment_status).toBe("corrected"); + expect(temporal.temporal_guard_basis).toBe("resolved_primary_period"); expect(temporal.normalized_anchor_drift_detected).toBe(true); expect(temporal.reason_codes).toContain("normalized_anchor_out_of_primary_window_overridden"); }); @@ -235,6 +238,25 @@ describe("stage4 blocker-pack runtime guards", () => { expect(units.some((item: any) => item.lifecycle_domain === "customer_settlement")).toBe(false); }); + it("cleans polluted account anchors from date/amount numerics", () => { + const guard = resolveDomainPolarityGuard({ + userMessage: "VAT 233.33 on 13 july 2020 and 15 july 2020, account 68.02 in company snapshot.", + focusDomainHint: "vat_document_register_book" + }); + + expect(guard.account_scope.some((item) => /^68(?:\.|$)/.test(String(item)))).toBe(true); + expect(guard.account_scope).not.toContain("13"); + expect(guard.account_scope).not.toContain("15"); + expect(guard.rejected_as_non_accounts).toContain("13"); + expect(guard.rejected_as_non_accounts).toContain("15"); + expect( + guard.classified_numeric_tokens.some((item) => item.token === "13" && item.classification === "date_token") + ).toBe(true); + expect( + guard.classified_numeric_tokens.some((item) => item.token === "33" && item.classification === "amount_token") + ).toBe(true); + }); + it("rejects inadmissible live evidence on zero matched_rows and wrong account/date", () => { const userMessage = "Почему РїРѕ поставщику РїРѕ счету 60 РІ июле 2020 С…РІРѕСЃС‚ РЅРµ закрыт?"; const temporal = resolveTemporalGuard({ @@ -301,8 +323,16 @@ describe("stage4 blocker-pack runtime guards", () => { const eligibility = evaluateGroundedAnswerEligibility({ temporal: { raw_time_anchor: "6 июля 2020", + raw_time_scope: "2023-07-06", resolved_time_anchor: "2020-07-06", + resolved_primary_period: { + from: "2020-07-06", + to: "2020-07-06", + granularity: "day" + }, + temporal_alignment_status: "corrected", temporal_resolution_source: "company_snapshot_july_day_lock", + temporal_guard_basis: "resolved_primary_period", temporal_guard_applied: true, temporal_guard_outcome: "failed_out_of_snapshot_window", primary_period_window: { @@ -310,6 +340,10 @@ describe("stage4 blocker-pack runtime guards", () => { to: "2020-07-06", granularity: "day" }, + allowed_context_window: null, + controlled_temporal_expansion_enabled: true, + context_expansion_reasons_allowed: ["prehistory", "carryover", "post_period_closure", "long_running_contract_context"], + normalized_anchor_drift_detected: true, reason_codes: ["normalized_anchor_out_of_snapshot_window"] }, polarity: { @@ -319,6 +353,15 @@ describe("stage4 blocker-pack runtime guards", () => { supplier_score: 3, customer_score: 0, account_scope: ["60"], + raw_numeric_tokens: ["6", "2020", "60"], + classified_numeric_tokens: [ + { + token: "60", + classification: "account_token" + } + ], + rejected_as_non_accounts: ["6", "2020"], + resolved_account_anchors: ["60"], rejected_problem_units: 0, rejected_evidence: 0, critical_contradiction: false, diff --git a/llm_normalizer/data/eval_cases/eval-54ASHode0i.report.json b/llm_normalizer/data/eval_cases/eval-54ASHode0i.report.json new file mode 100644 index 0000000..7998008 --- /dev/null +++ b/llm_normalizer/data/eval_cases/eval-54ASHode0i.report.json @@ -0,0 +1,112 @@ +{ + "run_id": "eval-54ASHode0i", + "timestamp": "2026-03-28T22:57:36.893Z", + "mode": "single-pass-strict", + "use_mock": true, + "prompt_version": "normalizer_v2_0_2", + "schema_version": "v2_0_2", + "dataset": { + "source": "inline_raw_questions", + "file": null, + "raw_questions_count": 2 + }, + "cases_total": 2, + "metrics": { + "schema_validation_pass_rate": 100, + "scope_detection_accuracy": null, + "scope_in_scope_rate": 100, + "multi_intent_detected_rate": 0, + "clarification_required_rate": 0, + "avg_fragments_per_message": 1, + "out_of_scope_fragment_rate": 0, + "routed_fragment_rate": 100, + "no_route_fragment_rate": 0, + "route_resolution_accuracy": null, + "no_route_precision": null, + "false_no_route_rate": null, + "execution_state_consistency_rate": 100, + "executable_with_soft_assumptions_rate": 100, + "soft_assumption_used_fragment_rate": 100, + "clarification_precision": null, + "clarification_recall": null, + "false_clarification_rate": null + }, + "budget": { + "requests_total": 0, + "retries_used": 0 + }, + "clarification_eval": { + "labeled_cases": 0, + "true_positive": 0, + "false_positive": 0, + "false_negative": 0 + }, + "route_eval": { + "labeled_cases": 0, + "correct_cases": 0, + "expected_routed_cases": 0, + "no_route_true_positive": 0, + "no_route_false_positive": 0 + }, + "scope_eval": { + "labeled_cases": 0, + "correct_cases": 0 + }, + "execution_state_eval": { + "checks_total": 2, + "checks_passed": 2 + }, + "route_distribution": { + "store_feature_risk": 1, + "hybrid_store_plus_live": 1 + }, + "fallback_distribution": { + "none": 2 + }, + "results": [ + { + "case_id": "BQ-001", + "raw_question": "Проверь счет 60 за июнь 2020", + "validation_passed": true, + "message_in_scope": true, + "scope_confidence": "high", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 1, + "out_of_scope_fragments": 0, + "unclear_fragments": 0, + "fallback_type": "none", + "predicted_route_status": "routed", + "expected_route_status": null, + "predicted_no_route_reason": null, + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 1, + "trace_id": "aAX-LWSzWz3Sbi", + "request_count_for_case": 0 + }, + { + "case_id": "BQ-002", + "raw_question": "Покажи риски по НДС и по закрытию", + "validation_passed": true, + "message_in_scope": true, + "scope_confidence": "high", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 1, + "out_of_scope_fragments": 0, + "unclear_fragments": 0, + "fallback_type": "none", + "predicted_route_status": "routed", + "expected_route_status": null, + "predicted_no_route_reason": null, + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 1, + "trace_id": "lYQ7-86_XPh040", + "request_count_for_case": 0 + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/eval-IxmMzClrcZ.report.json b/llm_normalizer/data/eval_cases/eval-IxmMzClrcZ.report.json new file mode 100644 index 0000000..fc96492 --- /dev/null +++ b/llm_normalizer/data/eval_cases/eval-IxmMzClrcZ.report.json @@ -0,0 +1,137 @@ +{ + "run_id": "eval-IxmMzClrcZ", + "timestamp": "2026-03-28T22:57:35.113Z", + "mode": "single-pass-strict", + "use_mock": true, + "prompt_version": "normalizer_v2_0_2", + "schema_version": "v2_0_2", + "dataset": { + "source": "inline_raw_questions", + "file": null, + "raw_questions_count": 3 + }, + "cases_total": 3, + "metrics": { + "schema_validation_pass_rate": 100, + "scope_detection_accuracy": null, + "scope_in_scope_rate": 33.33, + "multi_intent_detected_rate": 0, + "clarification_required_rate": 0, + "avg_fragments_per_message": 1, + "out_of_scope_fragment_rate": 33.33, + "routed_fragment_rate": 66.67, + "no_route_fragment_rate": 33.33, + "route_resolution_accuracy": null, + "no_route_precision": null, + "false_no_route_rate": null, + "execution_state_consistency_rate": 66.67, + "executable_with_soft_assumptions_rate": 100, + "soft_assumption_used_fragment_rate": 100, + "clarification_precision": null, + "clarification_recall": null, + "false_clarification_rate": null + }, + "budget": { + "requests_total": 0, + "retries_used": 0 + }, + "clarification_eval": { + "labeled_cases": 0, + "true_positive": 0, + "false_positive": 0, + "false_negative": 0 + }, + "route_eval": { + "labeled_cases": 0, + "correct_cases": 0, + "expected_routed_cases": 0, + "no_route_true_positive": 0, + "no_route_false_positive": 0 + }, + "scope_eval": { + "labeled_cases": 0, + "correct_cases": 0 + }, + "execution_state_eval": { + "checks_total": 3, + "checks_passed": 2 + }, + "route_distribution": { + "hybrid_store_plus_live": 1, + "no_route": 1, + "batch_refresh_then_store": 1 + }, + "fallback_distribution": { + "none": 1, + "out_of_scope": 1, + "clarification": 1 + }, + "results": [ + { + "case_id": "BQ-001", + "raw_question": "Проверь хвосты по поставщикам и разложи цепочку", + "validation_passed": true, + "message_in_scope": true, + "scope_confidence": "high", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 1, + "out_of_scope_fragments": 0, + "unclear_fragments": 0, + "fallback_type": "none", + "predicted_route_status": "routed", + "expected_route_status": null, + "predicted_no_route_reason": null, + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 1, + "trace_id": "o_KAYpvODknRQw", + "request_count_for_case": 0 + }, + { + "case_id": "BQ-002", + "raw_question": "Как вообще по ФСБУ", + "validation_passed": true, + "message_in_scope": false, + "scope_confidence": "low", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 0, + "out_of_scope_fragments": 1, + "unclear_fragments": 0, + "fallback_type": "out_of_scope", + "predicted_route_status": "no_route", + "expected_route_status": null, + "predicted_no_route_reason": "out_of_scope", + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 0, + "trace_id": "1Gte0dipzDpc0G", + "request_count_for_case": 0 + }, + { + "case_id": "BQ-003", + "raw_question": "Покажи топ рисков за июнь 2020", + "validation_passed": true, + "message_in_scope": false, + "scope_confidence": "low", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 0, + "out_of_scope_fragments": 0, + "unclear_fragments": 1, + "fallback_type": "clarification", + "predicted_route_status": "routed", + "expected_route_status": null, + "predicted_no_route_reason": null, + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 0, + "trace_id": "0waRprlgERL_Fh", + "request_count_for_case": 0 + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/eval-acMKx4LJYS.report.json b/llm_normalizer/data/eval_cases/eval-acMKx4LJYS.report.json new file mode 100644 index 0000000..d8f8038 --- /dev/null +++ b/llm_normalizer/data/eval_cases/eval-acMKx4LJYS.report.json @@ -0,0 +1,112 @@ +{ + "run_id": "eval-acMKx4LJYS", + "timestamp": "2026-03-28T22:57:36.649Z", + "mode": "single-pass-strict", + "use_mock": true, + "prompt_version": "normalizer_v2_0_2", + "schema_version": "v2_0_2", + "dataset": { + "source": "inline_raw_questions", + "file": null, + "raw_questions_count": 2 + }, + "cases_total": 2, + "metrics": { + "schema_validation_pass_rate": 100, + "scope_detection_accuracy": null, + "scope_in_scope_rate": 100, + "multi_intent_detected_rate": 0, + "clarification_required_rate": 0, + "avg_fragments_per_message": 1, + "out_of_scope_fragment_rate": 0, + "routed_fragment_rate": 100, + "no_route_fragment_rate": 0, + "route_resolution_accuracy": null, + "no_route_precision": null, + "false_no_route_rate": null, + "execution_state_consistency_rate": 100, + "executable_with_soft_assumptions_rate": 100, + "soft_assumption_used_fragment_rate": 100, + "clarification_precision": null, + "clarification_recall": null, + "false_clarification_rate": null + }, + "budget": { + "requests_total": 0, + "retries_used": 0 + }, + "clarification_eval": { + "labeled_cases": 0, + "true_positive": 0, + "false_positive": 0, + "false_negative": 0 + }, + "route_eval": { + "labeled_cases": 0, + "correct_cases": 0, + "expected_routed_cases": 0, + "no_route_true_positive": 0, + "no_route_false_positive": 0 + }, + "scope_eval": { + "labeled_cases": 0, + "correct_cases": 0 + }, + "execution_state_eval": { + "checks_total": 2, + "checks_passed": 2 + }, + "route_distribution": { + "store_feature_risk": 1, + "hybrid_store_plus_live": 1 + }, + "fallback_distribution": { + "none": 2 + }, + "results": [ + { + "case_id": "BQ-001", + "raw_question": "Проверь счет 60 за июнь 2020", + "validation_passed": true, + "message_in_scope": true, + "scope_confidence": "high", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 1, + "out_of_scope_fragments": 0, + "unclear_fragments": 0, + "fallback_type": "none", + "predicted_route_status": "routed", + "expected_route_status": null, + "predicted_no_route_reason": null, + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 1, + "trace_id": "M4nUlAGhhljTgQ", + "request_count_for_case": 0 + }, + { + "case_id": "BQ-002", + "raw_question": "Покажи риски по счету 97", + "validation_passed": true, + "message_in_scope": true, + "scope_confidence": "high", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 1, + "out_of_scope_fragments": 0, + "unclear_fragments": 0, + "fallback_type": "none", + "predicted_route_status": "routed", + "expected_route_status": null, + "predicted_no_route_reason": null, + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 1, + "trace_id": "NGJPhsQCA8TnYe", + "request_count_for_case": 0 + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_1_Live_Alignment_Fix_Claim_Bound_Runtime.zip b/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_1_Live_Alignment_Fix_Claim_Bound_Runtime.zip new file mode 100644 index 0000000..034ed15 Binary files /dev/null and b/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_1_Live_Alignment_Fix_Claim_Bound_Runtime.zip differ diff --git a/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_Claim_Bound_Evidence_Acquisition_P0.zip b/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_Claim_Bound_Evidence_Acquisition_P0.zip deleted file mode 100644 index 17ae5ab..0000000 Binary files a/llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_Claim_Bound_Evidence_Acquisition_P0.zip and /dev/null differ