АДРЕСНЫЙ РЕЖИМ -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
@@ -2,7 +2,7 @@
import iconv from "iconv-lite";
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|\bпервые\b|\bтоп\b)\s*(\d{1,3})/i;
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-_:#]*?(\d{1,3})/iu;
const COUNTERPARTY_PATTERN =
/(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
@@ -798,7 +798,10 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
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";
const filters: AddressFilterSet = {
sort: "period_desc"
};
@@ -268,6 +268,50 @@ const CONTRACT_USAGE_OVERVIEW_HINTS = [
"contract usage overview"
];
const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [
"самые доходные клиенты",
"самые доходные заказчики",
"топ клиентов по сумме поступлений",
"топ заказчиков по сумме поступлений",
"кто нам больше всего занес",
"кто нам больше всего занёс",
"кто нам принес больше всего",
"кто нам принёс больше всего",
"кто платит чаще всего",
"средний чек клиентов",
"средний чек заказчиков",
"крупные сделки по поступлениям",
"маленькие сделки по поступлениям",
"smallest deals by inflow",
"largest deals by inflow",
"top customers by inflow",
"top customers by revenue"
];
const SUPPLIER_PAYOUTS_PROFILE_HINTS = [
"топ поставщиков по сумме выплат",
"кому мы больше всего заплатили",
"кому ушло больше всего денег",
"кому мы больше всего сгрузили денег",
"поставщики по выплатам",
"поставщики по исходящим платежам",
"поставщики с максимальным числом выплат",
"крупные разовые выплаты поставщикам",
"top suppliers by payouts",
"top suppliers by outgoing payments"
];
const CONTRACT_USAGE_AND_VALUE_HINTS = [
"договоры по обороту",
"договоры по сумме оборота",
"топ договоров по обороту",
"договоры с минимальным бюджетом",
"договоры с самым маленьким бюджетом",
"активные договоры по бюджету",
"contracts by turnover",
"contracts by budget"
];
const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
"договоры по",
"договора по",
@@ -283,6 +327,94 @@ function hasAny(text: string, patterns: string[]): boolean {
return patterns.some((item) => text.includes(item));
}
function tokenizeText(text: string): string[] {
return String(text ?? "")
.toLowerCase()
.split(/[^a-zа-яё0-9]+/iu)
.map((token) => token.trim())
.filter((token) => token.length > 0);
}
function trimRussianEnding(token: string): string {
return token.replace(
/(?:иями|ями|ами|ого|ему|ому|ыми|ими|ией|ей|ий|ый|ой|ях|ах|ов|ев|ам|ям|ом|ем|ы|и|а|я|у|ю|е|о)$/u,
""
);
}
function normalizeLexemeToken(rawToken: string): string {
const token = String(rawToken ?? "").toLowerCase().replace(/[^a-zа-яё0-9]+/gu, "");
if (!token) {
return "";
}
if (/^[a-z0-9]+$/u.test(token)) {
return token;
}
return trimRussianEnding(token);
}
function levenshteinDistance(a: string, b: string): number {
if (a === b) {
return 0;
}
if (!a.length) {
return b.length;
}
if (!b.length) {
return a.length;
}
const prev = new Array<number>(b.length + 1);
const curr = new Array<number>(b.length + 1);
for (let j = 0; j <= b.length; j += 1) {
prev[j] = j;
}
for (let i = 1; i <= a.length; i += 1) {
curr[0] = i;
for (let j = 1; j <= b.length; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(
prev[j] + 1,
curr[j - 1] + 1,
prev[j - 1] + cost
);
}
for (let j = 0; j <= b.length; j += 1) {
prev[j] = curr[j];
}
}
return prev[b.length];
}
function hasFuzzyLexeme(text: string, lexemeRoots: string[]): boolean {
const normalizedRoots = lexemeRoots
.map((root) => normalizeLexemeToken(root))
.filter((root) => root.length > 0);
if (normalizedRoots.length === 0) {
return false;
}
const tokens = tokenizeText(text)
.map((token) => normalizeLexemeToken(token))
.filter((token) => token.length >= 4);
for (const token of tokens) {
for (const root of normalizedRoots) {
if (token.includes(root)) {
return true;
}
if (root.includes(token) && token.length >= 5) {
return true;
}
const maxDistance = root.length >= 7 ? 2 : 1;
if (Math.abs(token.length - root.length) > maxDistance) {
continue;
}
if (levenshteinDistance(token, root) <= maxDistance) {
return true;
}
}
}
return false;
}
function hasCompactAccountCodeToken(text: string): boolean {
// Match compact account tokens like 60.01 / 62, while avoiding date fragments.
return /(?<![\d-])\d{2}(?:[.,]\d{1,2})?(?![\d-])/u.test(text);
@@ -472,6 +604,100 @@ function hasContractUsageOverviewSignal(text: string): boolean {
return false;
}
function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
if (hasAny(text, CUSTOMER_REVENUE_AND_PAYMENTS_HINTS)) {
return true;
}
if (hasContractAnchorSignal(text)) {
return false;
}
const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]);
const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]);
const hasSpecificCounterpartyAnchor =
hasLooseByAnchorMention(text) ||
hasHeuristicCounterpartyAnchor(text) ||
/(?:по\s+(?:клиент(?:у|а)?|заказчик(?:у|а)?|покупател(?:ю|я)|customer|client)\s+[a-zа-яё0-9])/iu.test(text);
const asksCustomerGroup =
/(?:клиент(?:ов|а|ы)?|заказчик(?:ов|а|и)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(text) ||
hasFuzzyCustomerLexeme ||
/(?:кто\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 asksValue =
/(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(
text
);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(
text
);
const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksValue;
if (asksCountOnly) {
return false;
}
if (hasSpecificCounterpartyAnchor && !asksRankOrTop) {
return false;
}
if (asksCustomerGroup && (asksValue || asksRankOrTop)) {
return true;
}
if (asksCounterpartySource && asksValue) {
return true;
}
if (!hasFuzzySupplierLexeme && asksIncomingFlow && asksRankOrTop) {
return true;
}
return false;
}
function hasSupplierPayoutsProfileSignal(text: string): boolean {
if (hasAny(text, SUPPLIER_PAYOUTS_PROFILE_HINTS)) {
return true;
}
if (hasContractAnchorSignal(text)) {
return false;
}
const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]);
const hasSpecificCounterpartyAnchor =
hasLooseByAnchorMention(text) ||
hasHeuristicCounterpartyAnchor(text) ||
/(?:по\s+(?:поставщик(?:у|а)?|supplier|vendor)\s+[a-zа-яё0-9])/iu.test(text);
const asksSupplierGroup =
/(?:поставщик(?:ов|а|и)?|supplier(?:s)?|vendor(?:s)?|к[ао]му\s+мы)/iu.test(text) ||
hasFuzzySupplierLexeme ||
/(?:кому\s+ушло|кому\s+платили|кому\s+заплатили)/iu.test(text);
const asksPayoutValue =
/(?:выплат|исходящ|списан|заплат|ушло|сгрузил|сгрузили|перевел|перевёл|отдали|платеж|платёж|outflow|payout)/iu.test(
text
);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|больше\s+всего|чаще\s+всего|максимальн|наибольш)/iu.test(
text
);
const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksPayoutValue;
if (asksCountOnly) {
return false;
}
if (hasSpecificCounterpartyAnchor && !asksRankOrTop) {
return false;
}
return asksSupplierGroup && (asksPayoutValue || asksRankOrTop);
}
function hasContractUsageAndValueSignal(text: string): boolean {
if (hasAny(text, CONTRACT_USAGE_AND_VALUE_HINTS)) {
return true;
}
if (!/(?:договор(?:ов|а|ы)?|contract(?:s)?)/iu.test(text)) {
return false;
}
if (hasContractUsageOverviewSignal(text)) {
return false;
}
const asksValue =
/(?:оборот|бюджет|сумм|стоим|value|turnover|amount|revenue|крупн|мелк|миним|максим)/iu.test(text);
const asksRank = /(?:топ|top|ранк|rank|сам(?:ый|ая|ое|ые))/iu.test(text);
return asksValue || asksRank;
}
function hasContractListByCounterpartySignal(text: string): boolean {
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
if (!hasContractLexeme) {
@@ -947,6 +1173,34 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasCustomerRevenueAndPaymentsSignal(text) && !hasAccountBalanceSignal(text)) {
return {
intent: "customer_revenue_and_payments",
confidence: "high",
reasons: ["customer_revenue_and_payments_signal_detected"]
};
}
if (hasSupplierPayoutsProfileSignal(text) && !hasAccountBalanceSignal(text)) {
return {
intent: "supplier_payouts_profile",
confidence: "high",
reasons: ["supplier_payouts_profile_signal_detected"]
};
}
if (
hasContractUsageAndValueSignal(text) &&
!hasAccountBalanceSignal(text) &&
!hasOpenContractsListSignal(text)
) {
return {
intent: "contract_usage_and_value",
confidence: "high",
reasons: ["contract_usage_and_value_signal_detected"]
};
}
if (hasContractListByCounterpartySignal(text)) {
return {
intent: "list_contracts_by_counterparty",
@@ -21,6 +21,7 @@ const ADDRESS_ACTION_TOKENS = [
"кто",
"кому",
"какие",
"каких",
"что по",
"че по",
"чё по",
@@ -81,6 +82,15 @@ const ADDRESS_ENTITY_TOKENS = [
"кредитор",
"аванс",
"оплат",
"приход",
"чек",
"доход",
"выруч",
"сделк",
"бюджет",
"топ",
"самый",
"самые",
"поступлен",
"поступлени",
"списан",
@@ -372,6 +372,9 @@ function shouldAttemptCounterpartyCatalogResolution(intent: AddressIntent, filte
return false;
}
return (
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
@@ -273,6 +273,66 @@ const CONTRACT_USAGE_OVERVIEW_QUERY_TEMPLATE = `
Регистратор
`;
const CUSTOMER_REVENUE_PROFILE_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкПоступление.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
УПОРЯДОЧИТЬ ПО
БанкПоступление.Дата __ORDER_DIRECTION__
`;
const SUPPLIER_PAYOUT_PROFILE_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT__
УПОРЯДОЧИТЬ ПО
БанкСписание.Дата __ORDER_DIRECTION__
`;
const CONTRACT_VALUE_PROFILE_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкПоступление.Дата КАК Период,
"CT_VALUE_IN" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкПоступление.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN_VALUE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период,
"CT_VALUE_OUT" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
БанкСписание.СуммаДокумента КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкСписание.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(БанкСписание.ДоговорКонтрагента) КАК Договор
ИЗ
Документ.СписаниеСРасчетногоСчета КАК БанкСписание
__WHERE_OUT_VALUE__
УПОРЯДОЧИТЬ ПО
Период __ORDER_DIRECTION__
`;
const CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
@@ -336,6 +396,36 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred",
query_template: "contract_usage_profile"
},
{
recipe_id: "address_customer_revenue_and_payments_v1",
intent: "customer_revenue_and_payments",
purpose: "Build customer value ranking and incoming deal profile from bank inflow docs",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 20,
account_scope_mode: "preferred",
query_template: "customer_revenue_profile"
},
{
recipe_id: "address_supplier_payouts_profile_v1",
intent: "supplier_payouts_profile",
purpose: "Build supplier payout ranking and outgoing deal profile from bank outflow docs",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 20,
account_scope_mode: "preferred",
query_template: "supplier_payout_profile"
},
{
recipe_id: "address_contract_usage_and_value_v1",
intent: "contract_usage_and_value",
purpose: "Build contract turnover/value ranking from bank inflow/outflow docs linked to contracts",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 20,
account_scope_mode: "preferred",
query_template: "contract_value_profile"
},
{
recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty",
@@ -523,6 +613,12 @@ function buildUsedContractWhereClause(filters: AddressFilterSet, fieldPath: stri
]);
}
function buildContractValueWhereClause(filters: AddressFilterSet, fieldPath: string, contractFieldPath: string): string {
return buildWhereClause(filters, fieldPath, [
`${contractFieldPath} <> ЗНАЧЕНИЕ(Справочник.ДоговорыКонтрагентов.ПустаяСсылка)`
]);
}
function normalizeAccountTokenForQuery(value: string): string {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/^(\d{2})(?:\.(\d{1,2}))?/);
@@ -592,6 +688,9 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "counterparty_population_and_roles" ||
intent === "counterparty_activity_lifecycle" ||
intent === "contract_usage_overview" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
@@ -706,6 +805,28 @@ export function buildAddressRecipePlan(
"__WHERE_IN_USED__",
buildUsedContractWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.ДоговорКонтрагента")
)
: recipe.query_template === "customer_revenue_profile"
? CUSTOMER_REVENUE_PROFILE_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__WHERE_IN__", buildWhereClause(filters, "БанкПоступление.Дата"))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "supplier_payout_profile"
? SUPPLIER_PAYOUT_PROFILE_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__WHERE_OUT__", buildWhereClause(filters, "БанкСписание.Дата"))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "contract_value_profile"
? CONTRACT_VALUE_PROFILE_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll(
"__WHERE_IN_VALUE__",
buildContractValueWhereClause(filters, "БанкПоступление.Дата", "БанкПоступление.ДоговорКонтрагента")
)
.replaceAll(
"__WHERE_OUT_VALUE__",
buildContractValueWhereClause(filters, "БанкСписание.Дата", "БанкСписание.ДоговорКонтрагента")
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: MOVEMENTS_QUERY_TEMPLATE
@@ -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";
}
@@ -6,6 +6,9 @@ export type AddressIntent =
| "counterparty_population_and_roles"
| "counterparty_activity_lifecycle"
| "contract_usage_overview"
| "customer_revenue_and_payments"
| "supplier_payouts_profile"
| "contract_usage_and_value"
| "list_contracts_by_counterparty"
| "list_open_contracts"
| "list_payables_counterparties"
@@ -113,6 +116,9 @@ export interface AddressRecipeDefinition {
| "counterparty_roles_profile"
| "counterparty_lifecycle_profile"
| "contract_usage_profile"
| "customer_revenue_profile"
| "supplier_payout_profile"
| "contract_value_profile"
| "contracts_by_counterparty_profile";
required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>;