АДРЕСНЫЙ РЕЖИМ -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
@@ -34,6 +34,14 @@ type CounterpartyProfileFocus =
| "customers_only"
| "mixed_only";
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time";
type ValueRankingFocus =
| "top_by_total"
| "top_by_ops"
| "top_by_max_single"
| "top_by_avg_check_min_ops"
| "top_deals"
| "bottom_deals";
type ContractValueFocus = "top_by_turnover" | "bottom_by_turnover_active" | "top_by_docs";
interface YearAggPoint {
year: number;
@@ -142,6 +150,22 @@ function normalizeQuestionText(value: string | null | undefined): string {
.trim();
}
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
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: string | null | undefined): PeriodProfileFocus {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -255,6 +279,65 @@ function detectCounterpartyLifecycleFocus(userMessage: string | null | undefined
return "active_customers_period";
}
function detectMinOpsForAvgCheck(userMessage: string | null | undefined): number {
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: string | null | undefined): ValueRankingFocus {
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: string | null | undefined): ContractValueFocus {
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: string | null | undefined): number | null {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -290,6 +373,34 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
return null;
}
function extractContractName(row: ComposeStageRow): string | null {
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: YearAggPoint[],
yearOps: YearAggPoint[]
@@ -838,6 +949,326 @@ export function composeFactualReply(
};
}
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<
string,
{
name: string;
total: number;
ops: number;
maxSingle: number;
minSingle: number;
lastPeriod: string | null;
}
>();
const deals: Array<{ period: string | null; registrator: string; counterparty: string; amount: number }> = [];
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: string[] = [
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<
string,
{
contract: string;
turnover: number;
docs: number;
lastPeriod: string | null;
counterparties: Set<string>;
}
>();
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: string[] = [
"Собран профиль договоров по обороту/бюджету (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 = [
@@ -82,7 +82,10 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
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";
}