From d461cedf3597b89c14e4fed2f24e37235d4ca86c Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 29 Mar 2026 08:33:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=204=20/=20=D0=92=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B0=2019.1=20=20=D0=B2=D1=8B=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B6=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BB=D0=BE=D1=8F=20=D0=B0=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D0=B4=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=B1=D0=B0=D0=B7=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/assistantDataLayer.js | 4 +- .../dist/services/assistantRuntimeGuards.js | 242 +++++++- .../backend/dist/services/assistantService.js | 162 +++++- .../dist/services/investigationState.js | 140 ++++- .../dist/services/normalizerService.js | 151 ++++- .../scripts/wave19_1LiveAlignmentPack.js | 539 ++++++++++++++++++ .../src/services/assistantDataLayer.ts | 4 +- .../src/services/assistantRuntimeGuards.ts | 283 ++++++++- .../backend/src/services/assistantService.ts | 162 +++++- .../src/services/investigationState.ts | 156 ++++- .../backend/src/services/normalizerService.ts | 165 +++++- llm_normalizer/backend/src/types/assistant.ts | 34 ++ .../backend/tests/assistantEndpoint.test.ts | 9 + .../assistantRuntimeGuardsStage4Pack.test.ts | 43 ++ .../eval_cases/eval-54ASHode0i.report.json | 112 ++++ .../eval_cases/eval-IxmMzClrcZ.report.json | 137 +++++ .../eval_cases/eval-acMKx4LJYS.report.json | 112 ++++ ...Live_Alignment_Fix_Claim_Bound_Runtime.zip | Bin 0 -> 190117 bytes ...19_Claim_Bound_Evidence_Acquisition_P0.zip | Bin 3719789 -> 0 bytes 19 files changed, 2389 insertions(+), 66 deletions(-) create mode 100644 llm_normalizer/backend/scripts/wave19_1LiveAlignmentPack.js create mode 100644 llm_normalizer/data/eval_cases/eval-54ASHode0i.report.json create mode 100644 llm_normalizer/data/eval_cases/eval-IxmMzClrcZ.report.json create mode 100644 llm_normalizer/data/eval_cases/eval-acMKx4LJYS.report.json create mode 100644 llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_1_Live_Alignment_Fix_Claim_Bound_Runtime.zip delete mode 100644 llm_normalizer/docs/runs/2026-03-29_Stage_04_Wave_19_Claim_Bound_Evidence_Acquisition_P0.zip 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 0000000000000000000000000000000000000000..034ed15ad712ac2aad6621a537ca386fc8c74ba1 GIT binary patch literal 190117 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