АДРЕСНЫЙ РЕЖИМ -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
@@ -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",