ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Спека exact-маршрута payables на дату: confirmed_balance без эвристического финала

This commit is contained in:
2026-04-12 13:46:14 +03:00
parent ca2feab893
commit 1b2ee93176
19 changed files with 2453 additions and 252 deletions
@@ -300,6 +300,10 @@ const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [
"самые доходные заказчики",
"топ клиентов по сумме поступлений",
"топ заказчиков по сумме поступлений",
"кто больше всего принес денег",
"кто больше всего принёс денег",
"кто принес больше всего денег",
"кто принёс больше всего денег",
"кто нам больше всего занес",
"кто нам больше всего занёс",
"кто нам принес больше всего",
@@ -685,6 +689,7 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksWhoBringsMostMoney = /(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test(text);
const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text);
const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text);
@@ -708,6 +713,9 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true;
}
if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true;
}
@@ -823,6 +831,18 @@ function hasSupplierTailRiskSignal(text) {
const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text);
return hasSupplier && hasTail && (hasRisk || hasPeriodCue);
}
function hasPayablesDebtLifecycleSignal(text) {
const hasOweSignal = /(?:кому\s+мы\s+должны|мы\s+должны|кому\s+должны|должн(?:ы|а|о)\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:ск)?)/iu.test(text);
if (!hasOweSignal) {
return false;
}
const hasPastPaymentSignal = /(?:заплатил(?:и)?|платил(?:и)?|кому\s+ушло|выплатил(?:и)?|списан|outflow|payout)/iu.test(text);
const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|максимальн)/iu.test(text);
if (hasPastPaymentSignal && hasTopRankingSignal) {
return false;
}
return true;
}
function hasReceivablesLatencyRiskSignal(text) {
const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text);
const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text);
@@ -1204,10 +1224,14 @@ function resolveAddressIntent(userMessage) {
};
}
if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) {
reasons.push("payables_debt_lifecycle_signal_detected");
}
return {
intent: "list_payables_counterparties",
confidence: "high",
reasons: ["payables_signal_detected"]
reasons
};
}
if (hasSettlementGapSignal(text)) {
@@ -1242,7 +1266,7 @@ function resolveAddressIntent(userMessage) {
return {
intent: "list_payables_counterparties",
confidence: "medium",
reasons: ["supplier_tail_risk_signal_detected"]
reasons: ["supplier_tail_risk_signal_detected", "payables_debt_lifecycle_signal_detected"]
};
}
if (hasDocumentsFormingBalanceSignal(text) && hasDocumentsFormingBalanceAccountAnchor(text)) {
+151 -1
View File
@@ -631,6 +631,92 @@ function isCounterpartyRiskIntent(intent) {
intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract");
}
function isHeuristicCandidatesIntent(intent) {
return (intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" ||
intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract");
}
function isConfirmedBalanceIntent(intent) {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
}
function resolveAsOfDateBasis(filters) {
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
if (asOfDate) {
return "explicit_as_of_date";
}
const periodFrom = normalizeAnalysisDateHint(filters.period_from);
const periodTo = normalizeAnalysisDateHint(filters.period_to);
if (periodFrom && periodTo) {
return "period_range";
}
if (!periodFrom && periodTo) {
return "period_end";
}
if (periodFrom) {
return "period_range";
}
return null;
}
function deriveAddressEvidenceStrength(input) {
if (isHeuristicCandidatesIntent(input.intent)) {
if (input.rowsMatched <= 0 || input.responseType === "LIMITED_WITH_REASON") {
return "weak";
}
if (input.selectedRecipe === "address_open_items_by_party_or_contract_v1") {
return "medium";
}
return "weak";
}
if (isConfirmedBalanceIntent(input.intent)) {
if (input.rowsMatched > 0) {
return "strong";
}
return input.responseType === "LIMITED_WITH_REASON" ? "weak" : "medium";
}
return undefined;
}
function resolveRequestedResultMode(intent, filters) {
if (isConfirmedBalanceIntent(intent)) {
return "confirmed_balance";
}
if (isHeuristicCandidatesIntent(intent)) {
const asOfDateBasis = resolveAsOfDateBasis(filters);
if (asOfDateBasis === "explicit_as_of_date" || asOfDateBasis === "period_end" || asOfDateBasis === "period_range") {
return "confirmed_balance";
}
return "heuristic_candidates";
}
return undefined;
}
function deriveAddressResultSemantics(input) {
const asOfDateBasis = resolveAsOfDateBasis(input.filters);
const requestedResultMode = resolveRequestedResultMode(input.intent, input.filters);
if (isHeuristicCandidatesIntent(input.intent)) {
return {
requested_result_mode: requestedResultMode,
result_mode: "heuristic_candidates",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: false,
as_of_date_basis: asOfDateBasis
};
}
if (isConfirmedBalanceIntent(input.intent)) {
return {
requested_result_mode: requestedResultMode,
result_mode: "confirmed_balance",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: true,
as_of_date_basis: asOfDateBasis ?? "period_end"
};
}
if (requestedResultMode) {
return {
requested_result_mode: requestedResultMode
};
}
return {};
}
function resolveFutureGuardReferenceDate(analysisDate, filters) {
if (analysisDate) {
return analysisDate;
@@ -1196,6 +1282,13 @@ function composeLimitedReply(input) {
}
function buildLimitedExecutionResult(input) {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
const resultSemantics = deriveAddressResultSemantics({
intent: input.intent.intent,
selectedRecipe: input.selectedRecipe,
filters: input.filters,
responseType: "LIMITED_WITH_REASON",
rowsMatched: input.rowsMatched
});
return {
handled: true,
reply_text: composeLimitedReply({
@@ -1246,6 +1339,7 @@ function buildLimitedExecutionResult(input) {
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON",
...resultSemantics,
limitations: input.limitations,
reasons: input.reasons
}
@@ -1288,11 +1382,25 @@ class AddressQueryService {
const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) &&
intent.reasons.includes("receivables_debt_lifecycle_signal_detected");
const recipeIntent = debtLifecycleReceivablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent;
const debtLifecyclePayablesScenario = intent.intent === "list_payables_counterparties" &&
Array.isArray(intent.reasons) &&
(intent.reasons.includes("payables_debt_lifecycle_signal_detected") ||
intent.reasons.includes("supplier_tail_risk_signal_detected") ||
intent.reasons.includes("payables_signal_detected"));
const recipeIntent = debtLifecycleReceivablesScenario || debtLifecyclePayablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent;
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, filters.extracted_filters);
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters);
if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle");
}
if (debtLifecyclePayablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_payables_debt_lifecycle");
}
if (requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" &&
!baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) {
baseReasons.push("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
}
if (intent.intent === "unknown") {
return buildLimitedExecutionResult({
mode,
@@ -1576,6 +1684,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: recoveredRows.length
}),
limitations: [...filters.warnings, recoveryReason],
reasons: [...baseReasons, recoveryReason]
}
@@ -1692,6 +1807,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: expandedFactual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: expandedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: expandedFactual.responseType,
rowsMatched: expandedFilteredRows.length
}),
limitations: expandedLimitations,
reasons: expandedReasons
}
@@ -1803,6 +1925,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: broadenedFactual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: broadenedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: broadenedFactual.responseType,
rowsMatched: broadenedFilteredRows.length
}),
limitations: broadenedLimitations,
reasons: broadenedReasons
}
@@ -1922,6 +2051,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: historicalFactual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: historicalSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: historicalFactual.responseType,
rowsMatched: historicalFilteredRows.length
}),
limitations: historicalLimitations,
reasons: historicalReasons
}
@@ -1986,6 +2122,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: fallbackFactual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: fallbackFactual.responseType,
rowsMatched: documentBankFallbackRows.length
}),
limitations: fallbackLimitations,
reasons: fallbackReasons
}
@@ -2142,6 +2285,13 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: filteredRows.length
}),
limitations: filters.warnings,
reasons: baseReasons
}
@@ -470,6 +470,142 @@ function extractCounterpartyName(row) {
}
return null;
}
function liabilityCategoryLabel(category) {
if (category === "supplier_or_contractor") {
return "поставщики/подрядчики";
}
if (category === "bank_or_credit") {
return "банки/кредиты";
}
if (category === "tax_or_state") {
return "налоги/госорганы";
}
return "прочие";
}
function classifyPayablesLiabilityCategory(row, counterparty) {
const scores = {
supplier_or_contractor: 0,
bank_or_credit: 0,
tax_or_state: 0,
other: 0
};
const reasons = new Set();
const text = `${counterparty} ${row.registrator} ${row.analytics.join(" ")}`.toLowerCase();
const accountPrefixes = [extractAccountSectionCode(row.account_dt), extractAccountSectionCode(row.account_kt)].filter((item) => Boolean(item));
if (accountPrefixes.includes("60")) {
scores.supplier_or_contractor += 3;
reasons.add("участие счета 60");
}
if (accountPrefixes.includes("66") || accountPrefixes.includes("67")) {
scores.bank_or_credit += 4;
reasons.add("участие счета 66/67");
}
if (accountPrefixes.includes("68") || accountPrefixes.includes("69")) {
scores.tax_or_state += 4;
reasons.add("участие счета 68/69");
}
if (accountPrefixes.includes("76")) {
scores.supplier_or_contractor += 1;
reasons.add("участие счета 76");
}
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|loan|overdraft)/iu.test(text)) {
scores.bank_or_credit += 3;
reasons.add("банк/кредит в аналитике");
}
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос)/iu.test(text)) {
scores.tax_or_state += 3;
reasons.add("налог/госорган в аналитике");
}
if (/(?:\bип\b|ооо|ао|зао|пао|подряд|поставщик|supplier|vendor|contractor)/iu.test(text)) {
scores.supplier_or_contractor += 2;
reasons.add("коммерческий контрагент в аналитике");
}
return {
scores,
reasons: Array.from(reasons)
};
}
function buildPayablesCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
for (const row of rows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const amountRaw = row.amount ?? 0;
if (!Number.isFinite(amountRaw)) {
continue;
}
const amount = Math.abs(amountRaw);
const classified = classifyPayablesLiabilityCategory(row, name);
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, {
base: {
name,
totalAmount: amount,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period
},
categoryScores: {
supplier_or_contractor: classified.scores.supplier_or_contractor,
bank_or_credit: classified.scores.bank_or_credit,
tax_or_state: classified.scores.tax_or_state,
other: classified.scores.other
},
reasons: new Set(classified.reasons)
});
continue;
}
current.base.totalAmount += amount;
current.base.operations += 1;
if ((row.period ?? "") < (current.base.firstPeriod ?? "")) {
current.base.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.base.lastPeriod ?? "")) {
current.base.lastPeriod = row.period;
}
current.categoryScores.supplier_or_contractor += classified.scores.supplier_or_contractor;
current.categoryScores.bank_or_credit += classified.scores.bank_or_credit;
current.categoryScores.tax_or_state += classified.scores.tax_or_state;
current.categoryScores.other += classified.scores.other;
for (const reason of classified.reasons) {
current.reasons.add(reason);
}
}
const scoreKeys = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"];
const toCategory = (scores) => {
let winner = "other";
let best = Number.NEGATIVE_INFINITY;
for (const key of scoreKeys) {
const score = scores[key];
if (score > best) {
best = score;
winner = key;
}
}
if (best <= 0) {
return "other";
}
return winner;
};
return Array.from(byCounterparty.values())
.map((item) => ({
...item.base,
category: toCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.sort((left, right) => {
if (right.totalAmount !== left.totalAmount) {
return right.totalAmount - left.totalAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.name.localeCompare(right.name);
});
}
function buildCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
for (const row of rows) {
@@ -1528,22 +1664,55 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
if (intent === "list_payables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const counterparties = buildPayablesCounterpartyRiskAggregate(rows);
const scopeLine = (() => {
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
if (asOfDate) {
return `Дата среза: ${formatDateRu(asOfDate)}.`;
}
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
if (periodFrom || periodTo) {
return `Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`;
}
return null;
})();
const lines = [
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
"Коротко: собран shortlist кандидатов на ручную проверку по потенциально незакрытым обязательствам (контур 60/76).",
"",
"Что это значит:",
"- Режим результата: эвристический скоринг по движениям.",
"- Это не финальный подтвержденный остаток к оплате.",
...(scopeLine ? ["", scopeLine] : []),
"",
`Строк в выборке: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.`
`Контрагентов-кандидатов: ${counterparties.length}.`
];
if (counterparties.length > 0) {
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
const categoryCounts = counterparties.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
lines.push("");
lines.push("Категории обязательств:");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Приоритет ручной проверки (по сумме/частоте сигналов):");
lines.push(...counterparties
.slice(0, 8)
.map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`));
.map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""} | статус: требует ручной проверки${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`));
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 4));
}
else {
lines.push("Явных признаков системной задолженности по доступному срезу не найдено.");
lines.push("");
lines.push("Явных кандидатов на незакрытые обязательства по текущему срезу не найдено.");
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 6));
}
return {
+200 -52
View File
@@ -1460,6 +1460,11 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
requested_result_mode: addressDebug.requested_result_mode ?? undefined,
result_mode: addressDebug.result_mode ?? undefined,
evidence_strength: addressDebug.evidence_strength ?? undefined,
balance_confirmed: typeof addressDebug.balance_confirmed === "boolean" ? addressDebug.balance_confirmed : undefined,
as_of_date_basis: addressDebug.as_of_date_basis ?? undefined,
execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
@@ -1588,6 +1593,19 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"kakoi",
"vse",
"all",
"\u043f\u0443\u043d\u043a\u0442",
"\u043f\u0443\u043d\u043a\u0442\u0430",
"\u043f\u0443\u043d\u043a\u0442\u0443",
"\u043f\u0443\u043d\u043a\u0442\u043e\u043c",
"\u043f\u043e\u0437\u0438\u0446\u0438\u044f",
"\u043f\u043e\u0437\u0438\u0446\u0438\u0438",
"\u043f\u043e\u0437\u0438\u0446\u0438\u044e",
"\u0441\u0442\u0440\u043e\u043a\u0430",
"\u0441\u0442\u0440\u043e\u043a\u0438",
"\u0441\u0442\u0440\u043e\u043a\u0443",
"item",
"row",
"line",
"blya",
"blyat",
"епт",
@@ -1612,51 +1630,51 @@ const ADDRESS_FALLBACK_STRIP_TOKENS = new Set([
"please"
]);
const ADDRESS_MONTH_ALIAS_MAP = {
янв: "01",
январ: "01",
"\u044f\u043d\u0432": "01",
"\u044f\u043d\u0432\u0430\u0440": "01",
january: "01",
jan: "01",
фев: "02",
феврал: "02",
"\u0444\u0435\u0432": "02",
"\u0444\u0435\u0432\u0440\u0430\u043b": "02",
february: "02",
feb: "02",
мар: "03",
март: "03",
"\u043c\u0430\u0440": "03",
"\u043c\u0430\u0440\u0442": "03",
march: "03",
apr: "04",
апр: "04",
апрел: "04",
"\u0430\u043f\u0440": "04",
"\u0430\u043f\u0440\u0435\u043b": "04",
april: "04",
май: "05",
ма: "05",
"\u043c\u0430\u0439": "05",
"\u043c\u0430": "05",
may: "05",
июн: "06",
июнь: "06",
"\u0438\u044e\u043d": "06",
"\u0438\u044e\u043d\u044c": "06",
june: "06",
jun: "06",
июл: "07",
июль: "07",
"\u0438\u044e\u043b": "07",
"\u0438\u044e\u043b\u044c": "07",
july: "07",
jul: "07",
авг: "08",
август: "08",
"\u0430\u0432\u0433": "08",
"\u0430\u0432\u0433\u0443\u0441\u0442": "08",
august: "08",
aug: "08",
сен: "09",
сент: "09",
сентябр: "09",
"\u0441\u0435\u043d": "09",
"\u0441\u0435\u043d\u0442": "09",
"\u0441\u0435\u043d\u0442\u044f\u0431\u0440": "09",
september: "09",
sep: "09",
окт: "10",
октябр: "10",
"\u043e\u043a\u0442": "10",
"\u043e\u043a\u0442\u044f\u0431\u0440": "10",
october: "10",
oct: "10",
ноя: "11",
ноябр: "11",
"\u043d\u043e\u044f": "11",
"\u043d\u043e\u044f\u0431\u0440": "11",
november: "11",
nov: "11",
дек: "12",
декабр: "12",
"\u0434\u0435\u043a": "12",
"\u0434\u0435\u043a\u0430\u0431\u0440": "12",
december: "12",
dec: "12"
};
@@ -1883,6 +1901,10 @@ function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage)
const bankSignal = ADDRESS_BANK_SIGNAL_PATTERN.test(source);
const contractSignal = ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
const balanceSignal = ADDRESS_BALANCE_SIGNAL_PATTERN.test(source);
const hasIndexPointerSignal = /(?:\u043f\u0443\u043d\u043a\u0442|\u043f\u043e\u0437\u0438\u0446|\u0441\u0442\u0440\u043e\u043a|item|row|line)/iu.test(sourceRaw);
if (hasIndexPointerSignal && extractDisplayedEntityIndexMention(sourceRaw) !== null) {
return null;
}
if (balanceSignal && account) {
let periodClause = "";
let rule = "balance_account_rewrite";
@@ -2201,6 +2223,14 @@ const FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS = new Set([
"company",
"group"
]);
const FOLLOWUP_DISPLAY_ENTITY_TYPE_BY_INTENT = {
counterparty_activity_lifecycle: "counterparty",
customer_revenue_and_payments: "counterparty",
supplier_payouts_profile: "counterparty",
counterparty_population_and_roles: "counterparty",
contract_usage_and_value: "contract",
list_contracts_by_counterparty: "contract"
};
function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase()
@@ -2211,7 +2241,25 @@ function normalizeCounterpartyForFollowupMatch(value) {
function normalizeCounterpartyTokenForFollowupMatch(value) {
return normalizeCounterpartyForFollowupMatch(value).replace(/[._-]+/g, "");
}
function extractDisplayedCounterpartyCandidates(replyText) {
function normalizeCounterpartyStemForFollowupMatch(value) {
const compact = normalizeCounterpartyTokenForFollowupMatch(value);
if (!compact || !/[а-яё]/iu.test(compact)) {
return compact;
}
const stem = compact.replace(/(?:иями|ями|ами|ией|ей|ий|ов|ев|ом|ем|ах|ях|ую|юю|ая|яя|ое|ее|ые|ие|ого|его|ому|ему|ыми|ими|ым|им|ам|ям|у|ю|а|я|е|и|ы|о)$/iu, "");
return stem.length >= 3 ? stem : compact;
}
function inferDisplayedEntityTypeFromIntent(intent) {
const normalized = compactWhitespace(String(intent ?? "").toLowerCase());
if (!normalized) {
return "unknown";
}
return FOLLOWUP_DISPLAY_ENTITY_TYPE_BY_INTENT[normalized] ?? "unknown";
}
function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknown") {
if (entityType === "unknown") {
return [];
}
const lines = String(replyText ?? "").split(/\r?\n/);
const candidates = [];
for (const line of lines) {
@@ -2219,10 +2267,15 @@ function extractDisplayedCounterpartyCandidates(replyText) {
if (!compactLine) {
continue;
}
if (!/^\d+\.\s+/.test(compactLine)) {
const numberedMatch = compactLine.match(/^(\d+)\.\s+(.+)$/);
if (!numberedMatch) {
continue;
}
const afterNumber = compactLine.replace(/^\d+\.\s+/, "");
const index = Number.parseInt(String(numberedMatch[1] ?? ""), 10);
if (!Number.isFinite(index) || index <= 0) {
continue;
}
const afterNumber = String(numberedMatch[2] ?? "");
const parts = afterNumber.split("|").map((item) => compactWhitespace(item));
let counterpartyCandidate = parts[0] ?? "";
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) {
@@ -2232,9 +2285,20 @@ function extractDisplayedCounterpartyCandidates(replyText) {
if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue;
}
candidates.push(cleanedCandidate);
candidates.push({
index,
value: cleanedCandidate,
entityType
});
}
return Array.from(new Set(candidates));
const dedup = new Map();
for (const candidate of candidates) {
const key = `${candidate.entityType}:${candidate.index}:${normalizeCounterpartyForFollowupMatch(candidate.value)}`;
if (!dedup.has(key)) {
dedup.set(key, candidate);
}
}
return Array.from(dedup.values());
}
function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
const aliases = new Set();
@@ -2247,13 +2311,14 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
const withoutLegalTokens = normalizedTokens
const tokensForAlias = Array.from(new Set(normalizedTokens.flatMap((token) => [token, ...token.split(/-+/).map((part) => part.trim()).filter(Boolean)])));
const withoutLegalTokens = tokensForAlias
.filter((token) => !FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS.has(token))
.join(" ");
if (withoutLegalTokens) {
aliases.add(withoutLegalTokens);
}
for (const token of normalizedTokens) {
for (const token of tokensForAlias) {
const compactToken = normalizeCounterpartyTokenForFollowupMatch(token);
if (compactToken.length < 3) {
continue;
@@ -2265,6 +2330,10 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
continue;
}
aliases.add(compactToken);
const stemToken = normalizeCounterpartyStemForFollowupMatch(compactToken);
if (stemToken.length >= 4) {
aliases.add(stemToken);
}
}
return Array.from(aliases)
.map((alias) => compactWhitespace(alias))
@@ -2278,31 +2347,95 @@ function hasCounterpartyAliasMention(normalizedMessage, alias) {
}
const aliasPattern = escapeRegex(trimmedAlias).replace(/\s+/g, "\\s+");
const boundaryPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}(?:$|[^a-zа-я0-9])`, "iu");
return boundaryPattern.test(normalizedMessage);
if (boundaryPattern.test(normalizedMessage)) {
return true;
}
if (trimmedAlias.length < 4 || !/[а-яё]/iu.test(trimmedAlias)) {
return false;
}
const fuzzyPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}[а-яё]{0,3}(?:$|[^a-zа-я0-9])`, "iu");
return fuzzyPattern.test(normalizedMessage);
}
function resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) {
function extractDisplayedEntityIndexMention(userMessage) {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
if (!normalized) {
return null;
}
const tokenStart = "(?:^|[^\\p{L}\\p{N}_])";
const tokenEnd = "(?=$|[^\\p{L}\\p{N}_])";
const pointerPattern = "(?:\\u043f\\u0443\\u043d\\u043a\\u0442(?:\\u0430|\\u0443|\\u043e\\u043c)?|\\u043f\\u043e\\u0437\\u0438\\u0446\\u0438(?:\\u044f|\\u0438|\\u044e|\\u0435\\u0439)|\\u0441\\u0442\\u0440\\u043e\\u043a(?:\\u0430|\\u0438|\\u0435|\\u0443)|item|row|line)";
const pointerSignalPattern = new RegExp(`${tokenStart}${pointerPattern}${tokenEnd}`, "iu");
const directPattern = new RegExp(`${tokenStart}${pointerPattern}${tokenEnd}\\D{0,8}(\\d{1,3})(?!\\d)`, "iu");
const directMatch = normalized.match(directPattern);
if (directMatch) {
const value = Number.parseInt(String(directMatch[1] ?? ""), 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
const reversePattern = new RegExp(`${tokenStart}(\\d{1,3})(?:-?(?:\\u0439|\\u044f|\\u0435|\\u0433\\u043e|\\u043c\\u0443))?\\s+${pointerPattern}${tokenEnd}`, "iu");
const reverseMatch = normalized.match(reversePattern);
if (reverseMatch) {
const value = Number.parseInt(String(reverseMatch[1] ?? ""), 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
if (pointerSignalPattern.test(normalized)) {
const numericMatches = Array.from(normalized.matchAll(/(?:^|[^\p{N}])(\d{1,3})(?!\d)/gu))
.map((match) => Number.parseInt(String(match[1] ?? ""), 10))
.filter((value) => Number.isFinite(value) && value > 0);
if (numericMatches.length === 1) {
return numericMatches[0];
}
}
return null;
}
function resolveDisplayedAddressEntityMention(userMessage, displayedEntities) {
const normalizedMessage = normalizeCounterpartyForFollowupMatch(userMessage);
if (!normalizedMessage) {
return null;
}
if (!Array.isArray(displayedCounterparties) || displayedCounterparties.length === 0) {
if (!Array.isArray(displayedEntities) || displayedEntities.length === 0) {
return null;
}
const indexMention = extractDisplayedEntityIndexMention(userMessage);
if (indexMention !== null) {
const indexedCandidate = displayedEntities.find((candidate) => Number(candidate.index) === indexMention);
if (indexedCandidate) {
return {
value: indexedCandidate.value,
entityType: indexedCandidate.entityType,
matchKind: "index",
index: indexedCandidate.index
};
}
}
let bestMatch = null;
for (const candidate of displayedCounterparties) {
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate);
for (const candidate of displayedEntities) {
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate.value);
for (const alias of aliases) {
if (!hasCounterpartyAliasMention(normalizedMessage, alias)) {
continue;
}
const score = alias.length * 10 + (normalizeCounterpartyForFollowupMatch(candidate) === alias ? 1 : 0);
const score = alias.length * 10 + (normalizeCounterpartyForFollowupMatch(candidate.value) === alias ? 1 : 0);
if (!bestMatch || score > bestMatch.score) {
bestMatch = { value: candidate, score };
bestMatch = {
value: candidate.value,
entityType: candidate.entityType,
index: candidate.index,
matchKind: "alias",
score
};
}
break;
}
}
return bestMatch?.value ?? null;
if (!bestMatch) {
return null;
}
return {
value: bestMatch.value,
entityType: bestMatch.entityType,
matchKind: bestMatch.matchKind,
index: bestMatch.index
};
}
function findRecentAddressFilterValue(items, key) {
for (let index = items.length - 1; index >= 0; index -= 1) {
@@ -2479,12 +2612,17 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage)
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null
: false;
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) {
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null;
}
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) {
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null;
}
if (!previousAddressDebug) {
@@ -2531,16 +2669,24 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previousFilters.organization = historicalOrganization;
}
}
const displayedCounterparties = extractDisplayedCounterpartyCandidates(toNonEmptyString(previousAddressItem?.text) ?? "");
const counterpartyFromFollowupText = resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) ??
const displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent);
const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType);
const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ??
(toNonEmptyString(alternateMessage)
? resolveDisplayedCounterpartyMention(String(alternateMessage ?? ""), displayedCounterparties)
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
: null);
if (counterpartyFromFollowupText) {
previousFilters.counterparty = counterpartyFromFollowupText;
previousAnchorType = "counterparty";
previousAnchor = counterpartyFromFollowupText;
resolvedCounterpartyFromDisplay = true;
if (resolvedEntityFromFollowup) {
if (resolvedEntityFromFollowup.entityType === "counterparty") {
previousFilters.counterparty = resolvedEntityFromFollowup.value;
previousAnchorType = "counterparty";
previousAnchor = resolvedEntityFromFollowup.value;
resolvedCounterpartyFromDisplay = true;
}
else if (resolvedEntityFromFollowup.entityType === "contract") {
previousFilters.contract = resolvedEntityFromFollowup.value;
previousAnchorType = "contract";
previousAnchor = resolvedEntityFromFollowup.value;
}
if (followupSelectionMode !== "switch_to_suggested_intent") {
followupSelectionMode = "carry_referenced_entity";
}
@@ -3373,7 +3519,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
llmContractIntent === "unknown" &&
!followupContext &&
!hasClassifierSignal &&
!strongDataSignalFromRawMessage) {
!hasIntentSignal &&
!strongDataSignalFromRawMessage &&
!strongDataSignalFromEffectiveMessage) {
return {
runAddressLane: false,
decision: "skip_address_lane",
@@ -4510,7 +4658,7 @@ function isPlausibleOrganizationName(value) {
if (/(?:справочникссылка|документссылка|плансчетовссылка|standardodata|recordtype|cmp:)/i.test(candidate)) {
return false;
}
return /[A-Za-zА-Яа-яЁё]/u.test(candidate);
return /[A-Za-z\u0400-\u04FF]/u.test(candidate);
}
function appendOrganizationFactsFromValue(value, hints, bucket, depth = 0) {
if (depth > 4 || value === null || value === undefined) {