АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 2 - Универсализация value-вопросов общего домена (TOP-20, без словарей клиентов/поставщиков

This commit is contained in:
2026-04-03 00:05:58 +03:00
parent 88094c09f8
commit 58b293a3e4
18 changed files with 2126 additions and 20 deletions
@@ -93,6 +93,21 @@ function normalizeQuestionText(value) {
.replace(/\s+/g, " ")
.trim();
}
function detectRankingLimit(userMessage, fallback = 20) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return fallback;
}
const match = text.match(/(?:\btop\b|\blimit\b|первые|топ)[\s\-–—_:№#]*?(\d{1,3})/iu);
if (!match) {
return fallback;
}
const parsed = Number(match[1]);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.min(200, Math.trunc(parsed));
}
function detectPeriodProfileFocus(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -181,6 +196,60 @@ function detectCounterpartyLifecycleFocus(userMessage) {
}
return "active_customers_period";
}
function detectMinOpsForAvgCheck(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return 3;
}
const explicit = text.match(/(?:мин(?:имум)?\s*|minimum\s*)(\d{1,2})/iu);
if (!explicit) {
return 3;
}
const parsed = Number(explicit[1]);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 3;
}
return Math.min(20, Math.trunc(parsed));
}
function detectValueRankingFocus(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "top_by_total";
}
if (/(?:сам(?:ый|ая|ое|ые)\s+высок[а-яё]*|highest|largest)\s+чек|(?:max\s+check|чек\s+макс)/iu.test(text)) {
return "top_by_max_single";
}
if (/(?:сам(?:ые|ый|ая)\s+мал|наименьш|минимал|smallest|tiny|мелк)/iu.test(text) && /(?:сделк|deal|бюджет)/iu.test(text)) {
return "bottom_deals";
}
if (/(?:сам(?:ые|ый|ая)\s+(?:круп|высок)|largest|highest|жирн|max)/iu.test(text) &&
/(?:сделк|deal|платеж|платёж|выплат|поступлен|приход|входящ)/iu.test(text)) {
return "top_deals";
}
if (/(?:средн(?:ий|его)\s+чек|avg(?:erage)?\s+check|average\s+payment)/iu.test(text)) {
return "top_by_avg_check_min_ops";
}
if (/(?:макс(?:имальн)?(?:ой|ая|ое)?\s+сумм|max\s+single|largest\s+single)/iu.test(text)) {
return "top_by_max_single";
}
if (/(?:по\s+количеств|частот|чаще\s+всего|most\s+frequent|ops?\s+count)/iu.test(text)) {
return "top_by_ops";
}
return "top_by_total";
}
function detectContractValueFocus(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return "top_by_turnover";
}
if (/(?:документ|docs?|documents?|по\s+количеств)/iu.test(text)) {
return "top_by_docs";
}
if (/(?:минимал|мал(?:еньк)?|smallest|least|мелк)/iu.test(text) && /(?:бюджет|оборот|turnover|budget|sum)/iu.test(text)) {
return "bottom_by_turnover_active";
}
return "top_by_turnover";
}
function extractRequestedYearFromQuestion(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -214,6 +283,33 @@ function extractCounterpartyName(row) {
}
return null;
}
function extractContractName(row) {
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
continue;
}
if (/(?:договор|contract|дог\.)/iu.test(normalized)) {
return normalized;
}
}
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
continue;
}
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
continue;
}
if (normalized.length >= 3 && /[\\/]/.test(normalized)) {
return normalized;
}
}
return null;
}
function deriveOperationalYearWindow(yearDocs, yearOps) {
const docsSeries = [...yearDocs].sort((a, b) => a.year - b.year);
const fallbackSeries = [...yearOps].sort((a, b) => a.year - b.year);
@@ -660,6 +756,230 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n")
};
}
if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") {
const isSupplier = intent === "supplier_payouts_profile";
const focus = detectValueRankingFocus(options.userMessage);
const limit = detectRankingLimit(options.userMessage, 20);
const minOpsForAvgCheck = detectMinOpsForAvgCheck(options.userMessage);
const normalizedQuestion = normalizeQuestionText(options.userMessage);
const byCounterparty = new Map();
const deals = [];
for (const row of rows) {
const counterparty = extractCounterpartyName(row);
const amount = row.amount ?? 0;
if (!counterparty || !Number.isFinite(amount) || amount <= 0) {
continue;
}
const current = byCounterparty.get(counterparty);
if (!current) {
byCounterparty.set(counterparty, {
name: counterparty,
total: amount,
ops: 1,
maxSingle: amount,
minSingle: amount,
lastPeriod: row.period
});
}
else {
current.total += amount;
current.ops += 1;
current.maxSingle = Math.max(current.maxSingle, amount);
current.minSingle = Math.min(current.minSingle, amount);
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
}
deals.push({
period: row.period,
registrator: row.registrator,
counterparty,
amount
});
}
const profileRows = Array.from(byCounterparty.values());
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
const rankedByMaxSingle = [...profileRows].sort((a, b) => b.maxSingle - a.maxSingle || b.total - a.total || a.name.localeCompare(b.name));
const rankedByAvgCheck = [...profileRows]
.filter((item) => item.ops >= minOpsForAvgCheck)
.map((item) => ({
...item,
avgCheck: item.total / item.ops
}))
.sort((a, b) => b.avgCheck - a.avgCheck || b.total - a.total || a.name.localeCompare(b.name));
const rankedDealsTop = [...deals].sort((a, b) => b.amount - a.amount || (b.period ?? "").localeCompare(a.period ?? ""));
const activeOnlyForBottomDeals = /(?:активн|active)/iu.test(normalizedQuestion);
const activeCounterpartiesForBottom = new Set(profileRows.filter((item) => item.ops >= Math.max(3, minOpsForAvgCheck)).map((item) => item.name));
const rankedDealsBottom = [...deals]
.filter((item) => !activeOnlyForBottomDeals || activeCounterpartiesForBottom.has(item.counterparty))
.sort((a, b) => a.amount - b.amount || (a.period ?? "").localeCompare(b.period ?? ""));
const lines = [
isSupplier
? "Собран профиль выплат поставщикам (bank-doc value aggregate)."
: "Собран профиль поступлений от заказчиков (bank-doc value aggregate).",
`Строк источника: ${rows.length}.`,
`Уникальных контрагентов: ${profileRows.length}.`
];
if (profileRows.length === 0) {
lines.push("По выбранному окну данных платежные строки не найдены.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (focus === "top_by_ops") {
const visible = rankedByOps.slice(0, limit);
lines.push(isSupplier
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (focus === "top_by_max_single") {
const visible = rankedByMaxSingle.slice(0, limit);
lines.push(isSupplier
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (focus === "top_by_avg_check_min_ops") {
const visible = rankedByAvgCheck.slice(0, limit);
lines.push(isSupplier
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`);
if (visible.length === 0) {
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
}
else {
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | средний чек: ${item.avgCheck.toFixed(2)} | операций: ${item.ops} | сумма: ${item.total}`));
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (focus === "top_deals") {
const visible = rankedDealsTop.slice(0, limit);
lines.push(isSupplier
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (focus === "bottom_deals") {
const visible = rankedDealsBottom.slice(0, limit);
lines.push(isSupplier
? `Топ-${visible.length} самых маленьких разовых выплат:`
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`);
if (activeOnlyForBottomDeals) {
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
}
lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
const visible = rankedByTotal.slice(0, limit);
lines.push(isSupplier
? `Топ-${visible.length} поставщиков по сумме выплат:`
: `Топ-${visible.length} заказчиков по сумме поступлений:`);
lines.push(...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0";
return `${index + 1}. ${item.name} | сумма: ${item.total} | операций: ${item.ops} | средний чек: ${avgCheck} | макс: ${item.maxSingle}`;
}));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "contract_usage_and_value") {
const focus = detectContractValueFocus(options.userMessage);
const limit = detectRankingLimit(options.userMessage, 20);
const byContract = new Map();
for (const row of rows) {
const contract = extractContractName(row);
const amount = row.amount ?? 0;
if (!contract || !Number.isFinite(amount) || amount <= 0) {
continue;
}
const counterparty = extractCounterpartyName(row);
const current = byContract.get(contract);
if (!current) {
byContract.set(contract, {
contract,
turnover: amount,
docs: 1,
lastPeriod: row.period,
counterparties: new Set(counterparty ? [counterparty] : [])
});
}
else {
current.turnover += amount;
current.docs += 1;
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
if (counterparty) {
current.counterparties.add(counterparty);
}
}
}
const contractRows = Array.from(byContract.values());
const rankedByTurnover = [...contractRows].sort((a, b) => b.turnover - a.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
const rankedByDocs = [...contractRows].sort((a, b) => b.docs - a.docs || b.turnover - a.turnover || a.contract.localeCompare(b.contract));
const rankedBottomActive = [...contractRows]
.filter((item) => item.docs > 0 && item.turnover > 0)
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
const lines = [
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
`Строк источника: ${rows.length}.`,
`Активных договоров: ${contractRows.length}.`
];
if (contractRows.length === 0) {
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (focus === "top_by_docs") {
const visible = rankedByDocs.slice(0, limit);
lines.push(`Топ-${visible.length} договоров по количеству операций:`);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${item.turnover} | контрагентов: ${item.counterparties.size}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (focus === "bottom_by_turnover_active") {
const visible = rankedBottomActive.slice(0, limit);
lines.push(`Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
const visible = rankedByTurnover.slice(0, limit);
lines.push(`Топ-${visible.length} договоров по сумме оборота:`);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
};
}
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
@@ -40,7 +40,10 @@ function inferAggregationProfile(intent, shape) {
intent === "document_type_and_account_section_profile" ||
intent === "counterparty_population_and_roles" ||
intent === "counterparty_activity_lifecycle" ||
intent === "contract_usage_overview") {
intent === "contract_usage_overview" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value") {
return "management_profile";
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {