АДРЕСНЫЙ РЕЖИМ - fix(address): закрыт S019 predecompose-anchor drift; nightly 102/102+25/25 PASS; docs synced
This commit is contained in:
+143
-3
@@ -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
@@ -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;
|
||||
|
||||
@@ -32,7 +32,7 @@ const MONTH_PERIOD_NAME_PATTERN =
|
||||
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)?)";
|
||||
"(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|docy|doci|doki|dokument(?:y|ov|am|a)?)";
|
||||
|
||||
function textMojibakeScore(value: string): number {
|
||||
const source = String(value ?? "");
|
||||
@@ -362,7 +362,14 @@ function cleanupAnchorValue(value: string): string {
|
||||
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();
|
||||
@@ -374,6 +381,8 @@ function cleanupContractAnchorValue(value: string): string {
|
||||
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)) {
|
||||
@@ -385,6 +394,16 @@ function cleanupContractAnchorValue(value: string): string {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -428,6 +447,26 @@ function extractLooseByAnchorValue(text: string): string | undefined {
|
||||
"дате",
|
||||
"периоду",
|
||||
"период",
|
||||
"есть",
|
||||
"же",
|
||||
"сводные",
|
||||
"сводный",
|
||||
"сводная",
|
||||
"сводную",
|
||||
"сводном",
|
||||
"сводного",
|
||||
"сводному",
|
||||
"неуказанному",
|
||||
"неуказанный",
|
||||
"неуказанная",
|
||||
"неуказанное",
|
||||
"неуказанному",
|
||||
"указанному",
|
||||
"указанный",
|
||||
"указанная",
|
||||
"указанное",
|
||||
"объекту",
|
||||
"объект",
|
||||
"документам",
|
||||
"документами",
|
||||
"докам",
|
||||
@@ -452,7 +491,9 @@ function extractLooseByAnchorValue(text: string): string | undefined {
|
||||
"ней",
|
||||
"ним",
|
||||
"этому",
|
||||
"тому"
|
||||
"тому",
|
||||
"этомуже",
|
||||
"томуже"
|
||||
]);
|
||||
if (stopWords.has(lowered)) {
|
||||
return undefined;
|
||||
@@ -500,9 +541,21 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
"за",
|
||||
"с",
|
||||
"по",
|
||||
"у",
|
||||
"на",
|
||||
"и",
|
||||
"или",
|
||||
"какие",
|
||||
"какой",
|
||||
"какая",
|
||||
"какое",
|
||||
"каких",
|
||||
"какому",
|
||||
"какую",
|
||||
"чьи",
|
||||
"чья",
|
||||
"чей",
|
||||
"чью",
|
||||
"док",
|
||||
"доки",
|
||||
"документ",
|
||||
@@ -543,8 +596,28 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
"епта",
|
||||
"нах",
|
||||
"нахуй",
|
||||
"есть",
|
||||
"же",
|
||||
"сводные",
|
||||
"сводный",
|
||||
"сводная",
|
||||
"сводную",
|
||||
"сводном",
|
||||
"сводного",
|
||||
"сводному",
|
||||
"неуказанному",
|
||||
"неуказанный",
|
||||
"неуказанная",
|
||||
"неуказанное",
|
||||
"указанному",
|
||||
"указанный",
|
||||
"указанная",
|
||||
"указанное",
|
||||
"объекту",
|
||||
"объект",
|
||||
"покеж",
|
||||
"покажи",
|
||||
"скажи",
|
||||
"показать",
|
||||
"выведи",
|
||||
"show",
|
||||
@@ -563,12 +636,20 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
"ним",
|
||||
"этому",
|
||||
"тому",
|
||||
"этомуже",
|
||||
"томуже",
|
||||
"vse",
|
||||
"all",
|
||||
"kakie",
|
||||
"kakoi",
|
||||
"est",
|
||||
"za",
|
||||
"po",
|
||||
"na",
|
||||
"s",
|
||||
"poka",
|
||||
"pokaji",
|
||||
"skazhi",
|
||||
"pokazhi",
|
||||
"pokazh",
|
||||
"pokezh",
|
||||
@@ -589,6 +670,65 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
return !stopWords.has(lowered);
|
||||
}
|
||||
|
||||
function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
|
||||
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: string): boolean {
|
||||
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: string): boolean {
|
||||
const lowered = String(text ?? "").toLowerCase();
|
||||
return new RegExp(
|
||||
@@ -992,6 +1132,15 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
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];
|
||||
|
||||
@@ -89,6 +89,9 @@ const DOCUMENTS_BY_COUNTERPARTY_HINTS = [
|
||||
"доки",
|
||||
"доки по",
|
||||
"док по",
|
||||
"doki",
|
||||
"docy",
|
||||
"doci",
|
||||
"по контрагент"
|
||||
];
|
||||
|
||||
@@ -484,6 +487,21 @@ function hasPeriodCoverageProfileSignal(text: string): boolean {
|
||||
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;
|
||||
}
|
||||
@@ -509,6 +527,12 @@ function hasDocumentTypeAndAccountSectionProfileSignal(text: string): boolean {
|
||||
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;
|
||||
}
|
||||
@@ -623,6 +647,11 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
|
||||
/(?:кто\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
|
||||
@@ -646,6 +675,9 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
|
||||
if (!hasFuzzySupplierLexeme && asksIncomingFlow && asksRankOrTop) {
|
||||
return true;
|
||||
}
|
||||
if (!hasFuzzySupplierLexeme && asksDealBudgetRanking) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -704,6 +736,11 @@ function hasContractListByCounterpartySignal(text: string): boolean {
|
||||
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;
|
||||
}
|
||||
@@ -732,7 +769,7 @@ function hasContractListByCounterpartySignal(text: string): boolean {
|
||||
|
||||
function hasDocumentsByAccountDrilldownSignal(text: string): boolean {
|
||||
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);
|
||||
@@ -822,6 +859,25 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
"пж",
|
||||
"пжлст",
|
||||
"пожалуйста",
|
||||
"есть",
|
||||
"же",
|
||||
"сводные",
|
||||
"сводный",
|
||||
"сводная",
|
||||
"сводную",
|
||||
"сводном",
|
||||
"сводного",
|
||||
"сводному",
|
||||
"неуказанному",
|
||||
"неуказанный",
|
||||
"неуказанная",
|
||||
"неуказанное",
|
||||
"указанному",
|
||||
"указанный",
|
||||
"указанная",
|
||||
"указанное",
|
||||
"объекту",
|
||||
"объект",
|
||||
"бля",
|
||||
"блять",
|
||||
"епт",
|
||||
@@ -834,7 +890,9 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
"связанных",
|
||||
"связанному",
|
||||
"related",
|
||||
"linked"
|
||||
"linked",
|
||||
"этомуже",
|
||||
"томуже"
|
||||
]);
|
||||
return !stopWords.has(token);
|
||||
}
|
||||
@@ -979,14 +1037,14 @@ function hasLooseByAnchorMention(text: string): boolean {
|
||||
|
||||
function hasImplicitCounterpartyAnchorAroundDocs(text: string): boolean {
|
||||
const beforeDocsMatch = text.match(
|
||||
/(?:^|\s)([a-zа-яё][a-zа-яё0-9._-]{1,})\s+(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?)(?=[\s,.;:!?)]|$)/iu
|
||||
/(?:^|\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
|
||||
/(?:док(?:и|ум(?:ент(?:ы|ов|ам|а)?)?)|docs?|documents?|doki|docy|doci)\s+(?:по\s+)?([a-zа-яё][a-zа-яё0-9._-]{1,})(?=[\s,.;:!?)]|$)/iu
|
||||
);
|
||||
if (afterDocsMatch && isLikelyCounterpartyToken(String(afterDocsMatch[1] ?? ""))) {
|
||||
return true;
|
||||
@@ -996,7 +1054,7 @@ function hasImplicitCounterpartyAnchorAroundDocs(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasDocsOrBankSignal(text: string): boolean {
|
||||
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
|
||||
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|doki|docy|doci|банк|выписк|платеж|платёж|оплат|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
@@ -1010,6 +1068,9 @@ function hasDocumentSignal(text: string): boolean {
|
||||
text.includes("док") ||
|
||||
text.includes("доки") ||
|
||||
text.includes("документ") ||
|
||||
text.includes("doki") ||
|
||||
text.includes("docy") ||
|
||||
text.includes("doci") ||
|
||||
text.includes("docs") ||
|
||||
text.includes("documents")
|
||||
);
|
||||
|
||||
@@ -118,6 +118,27 @@ const DEEP_REASONING_TOKENS = [
|
||||
];
|
||||
|
||||
function hasManagementProfileSignal(text: string): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function hasOpenItemsHint(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasDocumentSignal(text: string): boolean {
|
||||
return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?)/iu.test(String(text ?? ""));
|
||||
return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? ""));
|
||||
}
|
||||
|
||||
function hasBankSignal(text: string): boolean {
|
||||
@@ -134,6 +134,122 @@ function hasExplicitLooseByAnchorToken(text: string): boolean {
|
||||
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: string): string {
|
||||
return String(value ?? "").trim().toLowerCase().replace(/ё/g, "е");
|
||||
}
|
||||
|
||||
function hasContractLikeToken(value: string): boolean {
|
||||
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: string | null): boolean {
|
||||
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: string | null): boolean {
|
||||
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: AddressIntent): AddressIntent | null {
|
||||
if (intent === "list_documents_by_counterparty") {
|
||||
return "list_documents_by_contract";
|
||||
@@ -199,24 +315,30 @@ function mergeFollowupFilters(
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,21 +360,27 @@ function mergeFollowupFilters(
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +470,7 @@ function deriveIntentWithFollowupContext(
|
||||
) {
|
||||
const preferDocumentsForming =
|
||||
hasDocumentSignal(normalizedMessage) &&
|
||||
/(?:раскрой|раскры|формир|документами|по\s+документ)/iu.test(normalizedMessage);
|
||||
/(?:раскрой|раскры|формир|документами)/iu.test(normalizedMessage);
|
||||
return {
|
||||
intent: preferDocumentsForming ? "documents_forming_balance" : "account_balance_snapshot",
|
||||
confidence: "low",
|
||||
@@ -351,6 +479,23 @@ function deriveIntentWithFollowupContext(
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as assistantClaimBoundEvidence_1 from "./assistantClaimBoundEvidence";
|
||||
import * as addressQueryService_1 from "./addressQueryService";
|
||||
import * as addressQueryClassifier_1 from "./addressQueryClassifier";
|
||||
import * as addressIntentResolver_1 from "./addressIntentResolver";
|
||||
import * as addressFilterExtractor_1 from "./addressFilterExtractor";
|
||||
import * as predecomposeContract_1 from "./address_runtime/predecomposeContract";
|
||||
import iconv from "iconv-lite";
|
||||
function retrievalSummaryForRoute(route) {
|
||||
@@ -1829,9 +1830,11 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
|
||||
"please",
|
||||
"покеж",
|
||||
"покажи",
|
||||
"скажи",
|
||||
"показать",
|
||||
"show",
|
||||
"list",
|
||||
"skazhi",
|
||||
"выведи",
|
||||
"что",
|
||||
"чо",
|
||||
@@ -1939,7 +1942,8 @@ 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;
|
||||
@@ -1985,6 +1989,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, "документы")
|
||||
@@ -2382,6 +2387,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());
|
||||
@@ -2437,6 +2471,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;
|
||||
}
|
||||
@@ -2598,6 +2644,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 {
|
||||
@@ -2714,6 +2923,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;
|
||||
|
||||
@@ -85,6 +85,16 @@ describe("address query shape classifier", () => {
|
||||
expect(result.mode).toBe("address_query");
|
||||
});
|
||||
|
||||
it("keeps noisy management phrase about years alive in address lane", () => {
|
||||
const result = detectAddressQuestionMode("за какие года база ваще живая?");
|
||||
expect(result.mode).toBe("address_query");
|
||||
});
|
||||
|
||||
it("keeps noisy month-peak phrase in address lane", () => {
|
||||
const result = detectAddressQuestionMode("а теперь месяц-пик по операциям");
|
||||
expect(result.mode).toBe("address_query");
|
||||
});
|
||||
|
||||
it("keeps management contract usage overview question in address lane", () => {
|
||||
const result = detectAddressQuestionMode("Сколько всего договоров заведено и сколько из них реально использовались?");
|
||||
expect(result.mode).toBe("address_query");
|
||||
@@ -1151,6 +1161,11 @@ describe("address intent resolver expansion (M2.3a)", () => {
|
||||
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||
});
|
||||
|
||||
it("resolves transliterated docy slang as documents by counterparty intent", () => {
|
||||
const result = resolveAddressIntent("svk poka docy za 2020");
|
||||
expect(result.intent).toBe("list_documents_by_counterparty");
|
||||
});
|
||||
|
||||
it("resolves bank operations by supplier phrase", () => {
|
||||
const result = resolveAddressIntent("Покажи платежи по поставщику Альфа за июль 2020");
|
||||
expect(result.intent).toBe("bank_operations_by_counterparty");
|
||||
@@ -1273,6 +1288,16 @@ describe("address intent resolver expansion (M2.3a)", () => {
|
||||
expect(result.intent).toBe("period_coverage_profile");
|
||||
});
|
||||
|
||||
it("resolves period coverage profile for noisy active-year-by-docs phrase", () => {
|
||||
const result = resolveAddressIntent("какой год тут самый движовый по докам");
|
||||
expect(result.intent).toBe("period_coverage_profile");
|
||||
});
|
||||
|
||||
it("resolves period coverage profile for month-peak follow-up phrase", () => {
|
||||
const result = resolveAddressIntent("а теперь месяц-пик по операциям");
|
||||
expect(result.intent).toBe("period_coverage_profile");
|
||||
});
|
||||
|
||||
it("resolves document+section profile for document type usage question", () => {
|
||||
const result = resolveAddressIntent("Какие типы документов используются чаще всего в базе?");
|
||||
expect(result.intent).toBe("document_type_and_account_section_profile");
|
||||
@@ -1293,6 +1318,16 @@ describe("address intent resolver expansion (M2.3a)", () => {
|
||||
expect(result.intent).toBe("document_type_and_account_section_profile");
|
||||
});
|
||||
|
||||
it("resolves document+section profile for noisy docs usage phrase", () => {
|
||||
const result = resolveAddressIntent("каких доков у нас больше всего крутится?");
|
||||
expect(result.intent).toBe("document_type_and_account_section_profile");
|
||||
});
|
||||
|
||||
it("resolves document+section profile for summary by doc types and share phrase", () => {
|
||||
const result = resolveAddressIntent("Сформируй сводку по типам документов и их доле в общем объеме.");
|
||||
expect(result.intent).toBe("document_type_and_account_section_profile");
|
||||
});
|
||||
|
||||
it("resolves counterparty population intent for total unique counterparties question", () => {
|
||||
const result = resolveAddressIntent("Сколько всего уникальных контрагентов в базе?");
|
||||
expect(result.intent).toBe("counterparty_population_and_roles");
|
||||
@@ -1363,6 +1398,11 @@ describe("address intent resolver expansion (M2.3a)", () => {
|
||||
expect(result.intent).toBe("customer_revenue_and_payments");
|
||||
});
|
||||
|
||||
it("resolves customer revenue intent from small deals by budget slang wording", () => {
|
||||
const result = resolveAddressIntent("покажи топ-20 самых маленьких сделок по бюджету");
|
||||
expect(result.intent).toBe("customer_revenue_and_payments");
|
||||
});
|
||||
|
||||
it("resolves customer revenue intent from typo highest-check wording", () => {
|
||||
const result = resolveAddressIntent("с каких кликентов самый высокий чек");
|
||||
expect(result.intent).toBe("customer_revenue_and_payments");
|
||||
@@ -1382,6 +1422,11 @@ describe("address intent resolver expansion (M2.3a)", () => {
|
||||
const result = resolveAddressIntent("покажи договора все по жуковке 51");
|
||||
expect(result.intent).toBe("list_contracts_by_counterparty");
|
||||
});
|
||||
|
||||
it("prefers documents-by-contract intent for explicit document follow-up wording", () => {
|
||||
const result = resolveAddressIntent("покажи документы по этому же договору");
|
||||
expect(result.intent).toBe("list_documents_by_contract");
|
||||
});
|
||||
});
|
||||
|
||||
describe("address filter extraction for balance drilldown", () => {
|
||||
@@ -1630,6 +1675,14 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(result.warnings).toContain("period_derived_from_year_phrase");
|
||||
});
|
||||
|
||||
it("does not use filler token 'есть' as counterparty when explicit shorthand anchor exists", () => {
|
||||
const result = extractAddressFilters("какие у свк есть доки за 2020?", "list_documents_by_counterparty");
|
||||
expect(result.extracted_filters.counterparty).toBe("свк");
|
||||
expect(result.extracted_filters.counterparty).not.toBe("есть");
|
||||
expect(result.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(result.extracted_filters.period_to).toBe("2020-12-31");
|
||||
});
|
||||
|
||||
it("extracts free-text counterparty and relaxed short-year period from noisy phrase", () => {
|
||||
const result = extractAddressFilters(
|
||||
"свк 20 год - покажи доки плс",
|
||||
@@ -1690,6 +1743,17 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(result.warnings).toContain("period_derived_from_year_phrase");
|
||||
});
|
||||
|
||||
it("does not treat transliterated filler verb as counterparty in docy phrase", () => {
|
||||
const result = extractAddressFilters(
|
||||
"svk poka docy za 2020",
|
||||
"list_documents_by_counterparty"
|
||||
);
|
||||
expect(result.extracted_filters.counterparty).toBe("svk");
|
||||
expect(result.extracted_filters.counterparty).not.toBe("poka");
|
||||
expect(result.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(result.extracted_filters.period_to).toBe("2020-12-31");
|
||||
});
|
||||
|
||||
it("repairs mojibake phrase before extracting counterparty filters", () => {
|
||||
const result = extractAddressFilters(
|
||||
"Показать документы СВК за 2020 год.",
|
||||
@@ -1785,6 +1849,14 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(result.extracted_filters.period_to).toBe("2020-12-31");
|
||||
});
|
||||
|
||||
it("trims explanatory tail after contract token", () => {
|
||||
const result = extractAddressFilters(
|
||||
"документы по договору 19/15 выведите связанные документы",
|
||||
"list_documents_by_contract"
|
||||
);
|
||||
expect(result.extracted_filters.contract).toBe("19/15");
|
||||
});
|
||||
|
||||
it("extracts multiline year range period from phrase", () => {
|
||||
const result = extractAddressFilters(
|
||||
"Какие документы по СВК за 2000 - 2025\n год?",
|
||||
@@ -2158,6 +2230,46 @@ describe("address decompose stage follow-up carryover", () => {
|
||||
expect(result?.baseReasons).toContain("intent_adjusted_to_contract_followup_context");
|
||||
});
|
||||
|
||||
it("replaces noisy follow-up contract anchor with previous contract from context", () => {
|
||||
const result = runAddressDecomposeStage("а документы по этому же договору за тот же период", {
|
||||
previous_intent: "bank_operations_by_contract",
|
||||
previous_filters: {
|
||||
contract: "19/15",
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
},
|
||||
previous_anchor_type: "contract",
|
||||
previous_anchor_value: "19/15"
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("list_documents_by_contract");
|
||||
expect(result?.filters.extracted_filters.contract).toBe("19/15");
|
||||
expect(
|
||||
result?.baseReasons?.includes("contract_replaced_from_followup_context") ||
|
||||
result?.baseReasons?.includes("contract_from_followup_context")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces noisy referential counterparty anchor with previous counterparty from context", () => {
|
||||
const result = runAddressDecomposeStage("а теперь документы по нему", {
|
||||
previous_intent: "bank_operations_by_counterparty",
|
||||
previous_filters: {
|
||||
counterparty: "свк",
|
||||
period_from: "2020-11-01",
|
||||
period_to: "2020-11-30"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "свк"
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("list_documents_by_counterparty");
|
||||
expect(result?.filters.extracted_filters.counterparty).toBe("свк");
|
||||
expect(
|
||||
result?.baseReasons?.includes("counterparty_replaced_from_followup_context") ||
|
||||
result?.baseReasons?.includes("counterparty_from_followup_context")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes open-items intent from follow-up wording with inherited contract anchor", () => {
|
||||
const result = runAddressDecomposeStage("а теперь открытые позиции по нему", {
|
||||
previous_intent: "bank_operations_by_contract",
|
||||
|
||||
@@ -159,6 +159,291 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
expect(["unknown", "list_documents_by_counterparty"]).toContain(response.debug?.llm_predecompose_contract?.intent);
|
||||
});
|
||||
|
||||
it("keeps counterparty anchor for docy typo when llm fragment drops anchor", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
calls.push({ message });
|
||||
return buildAddressLaneResult(message);
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
trace_id: "norm-predecompose-docy",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "svk poka docy za 2020",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "svk poka docy za 2020",
|
||||
normalized_fragment_text: "Покажи документы за 2020 год",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: [],
|
||||
account_hints: [],
|
||||
document_hints: ["документы"],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
type: "explicit",
|
||||
value: "2020",
|
||||
confidence: "high"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: false,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["simple_factual"],
|
||||
confidence: "medium",
|
||||
execution_readiness: "executable",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: [],
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
},
|
||||
raw_model_output: null,
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
request_count_for_case: 1
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: `asst-predecompose-docy-${Date.now()}`,
|
||||
user_message: "svk poka docy za 2020",
|
||||
llmProvider: "local",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].message.toLowerCase()).toContain("svk");
|
||||
expect(calls[0].message).not.toBe("Покажи документы за 2020 год");
|
||||
expect(String(response.debug?.llm_decomposition_effective_message ?? "").toLowerCase()).toContain("svk");
|
||||
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
||||
});
|
||||
|
||||
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
calls.push({ message });
|
||||
return buildAddressLaneResult(message);
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
trace_id: "norm-predecompose-skazhi-anchor",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "свк списания/поступления за 2020",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "свк списания/поступления за 2020",
|
||||
normalized_fragment_text: "скажи списания/поступления за 2020",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: [],
|
||||
account_hints: [],
|
||||
document_hints: ["списания", "поступления"],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
type: "explicit",
|
||||
value: "2020",
|
||||
confidence: "high"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: false,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["simple_factual"],
|
||||
confidence: "medium",
|
||||
execution_readiness: "executable",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: [],
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
},
|
||||
raw_model_output: null,
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
request_count_for_case: 1
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: `asst-predecompose-skazhi-anchor-${Date.now()}`,
|
||||
user_message: "свк списания/поступления за 2020",
|
||||
llmProvider: "local",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].message.toLowerCase()).toContain("свк");
|
||||
expect(calls[0].message.toLowerCase()).not.toContain("скажи");
|
||||
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
||||
});
|
||||
|
||||
it("rejects follow-up intent injection when llm adds documents to same-date account prompt", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
calls.push({ message });
|
||||
return buildAddressLaneResult(message);
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
trace_id: "norm-predecompose-followup-injection",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "а на ту же дату по 62",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "а на ту же дату по 62",
|
||||
normalized_fragment_text: "документы или проводки по счету 62 на ту же дату",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: [],
|
||||
account_hints: ["62"],
|
||||
document_hints: ["документы"],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
type: "explicit",
|
||||
value: "same-date",
|
||||
confidence: "medium"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: false,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["simple_factual"],
|
||||
confidence: "medium",
|
||||
execution_readiness: "executable",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: [],
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
},
|
||||
raw_model_output: null,
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
request_count_for_case: 1
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const sourceMessage = "а на ту же дату по 62";
|
||||
const response = await service.handleMessage({
|
||||
session_id: `asst-predecompose-followup-injection-${Date.now()}`,
|
||||
user_message: sourceMessage,
|
||||
llmProvider: "local",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].message).toBe(sourceMessage);
|
||||
expect(response.debug?.llm_decomposition_applied).toBe(false);
|
||||
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_followup_intent_injection");
|
||||
expect(response.debug?.llm_decomposition_effective_message).toBe(sourceMessage);
|
||||
});
|
||||
|
||||
it("applies deterministic fallback rule when llm fragment is unusable", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
|
||||
Reference in New Issue
Block a user