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