АДРЕСНЫЙ РЕЖИМ -ADDRESS:Шаг 2 - Универсализация value-вопросов общего домена (TOP-20, без словарей клиентов/поставщиков
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user