АДРЕСНЫЙ РЕЖИМ - fix(address): закрыт S019 predecompose-anchor drift; nightly 102/102+25/25 PASS; docs synced

This commit is contained in:
2026-04-08 11:15:33 +03:00
parent a5dee965c2
commit eec5ef4fc0
210 changed files with 325486 additions and 100 deletions
@@ -24,7 +24,7 @@ const MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for
const MONTH_PERIOD_NUMERIC_YEAR_MONTH_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})[.\/-](0?[1-9]|1[0-2])(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NAME_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu;
const DOC_SIGNAL_PATTERN = "(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|doki|dokument(?:y|ov|am|a)?)";
const DOC_SIGNAL_PATTERN = "(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|docy|doci|doki|dokument(?:y|ov|am|a)?)";
function textMojibakeScore(value) {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
@@ -330,7 +330,12 @@ function cleanupAnchorValue(value) {
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
}
return normalized
const trailingYearTailPattern = /\s+(?:year\s+)?(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?:\s+|$)[\s\S]*$/iu;
let cleaned = normalized;
if (trailingYearTailPattern.test(normalized)) {
cleaned = normalized.replace(trailingYearTailPattern, "").trim();
}
return cleaned
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim();
@@ -340,6 +345,7 @@ function cleanupContractAnchorValue(value) {
if (!normalized) {
return normalized;
}
normalized = normalized.replace(/^(?:номер|number|no\.?|n)\s+/iu, "").trim();
const yearQualifierTailPattern = /\s+(?:(?:за|for)\s+)?(?:year|г(?:од|ода)?\.?)\s*(?:20\d{2}|\d{2})(?:\s+|$)[\s\S]*$/iu;
if (yearQualifierTailPattern.test(normalized)) {
normalized = normalized.replace(yearQualifierTailPattern, "").trim();
@@ -348,6 +354,13 @@ function cleanupContractAnchorValue(value) {
if (trailingSeparatedYearPattern.test(normalized) && /[\/\\-]/.test(normalized)) {
normalized = normalized.replace(trailingSeparatedYearPattern, "").trim();
}
// Contract anchors in noisy rewrites can swallow trailing explanation text:
// "19/15 в банковском учете", "19/15 выведите связанные документы".
// When a leading contract-like token is present, keep it as the anchor.
const leadingContractTokenMatch = normalized.match(/^([a-zа-я0-9]{1,20}[\/_-][a-zа-я0-9]{1,20}(?:[\/_-][a-zа-я0-9]{1,20})?)(?:\s+.*)?$/iu);
if (leadingContractTokenMatch?.[1]) {
normalized = String(leadingContractTokenMatch[1]).trim();
}
return normalized;
}
function hasAllTimeHint(text) {
@@ -387,6 +400,26 @@ function extractLooseByAnchorValue(text) {
"дате",
"периоду",
"период",
"есть",
"же",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"неуказанному",
"указанному",
"указанный",
"указанная",
"указанное",
"объекту",
"объект",
"документам",
"документами",
"докам",
@@ -411,7 +444,9 @@ function extractLooseByAnchorValue(text) {
"ней",
"ним",
"этому",
"тому"
"тому",
"этомуже",
"томуже"
]);
if (stopWords.has(lowered)) {
return undefined;
@@ -456,9 +491,21 @@ function isLikelyCounterpartyToken(rawToken) {
"за",
"с",
"по",
"у",
"на",
"и",
"или",
"какие",
"какой",
"какая",
"какое",
"каких",
"какому",
"какую",
"чьи",
"чья",
"чей",
"чью",
"док",
"доки",
"документ",
@@ -499,8 +546,28 @@ function isLikelyCounterpartyToken(rawToken) {
"епта",
"нах",
"нахуй",
"есть",
"же",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"указанному",
"указанный",
"указанная",
"указанное",
"объекту",
"объект",
"покеж",
"покажи",
"скажи",
"показать",
"выведи",
"show",
@@ -519,12 +586,20 @@ function isLikelyCounterpartyToken(rawToken) {
"ним",
"этому",
"тому",
"этомуже",
"томуже",
"vse",
"all",
"kakie",
"kakoi",
"est",
"za",
"po",
"na",
"s",
"poka",
"pokaji",
"skazhi",
"pokazhi",
"pokazh",
"pokezh",
@@ -544,6 +619,63 @@ function isLikelyCounterpartyToken(rawToken) {
]);
return !stopWords.has(lowered);
}
function isLowQualityCounterpartyAnchorValue(rawValue) {
const value = String(rawValue ?? "")
.trim()
.toLowerCase()
.replace(/ё/g, "е");
if (!value) {
return true;
}
const tokens = value
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
.filter(Boolean);
if (tokens.length === 0) {
return true;
}
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0;
}
function isLowQualityContractAnchorValue(rawValue) {
const value = String(rawValue ?? "")
.trim()
.toLowerCase()
.replace(/ё/g, "е");
if (!value) {
return true;
}
if (/\b[a-zа-я0-9]{1,20}[\/_-][a-zа-я0-9]{1,20}(?:[\/_-][a-zа-я0-9]{1,20})?\b/iu.test(value)) {
return false;
}
if (!/\d/.test(value)) {
return true;
}
const tokens = value
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
.filter(Boolean);
if (tokens.length === 0) {
return true;
}
const lowQualityTokens = new Set([
"за",
"же",
"тот",
"тотже",
"этот",
"этому",
"этомуже",
"указанный",
"указанному",
"период",
"периоду",
"договор",
"договору"
]);
const meaningfulTokens = tokens.filter((token) => !lowQualityTokens.has(token));
return meaningfulTokens.length === 0;
}
function hasDocsOrBankSignal(text) {
const lowered = String(text ?? "").toLowerCase();
return new RegExp(`(?:${DOC_SIGNAL_PATTERN}|банк|выписк|списан|поступлен|платеж|платёж|оплат|transactions?|bank\\s+ops|bank\\s+operations?|payment|payments?|platezh|oplata)`, "iu").test(lowered);
@@ -892,6 +1024,14 @@ function extractAddressFilters(userMessage, intent) {
if (filters.contract && filters.contract.length < 2) {
warnings.push("contract_filter_too_short");
}
if (filters.counterparty && isLowQualityCounterpartyAnchorValue(filters.counterparty)) {
delete filters.counterparty;
warnings.push("counterparty_anchor_dropped_low_quality");
}
if (filters.contract && isLowQualityContractAnchorValue(filters.contract)) {
delete filters.contract;
warnings.push("contract_anchor_dropped_low_quality");
}
const required = requiredFiltersByIntent(intent);
const missingRequiredFilters = required.filter((key) => {
const value = filters[key];
@@ -84,6 +84,9 @@ const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
"доки",
"доки по",
"док по",
"doki",
"docy",
"doci",
"по контрагент"
];
const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
@@ -444,6 +447,21 @@ function hasPeriodCoverageProfileSignal(text) {
if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) {
return true;
}
if (/(?:за\s+какие\s+год[а-яё]*).*(?:баз[аы].*жив|период|данн)/iu.test(text)) {
return true;
}
if (/(?:какой\s+год[а-яё]*).*(?:по\s+док|докам|документам)/iu.test(text)) {
return true;
}
if (/(?:какой\s+месяц[а-яё]*).*(?:пик|по\s+операц)/iu.test(text)) {
return true;
}
if (/(?:месяц[\s-]*пик).*(?:операц|ops?|operation)/iu.test(text)) {
return true;
}
if (/(?:top\s*year|top\s*month|years?\/top\s*year|years?\s*top\s*year)/iu.test(text)) {
return true;
}
if (/(?:за\s+какие\s+год[а-яё]*\s+в\s+баз[еы]\s+есть\s+данн)/iu.test(text)) {
return true;
}
@@ -464,6 +482,12 @@ function hasDocumentTypeAndAccountSectionProfileSignal(text) {
if (hasAny(text, DOCUMENT_TYPE_AND_ACCOUNT_SECTION_PROFILE_HINTS)) {
return true;
}
if (/(?:каких?\s+док(?:ов|и)?).*(?:больше\s+всего|чаще\s+всего|крут)/iu.test(text)) {
return true;
}
if (/(?:сводк[ауи].*тип[а-яё]*\s+док(?:умент|ов|и)?).*(?:дол[ья]|объем|объ[её]м)/iu.test(text)) {
return true;
}
if (/(?:какие\s+тип[аы]\s+док(?:умент|ов|и)?\s+(?:использ|чаще|больш))/iu.test(text)) {
return true;
}
@@ -552,6 +576,8 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
/(?:кто\s+нам\s+(?:больше|чаще)|кто\s+платит)/iu.test(text);
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text);
const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(text);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(text);
const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksValue;
@@ -570,6 +596,9 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
if (!hasFuzzySupplierLexeme && asksIncomingFlow && asksRankOrTop) {
return true;
}
if (!hasFuzzySupplierLexeme && asksDealBudgetRanking) {
return true;
}
return false;
}
function hasSupplierPayoutsProfileSignal(text) {
@@ -616,6 +645,10 @@ function hasContractListByCounterpartySignal(text) {
if (!hasContractLexeme) {
return false;
}
// If user explicitly asks for documents, keep routing in document-by-contract/counterparty lane.
if (hasDocumentSignal(text)) {
return false;
}
if (hasContractUsageOverviewSignal(text) || hasOpenContractsListSignal(text)) {
return false;
}
@@ -637,7 +670,7 @@ function hasContractListByCounterpartySignal(text) {
}
function hasDocumentsByAccountDrilldownSignal(text) {
const hasAccountLexeme = hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text);
const hasDocLexeme = /(?:документ|док(?:и|ам|ах|ов|а)?|docs?|documents?)/iu.test(text);
const hasDocLexeme = /(?:документ|док(?:и|ам|ах|ов|а)?|docs?|documents?|doki|docy|doci)/iu.test(text);
const hasDrilldownVerb = /(?:раскрой|раскры|разлож|разверн|документами|по\s+документ)/iu.test(text);
const hasSameDate = /(?:на\s+ту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date)/iu.test(text);
return hasAccountLexeme && hasDocLexeme && (hasDrilldownVerb || hasSameDate);
@@ -725,6 +758,25 @@ function isLikelyCounterpartyToken(rawToken) {
"пж",
"пжлст",
"пожалуйста",
"есть",
"же",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"указанному",
"указанный",
"указанная",
"указанное",
"объекту",
"объект",
"бля",
"блять",
"епт",
@@ -737,7 +789,9 @@ function isLikelyCounterpartyToken(rawToken) {
"связанных",
"связанному",
"related",
"linked"
"linked",
"этомуже",
"томуже"
]);
return !stopWords.has(token);
}
@@ -865,18 +919,18 @@ function hasLooseByAnchorMention(text) {
return !stopWords.has(token);
}
function hasImplicitCounterpartyAnchorAroundDocs(text) {
const beforeDocsMatch = text.match(/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu);
const beforeDocsMatch = text.match(/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?|doki|docy|doci)(?=[\s,.;:!?)]|$)/iu);
if (beforeDocsMatch && isLikelyCounterpartyToken(String(beforeDocsMatch[1] ?? ""))) {
return true;
}
const afterDocsMatch = text.match(/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
const afterDocsMatch = text.match(/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?|doki|docy|doci)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu);
if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) {
return true;
}
return false;
}
function hasDocsOrBankSignal(text) {
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text);
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|doki|docy|doci|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text);
}
function hasBankOperationSignal(text) {
return hasAny(text, BANK_OPERATION_CORE_HINTS) || hasAny(text, BANK_OPERATIONS_BY_COUNTERPARTY_HINTS) || hasAny(text, BANK_OPERATIONS_BY_CONTRACT_HINTS);
@@ -885,6 +939,9 @@ function hasDocumentSignal(text) {
return (text.includes("док") ||
text.includes("доки") ||
text.includes("документ") ||
text.includes("doki") ||
text.includes("docy") ||
text.includes("doci") ||
text.includes("docs") ||
text.includes("documents"));
}
@@ -116,6 +116,27 @@ const DEEP_REASONING_TOKENS = [
"ошибк"
];
function hasManagementProfileSignal(text) {
if (/(?:за\s+какие\s+год[а-яё]*).*(?:баз[аы].*жив|период|данн)/iu.test(text)) {
return true;
}
if (/(?:какой\s+год[а-яё]*).*(?:по\s+док|докам|документам)/iu.test(text)) {
return true;
}
if (/(?:какой\s+месяц[а-яё]*).*(?:пик|по\s+операц)/iu.test(text)) {
return true;
}
if (/(?:месяц[\s-]*пик).*(?:операц|ops?|operation)/iu.test(text)) {
return true;
}
if (/(?:top\s*year|top\s*month|years?\/top\s*year|years?\s*top\s*year)/iu.test(text)) {
return true;
}
if (/(?:каких?\s+док(?:ов|и)?).*(?:больше\s+всего|чаще\s+всего|крут)/iu.test(text)) {
return true;
}
if (/(?:сводк[ауи].*тип[а-яё]*\s+док(?:умент|ов|и)?).*(?:дол[ья]|объем|объ[её]м)/iu.test(text)) {
return true;
}
if (/(?:за\s+какие\s+год[а-яё]*\s+в\s+баз[еы]\s+есть\s+данн)/iu.test(text)) {
return true;
}
@@ -28,7 +28,7 @@ function hasOpenItemsHint(text) {
return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? ""));
}
function hasDocumentSignal(text) {
return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?)/iu.test(String(text ?? ""));
return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? ""));
}
function hasBankSignal(text) {
return /(?:банк|банков|операц|опер|выписк|платеж|платёж|оплат|списан|поступлен|движени|bank|payment|payments|transaction|transactions|statement|wire)/iu.test(String(text ?? ""));
@@ -87,6 +87,116 @@ function hasExplicitLooseByAnchorToken(text) {
]);
return !pronounTokens.has(token) && !genericTokens.has(token);
}
const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"есть",
"же",
"что",
"все",
"всё",
"год",
"года",
"году",
"контрагентам",
"предоставьте",
"получить",
"сверка",
"теперь",
"сейчас",
"этому",
"этомуже",
"тому",
"томуже",
"нему",
"ней",
"ним",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"указанному",
"указанный",
"указанная",
"указанное",
"объект",
"объекту",
"период",
"периоду",
"указанныйпериод",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному"
]);
const FOLLOWUP_LOW_QUALITY_CONTRACT_TOKENS = new Set([
"за",
"же",
"это",
"указанный",
"указанному",
"период",
"периоду",
"тот",
"тотже",
"этот",
"этому",
"этомуже",
"договор",
"договору",
"номер"
]);
function normalizeAnchorToken(value) {
return String(value ?? "").trim().toLowerCase().replace(/ё/g, "е");
}
function hasContractLikeToken(value) {
return /\b[a-zа-я0-9]{1,20}[\/_-][a-zа-я0-9]{1,20}(?:[\/_-][a-zа-я0-9]{1,20})?\b/iu.test(String(value ?? ""));
}
function isLowQualityCounterpartyAnchor(value) {
const normalized = normalizeAnchorToken(value ?? "");
if (!normalized) {
return true;
}
const tokens = normalized
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
.filter(Boolean);
if (tokens.length === 0) {
return true;
}
const meaningful = tokens.filter((token) => {
if (token.length < 2) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
return !FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS.has(token);
});
return meaningful.length === 0;
}
function isLowQualityContractAnchor(value) {
const normalized = normalizeAnchorToken(value ?? "");
if (!normalized) {
return true;
}
if (hasContractLikeToken(normalized)) {
return false;
}
if (!/\d/.test(normalized)) {
return true;
}
const tokens = normalized
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
.filter(Boolean);
if (tokens.length === 0) {
return true;
}
const meaningful = tokens.filter((token) => !FOLLOWUP_LOW_QUALITY_CONTRACT_TOKENS.has(token));
return meaningful.length === 0;
}
function mapCounterpartyIntentToContractIntent(intent) {
if (intent === "list_documents_by_counterparty") {
return "list_documents_by_contract";
@@ -136,22 +246,26 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
if (intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty") {
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty = previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
const inheritedCounterparty = previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
const currentCounterparty = toNonEmptyString(merged.counterparty);
const shouldInheritCounterparty = !currentCounterparty ||
(Boolean(inheritedCounterparty) &&
isLowQualityCounterpartyAnchor(currentCounterparty) &&
!isLowQualityCounterpartyAnchor(inheritedCounterparty));
if (inheritedCounterparty && shouldInheritCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
}
}
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
if (!toNonEmptyString(merged.contract)) {
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
if (inheritedContract) {
merged.contract = inheritedContract;
reasons.push("contract_from_followup_context");
}
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract = !currentContract ||
(Boolean(inheritedContract) && isLowQualityContractAnchor(currentContract) && !isLowQualityContractAnchor(inheritedContract));
if (inheritedContract && shouldInheritContract) {
merged.contract = inheritedContract;
reasons.push(currentContract ? "contract_replaced_from_followup_context" : "contract_from_followup_context");
}
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
@@ -171,20 +285,24 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
}
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
if (!toNonEmptyString(merged.contract)) {
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
if (inheritedContract) {
merged.contract = inheritedContract;
reasons.push("contract_from_followup_context");
}
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract = !currentContract ||
(Boolean(inheritedContract) && isLowQualityContractAnchor(currentContract) && !isLowQualityContractAnchor(inheritedContract));
if (inheritedContract && shouldInheritContract) {
merged.contract = inheritedContract;
reasons.push(currentContract ? "contract_replaced_from_followup_context" : "contract_from_followup_context");
}
if (!toNonEmptyString(merged.counterparty)) {
const inheritedCounterparty = previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
if (inheritedCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push("counterparty_from_followup_context");
}
const inheritedCounterparty = previousCounterparty ??
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
const currentCounterparty = toNonEmptyString(merged.counterparty);
const shouldInheritCounterparty = !currentCounterparty ||
(Boolean(inheritedCounterparty) &&
isLowQualityCounterpartyAnchor(currentCounterparty) &&
!isLowQualityCounterpartyAnchor(inheritedCounterparty));
if (inheritedCounterparty && shouldInheritCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
}
}
if (allTimeRequested) {
@@ -257,7 +375,7 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
detectedIntent.intent === "bank_operations_by_counterparty" ||
detectedIntent.intent === "account_balance_snapshot")) {
const preferDocumentsForming = hasDocumentSignal(normalizedMessage) &&
/(?:раскрой|раскры|формир|документами|по\s+документ)/iu.test(normalizedMessage);
/(?:раскрой|раскры|формир|документами)/iu.test(normalizedMessage);
return {
intent: preferDocumentsForming ? "documents_forming_balance" : "account_balance_snapshot",
confidence: "low",
@@ -265,6 +383,22 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
};
}
if (hasPreviousContract) {
if (detectedIntent.intent === "list_contracts_by_counterparty") {
if (hasBankSignal(normalizedMessage)) {
return {
intent: "bank_operations_by_contract",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_contract_followup_context"]
};
}
if (hasDocumentSignal(normalizedMessage)) {
return {
intent: "list_documents_by_contract",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_contract_followup_context"]
};
}
}
if (detectedIntent.intent === "unknown") {
if (hasBankSignal(normalizedMessage)) {
return {
+270 -1
View File
@@ -56,6 +56,7 @@ const assistantClaimBoundEvidence_1 = __importStar(require("./assistantClaimBoun
const addressQueryService_1 = __importStar(require("./addressQueryService"));
const addressQueryClassifier_1 = __importStar(require("./addressQueryClassifier"));
const addressIntentResolver_1 = __importStar(require("./addressIntentResolver"));
const addressFilterExtractor_1 = __importStar(require("./addressFilterExtractor"));
const predecomposeContract_1 = __importStar(require("./address_runtime/predecomposeContract"));
const iconv_lite_1 = __importDefault(require("iconv-lite"));
function retrievalSummaryForRoute(route) {
@@ -1870,9 +1871,11 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"please",
"покеж",
"покажи",
"скажи",
"показать",
"show",
"list",
"skazhi",
"выведи",
"что",
"чо",
@@ -1980,7 +1983,7 @@ const ADDRESS_MONTH_ALIAS_MAP = {
december: "12",
dec: "12"
};
const ADDRESS_DOCS_SIGNAL_PATTERN = /(?:док|доки|документ|документы|документов|docs?|documents?|bank|выписк|плат[её]ж|оплат|поступлен|списан|операц|опер|transaction)/i;
const ADDRESS_DOCS_SIGNAL_PATTERN = /(?:док|доки|документ|документы|документов|docs?|documents?|doki|docy|doci|bank|выписк|плат[её]ж|оплат|поступлен|списан|операц|опер|transaction)/i;
const ADDRESS_BANK_SIGNAL_PATTERN = /(?:bank|банк|банков|выписк|плат[её]ж|оплат|поступлен|списан|операц|опер|расчетн|транзак)/i;
const ADDRESS_CONTRACT_SIGNAL_PATTERN = /(?:договор(?:а|у|ом|е)?|(?:^|[^\p{L}\p{N}_])(?:дог\.?|[dд][oо][gг]\.?|dog\.?)(?=$|[^\p{L}\p{N}_])|contract|dogovor)/iu;
const ADDRESS_BALANCE_SIGNAL_PATTERN = /(?:остат|сальдо|баланс|взаиморасч|долг|saldo|balance)/i;
@@ -2025,6 +2028,7 @@ function sanitizeAddressMessageForFallback(userMessage) {
.replace(/(^|[^\p{L}\p{N}_])опер(?:аци[яиюе]|ы|)?(?=$|[^\p{L}\p{N}_])/giu, "$1операции")
.replace(/(^|[^\p{L}\p{N}_])дог\.?(?=$|[^\p{L}\p{N}_])/giu, "$1договор")
.replace(/(^|[^\p{L}\p{N}_])dog\.?(?=$|[^\p{L}\p{N}_])/giu, "$1contract")
.replace(/\bdoc(?:y|i)\b/giu, "доки")
.replace(/\bdok(?:i|y)?\b/giu, "доки")
.replace(/\bdocuments?\b/giu, "документы")
.replace(/\bdocs?\b/giu, "документы")
@@ -2422,6 +2426,35 @@ function findLastAddressAssistantDebug(items) {
}
return null;
}
function findRecentAddressFilterValue(items, key) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug) {
continue;
}
const debug = item.debug;
if (!(debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1")) {
continue;
}
const directFilterValue = readAddressFilterString(debug, key);
if (directFilterValue) {
return directFilterValue;
}
if (key === "contract" && String(debug.anchor_type ?? "").trim() === "contract") {
const anchorValue = toNonEmptyString(debug.anchor_value_resolved) ?? toNonEmptyString(debug.anchor_value_raw);
if (anchorValue) {
return anchorValue;
}
}
if (key === "counterparty" && String(debug.anchor_type ?? "").trim() === "counterparty") {
const anchorValue = toNonEmptyString(debug.anchor_value_resolved) ?? toNonEmptyString(debug.anchor_value_raw);
if (anchorValue) {
return anchorValue;
}
}
}
return null;
}
function hasAddressFollowupContextSignal(userMessage) {
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const text = compactWhitespace(repaired.toLowerCase());
@@ -2477,6 +2510,18 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
? { ...previousFiltersRaw }
: {};
if (!toNonEmptyString(previousFilters.contract)) {
const historicalContract = findRecentAddressFilterValue(items, "contract");
if (historicalContract) {
previousFilters.contract = historicalContract;
}
}
if (!toNonEmptyString(previousFilters.counterparty)) {
const historicalCounterparty = findRecentAddressFilterValue(items, "counterparty");
if (historicalCounterparty) {
previousFilters.counterparty = historicalCounterparty;
}
}
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
return null;
}
@@ -2638,6 +2683,169 @@ function extractAddressQuestionFromRawNormalizerOutput(rawModelOutput) {
}
return null;
}
const ADDRESS_PREDECOMPOSE_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"есть",
"же",
"что",
"все",
"всё",
"год",
"года",
"году",
"контрагентам",
"предоставьте",
"получить",
"скажи",
"skazhi",
"покажи",
"выведи",
"сверка",
"теперь",
"сейчас",
"этому",
"этомуже",
"тому",
"томуже",
"нему",
"ней",
"ним",
"неуказанному",
"неуказанный",
"неуказанная",
"неуказанное",
"указанному",
"указанный",
"указанная",
"указанное",
"объект",
"объекту",
"период",
"периоду",
"сводные",
"сводный",
"сводная",
"сводную",
"сводном",
"сводного",
"сводному"
]);
const ADDRESS_PREDECOMPOSE_LOW_QUALITY_CONTRACT_TOKENS = new Set([
"за",
"же",
"это",
"указанный",
"указанному",
"период",
"периоду",
"тот",
"тотже",
"этот",
"этому",
"этомуже",
"договор",
"договору",
"номер"
]);
function normalizePredecomposeAnchorTokens(value) {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/ё/g, "е")
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
.filter(Boolean);
}
function isLowQualityPredecomposeCounterpartyAnchor(value) {
const tokens = normalizePredecomposeAnchorTokens(value);
if (tokens.length === 0) {
return true;
}
const meaningful = tokens.filter((token) => {
if (token.length < 2) {
return false;
}
if (/^(?:19|20)\d{2}$/.test(token)) {
return false;
}
return !ADDRESS_PREDECOMPOSE_LOW_QUALITY_COUNTERPARTY_TOKENS.has(token);
});
return meaningful.length === 0;
}
function isLowQualityPredecomposeContractAnchor(value) {
const normalized = String(value ?? "").trim().toLowerCase().replace(/ё/g, "е");
if (!normalized) {
return true;
}
if (/\b[a-zа-я0-9]{1,20}[\/_-][a-zа-я0-9]{1,20}(?:[\/_-][a-zа-я0-9]{1,20})?\b/iu.test(normalized)) {
return false;
}
if (!/\d/.test(normalized)) {
return true;
}
const tokens = normalizePredecomposeAnchorTokens(normalized);
if (tokens.length === 0) {
return true;
}
const meaningful = tokens.filter((token) => !ADDRESS_PREDECOMPOSE_LOW_QUALITY_CONTRACT_TOKENS.has(token));
return meaningful.length === 0;
}
function resolveRequiredAnchorTypeForIntent(intent) {
if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || intent === "list_contracts_by_counterparty") {
return "counterparty";
}
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
return "contract";
}
return null;
}
function evaluateAddressAnchorQuality(message) {
const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(String(message ?? ""));
const intent = intentResolution.intent;
const anchorType = resolveRequiredAnchorTypeForIntent(intent);
if (!anchorType) {
return {
intent,
anchorType: null,
anchorValue: null,
quality: 0
};
}
const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent);
const anchorValue = anchorType === "counterparty"
? toNonEmptyString(extracted?.extracted_filters?.counterparty)
: toNonEmptyString(extracted?.extracted_filters?.contract);
if (!anchorValue) {
return {
intent,
anchorType,
anchorValue: null,
quality: 0
};
}
const lowQuality = anchorType === "counterparty"
? isLowQualityPredecomposeCounterpartyAnchor(anchorValue)
: isLowQualityPredecomposeContractAnchor(anchorValue);
return {
intent,
anchorType,
anchorValue,
quality: lowQuality ? 1 : 2
};
}
function hasPredecomposeExplicitDrilldownSignal(text) {
const source = String(text ?? "");
return ADDRESS_DOCS_SIGNAL_PATTERN.test(source) || ADDRESS_BANK_SIGNAL_PATTERN.test(source) || ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
}
function hasSameDateAccountFollowupSignalForPredecompose(text) {
const source = String(text ?? "");
const hasSameDate = /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu.test(source);
if (!hasSameDate) {
return false;
}
return (/(?:сч[её]т|счет|account)\D{0,12}\d{2}(?:[.,]\d{1,2})?/iu.test(source) ||
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(source) ||
/\b\d{2}(?:[.,]\d{1,2})\b/u.test(source));
}
function attachAddressPredecomposeContract(meta, sourceMessage) {
const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? "");
return {
@@ -2754,6 +2962,67 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
sanitizedUserMessage
}, userMessage);
}
const sourceHasExplicitDrilldownSignal = hasPredecomposeExplicitDrilldownSignal(repairedSourceMessage || userMessage);
const candidateHasExplicitDrilldownSignal = hasPredecomposeExplicitDrilldownSignal(candidate);
const sourceLooksLikeSameDateAccountFollowup = hasSameDateAccountFollowupSignalForPredecompose(repairedSourceMessage || userMessage);
const candidateInjectsDrilldownIntent = candidateIntentResolution.intent === "documents_forming_balance";
if (sourceLooksLikeSameDateAccountFollowup &&
!sourceHasExplicitDrilldownSignal &&
candidateHasExplicitDrilldownSignal &&
candidateInjectsDrilldownIntent &&
sourceIntentResolution.intent !== "documents_forming_balance") {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_followup_intent_injection",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType &&
sourceAnchorQuality.quality >= 2 &&
candidateAnchorQuality.quality < sourceAnchorQuality.quality;
if (anchorDegradedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_degradation",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
if (fallbackCandidate) {
const fallbackAnchorQuality = evaluateAddressAnchorQuality(String(fallbackCandidate.candidate ?? ""));
const fallbackPreferredForAnchorSafety = sameIntentForAnchorSafety &&
fallbackAnchorQuality.intent === sourceAnchorQuality.intent &&
fallbackAnchorQuality.quality >= 2 &&
fallbackAnchorQuality.quality > candidateAnchorQuality.quality;
if (fallbackPreferredForAnchorSafety) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: true,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_preferred_over_llm_candidate_anchor_quality",
fallbackRuleHit: fallbackCandidate.rule,
sanitizedUserMessage
}, userMessage);
}
}
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const candidateCompact = compactWhitespace(candidate.toLowerCase());
const applied = sourceCompact !== candidateCompact;