ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Систематизировать ответы по поставщику без поставок через анализ оплат и возвратов

This commit is contained in:
2026-04-17 02:08:02 +03:00
parent a3a61b3a0f
commit 44f1c1e11e
33 changed files with 22512 additions and 173 deletions
@@ -40,6 +40,9 @@ export interface VatDirectSourceProbeSummary {
interface ComposeFactualReplyOptions {
userMessage?: string;
itemHint?: string;
counterpartyHint?: string;
accountHint?: string;
periodFrom?: string;
periodTo?: string;
asOfDate?: string;
@@ -78,6 +81,7 @@ type CounterpartyProfileFocus =
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time";
type ValueRankingFocus =
| "top_by_total"
| "total_flow"
| "top_years_by_total"
| "top_by_ops"
| "top_by_max_single"
@@ -662,6 +666,13 @@ function detectValueRankingFocus(userMessage: string | null | undefined): ValueR
if (!text) {
return "top_by_total";
}
const asksTotalMoneyEarned =
/(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text) &&
!/(?:клиент|заказчик|покупател|контрагент|customer|client|counterpart)/iu.test(text) &&
!/(?:топ|top|сам(?:ый|ая|ое|ые)|наибольш|больше\s+всего|максимальн)/iu.test(text);
if (asksTotalMoneyEarned) {
return "total_flow";
}
const asksYearlyRevenueRanking =
/(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) &&
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
@@ -767,6 +778,16 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
return null;
}
function hasCounterpartyItemFlowQuestion(userMessage: string | undefined): boolean {
const text = String(userMessage ?? "").trim().toLowerCase();
if (!text) {
return false;
}
return /(?:что\s+нам\s+(?:отгруж|постав|привоз|прод)|како(?:й|е|го|му)\s+товар|какую\s+услуг|какие\s+товар|какие\s+услуг|товар\s+или\s+услуг|позици(?:ю|и|ях)?)/iu.test(
text
);
}
function extractInventoryItemName(row: ComposeStageRow): string | null {
const direct = String(row.item ?? "").trim();
if (direct) {
@@ -988,10 +1009,13 @@ function looksLikeInventoryPartyToken(value: string): boolean {
return normalized === normalized.toUpperCase() && normalized.length >= 4;
}
function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[] {
function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTokens: string[] = []): string[] {
const itemToken = normalizeEntityToken(extractInventoryItemName(row));
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
const excludedComparableTokens = excludedTokens
.map((token) => normalizeEntityToken(token))
.filter((token): token is string => Boolean(token));
const candidates: string[] = [];
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
@@ -999,7 +1023,13 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[]
continue;
}
const comparable = normalizeEntityToken(normalized);
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) {
if (
!comparable ||
comparable === itemToken ||
comparable === warehouseToken ||
comparable === organizationToken ||
excludedComparableTokens.includes(comparable)
) {
continue;
}
candidates.push(normalized);
@@ -1018,7 +1048,7 @@ interface InventoryTraceSummary {
totalAmount: number;
}
function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSummary {
function summarizeInventoryTraceRows(rows: ComposeStageRow[], excludedCounterpartyTokens: string[] = []): InventoryTraceSummary {
const items = uniqueStrings(
rows
.map((row) => extractInventoryItemName(row))
@@ -1034,7 +1064,9 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSum
.map((row) => extractInventoryOrganizationName(row))
.filter((item): item is string => Boolean(item))
);
const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row)));
const counterparties = uniqueStrings(
rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens))
);
const documents = uniqueStrings(
rows
.map((row) => String(row.registrator ?? "").trim())
@@ -1057,9 +1089,9 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSum
};
}
function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[] {
function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10, excludedCounterpartyTokens: string[] = []): string[] {
return rows.slice(0, limit).map((row, index) => {
const parties = extractInventoryCounterpartyCandidates(row);
const parties = extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens);
const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row);
const amount =
@@ -1082,6 +1114,35 @@ function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[]
});
}
function formatCounterpartyItemFlowRows(rows: ComposeStageRow[], limit = 12): string[] {
return rows.slice(0, limit).map((row, index) => {
const item = extractInventoryItemName(row) ?? "позиция не указана";
const contract = extractContractName(row);
const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row);
const quantity = extractInventoryQuantity(row);
const amount =
typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана";
const parts = [
`${index + 1}. ${item}`,
`договор: ${contract ?? "не указан"}`,
`документ: ${row.registrator}`,
`дата: ${inventoryTraceDateLabel(row.period)}`,
`сумма: ${amount}`
];
if (quantity !== null && quantity > 0) {
parts.push(`количество: ${formatNumberWithDots(quantity, 3)}`);
}
if (warehouse) {
parts.push(`склад: ${warehouse}`);
}
if (organization) {
parts.push(`организация: ${organization}`);
}
return parts.join(" | ");
});
}
interface InventoryAgingByItemAggregate {
item: string;
warehouse: string | null;
@@ -3296,6 +3357,8 @@ export function composeFactualReply(
const profileRows = Array.from(byCounterparty.values());
const yearRows = Array.from(byYear.values());
const totalFlow = profileRows.reduce((sum, item) => sum + item.total, 0);
const totalOperations = profileRows.reduce((sum, item) => sum + item.ops, 0);
const rankedByTotal = [...profileRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.name.localeCompare(b.name));
const rankedByYearTotal = [...yearRows].sort((a, b) => b.total - a.total || b.ops - a.ops || a.year - b.year);
const rankedByOps = [...profileRows].sort((a, b) => b.ops - a.ops || b.total - a.total || a.name.localeCompare(b.name));
@@ -3337,6 +3400,33 @@ export function composeFactualReply(
};
}
if (focus === "total_flow") {
const periodLine =
options.periodFrom && options.periodTo
? `За период ${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)} подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
: `За все доступное время подтверждено ${formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
const directAnswerLine = isSupplier
? periodLine
: `${periodLine} Это сумма денег, полученных от клиентов, а не чистая прибыль.`;
const summaryLines = [
directAnswerLine,
"",
"Подтверждение:",
`- Операций в выборке: ${totalOperations}.`,
`- Контрагентов в выборке: ${profileRows.length}.`
];
if (rankedByYearTotal.length > 0) {
summaryLines.push(`- Самый сильный год по поступлениям: ${rankedByYearTotal[0].year} (${formatMoneyRub(rankedByYearTotal[0].total)}).`);
}
if (rankedByTotal.length > 0) {
summaryLines.push(`- Крупнейший контрагент по потоку: ${rankedByTotal[0].name} (${formatMoneyRub(rankedByTotal[0].total)}).`);
}
return {
responseType: "FACTUAL_SUMMARY",
text: summaryLines.join("\n")
};
}
if (focus === "top_years_by_total") {
const visible = rankedByYearTotal.slice(0, limit);
const heading = isSupplier
@@ -4274,8 +4364,11 @@ export function composeFactualReply(
if (intent === "inventory_sale_trace_for_item") {
const asOfDate = resolvePayablesAsOfDate(options);
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
const summary = summarizeInventoryTraceRows(saleRows);
const itemLabel = summary.item ?? "товар не определен";
const requestedItemHint = String(options.itemHint ?? "").trim();
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
const summary = summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
const excludedCounterpartyTokens = [itemLabel];
const directAnswerLine =
summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
@@ -4296,7 +4389,7 @@ export function composeFactualReply(
}
lines.push("", "Документы выбытия:");
if (saleRows.length > 0) {
lines.push(...formatInventoryTraceRows(saleRows, 12));
lines.push(...formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens));
} else {
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
}
@@ -5023,8 +5116,12 @@ export function composeFactualReply(
if (intent === "open_items_by_counterparty_or_contract") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const accountLead =
typeof options.accountHint === "string" && options.accountHint.trim().length > 0
? `Проверил хвосты по счету ${options.accountHint.trim()}.`
: "Собраны открытые позиции по взаиморасчетам.";
const lines = [
"Собраны открытые позиции по взаиморасчетам.",
accountLead,
`Строк отобрано: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.`
];
@@ -5090,10 +5187,73 @@ export function composeFactualReply(
}
if (intent === "list_documents_by_counterparty") {
const lines = [
`Найдено документов по контрагенту: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
const resolvedCounterparty =
(typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
? options.counterpartyHint.trim()
: null) ??
(() => {
const counterparties = uniqueStrings(
rows
.map((row) => extractCounterpartyName(row))
.filter((item): item is string => Boolean(item))
);
return counterparties.length === 1 ? counterparties[0] : null;
})();
const counterpartyLabel =
typeof resolvedCounterparty === "string" && resolvedCounterparty.endsWith(".")
? resolvedCounterparty
: resolvedCounterparty
? `${resolvedCounterparty}.`
: null;
const counterpartyInline =
typeof counterpartyLabel === "string" ? counterpartyLabel.replace(/[.]+$/u, "") : resolvedCounterparty;
const itemFlowQuestion = hasCounterpartyItemFlowQuestion(options.userMessage);
const items = uniqueStrings(
rows
.map((row) => extractInventoryItemName(row))
.filter((item): item is string => Boolean(item))
);
const contracts = uniqueStrings(
rows
.map((row) => extractContractName(row))
.filter((item): item is string => Boolean(item))
);
const lines: string[] = [];
if (itemFlowQuestion) {
lines.push(
counterpartyInline
? `Контрагент: ${counterpartyInline}. Подтвержденных поставок товаров или услуг: ${rows.length}.`
: `Подтвержденных поставок товаров или услуг по запрошенному контрагенту: ${rows.length}.`
);
} else {
lines.push(
counterpartyInline
? `Контрагент: ${counterpartyInline}. Найдено документов: ${rows.length}.`
: `Найдено документов по контрагенту: ${rows.length}.`
);
}
if (counterpartyLabel) {
lines.push(`Контрагент: ${counterpartyLabel}`);
}
if (itemFlowQuestion) {
if (items.length > 0) {
lines.push(`Позиции: ${items.slice(0, 8).join("; ")}.`);
if (items.length > 8) {
lines.push(`Показаны первые 8 из ${items.length} позиций.`);
}
}
if (contracts.length === 1) {
lines.push(`Договор: ${contracts[0]}.`);
} else if (contracts.length > 1) {
lines.push(`Договоры в выборке: ${contracts.slice(0, 3).join("; ")}.`);
}
lines.push(...formatCounterpartyItemFlowRows(rows));
if (rows.length > 12) {
lines.push(`Показаны первые 12 из ${rows.length} поставок.`);
}
} else {
lines.push(...formatTopRows(rows, rows.length));
}
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
@@ -26,6 +26,7 @@ import type { AddressLlmSemanticHints } from "../../types/addressQuery";
export interface AddressFollowupContext {
previous_intent?: AddressIntent;
target_intent?: AddressIntent;
previous_filters?: AddressFilterSet;
previous_anchor_type?:
| "account"
@@ -98,7 +99,7 @@ function hasSameDateHint(text: string): boolean {
}
function hasSamePeriodHint(text: string): boolean {
return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test(
return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|на\s+этот\s+период|за\s+тот\s+период|на\s+тот\s+период|этот\s+период|тот\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test(
String(text ?? "")
);
}
@@ -672,6 +673,9 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
if (hasSameDateHint(normalized)) {
return true;
}
if (hasSamePeriodHint(normalized)) {
return true;
}
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
if (
@@ -853,7 +857,9 @@ function mergeFollowupFilters(
intent === "inventory_aging_by_purchase_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period"
) {
const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage);
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
@@ -935,6 +941,34 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context");
}
}
if (
samePeriodRequested &&
(intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date")
) {
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
reasons.push("period_from_from_followup_context");
}
if (previousPeriodTo && merged.period_to !== previousPeriodTo) {
merged.period_to = previousPeriodTo;
reasons.push("period_to_from_followup_context");
}
if (
intent === "vat_payable_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date"
) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
}
if (
samePeriodRequested &&
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date")
@@ -1146,6 +1180,17 @@ function mergeFollowupFilters(
merged.period_to = previousPeriodTo;
}
reasons.push("period_from_followup_context");
if (
intent === "vat_payable_confirmed_as_of_date" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date"
) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
}
if (
@@ -1199,7 +1244,7 @@ function deriveIntentWithFollowupContext(
userMessage: string,
followupContext: AddressFollowupContext | null
): AddressIntentResolution {
if (!followupContext || !followupContext.previous_intent) {
if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
return detectedIntent;
}
@@ -1208,7 +1253,11 @@ function deriveIntentWithFollowupContext(
if (!hasFollowupSignal) {
return detectedIntent;
}
const previousIntent = followupContext.previous_intent;
const sourceIntent = followupContext.previous_intent ?? null;
const fallbackIntent = followupContext.target_intent ?? sourceIntent;
if (!sourceIntent && !fallbackIntent) {
return detectedIntent;
}
const previousFilters = followupContext.previous_filters ?? {};
const previousContract = toNonEmptyString(previousFilters.contract);
const previousCounterparty = toNonEmptyString(previousFilters.counterparty);
@@ -1243,7 +1292,8 @@ function deriveIntentWithFollowupContext(
};
}
const previousIsBalanceFamily = previousIntent === "account_balance_snapshot" || previousIntent === "documents_forming_balance";
const previousIsBalanceFamily =
sourceIntent === "account_balance_snapshot" || sourceIntent === "documents_forming_balance";
if (
previousIsBalanceFamily &&
hasAccountSignal(normalizedMessage) &&
@@ -1262,7 +1312,7 @@ function deriveIntentWithFollowupContext(
};
}
const previousIsInventoryFamily = isInventoryIntent(previousIntent);
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
const inventorySelectedObjectFollowup =
hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
@@ -1273,7 +1323,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "bank_operations_by_counterparty" ||
detectedIntent.intent === "bank_operations_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent
detectedIntent.intent === sourceIntent
) {
return {
intent: "inventory_purchase_provenance_for_item",
@@ -1289,7 +1339,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "list_documents_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent
detectedIntent.intent === sourceIntent
) {
return {
intent: "inventory_purchase_documents_for_item",
@@ -1309,7 +1359,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "bank_operations_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
detectedIntent.intent === previousIntent
detectedIntent.intent === sourceIntent
) {
return {
intent: "inventory_profitability_for_item",
@@ -1323,7 +1373,7 @@ function deriveIntentWithFollowupContext(
if (
detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === previousIntent ||
detectedIntent.intent === sourceIntent ||
detectedIntent.intent === "inventory_on_hand_as_of_date"
) {
return {
@@ -1339,7 +1389,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_purchase_provenance_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent
detectedIntent.intent === sourceIntent
) {
return {
intent: "inventory_sale_trace_for_item",
@@ -1354,7 +1404,7 @@ function deriveIntentWithFollowupContext(
detectedIntent.intent === "unknown" ||
detectedIntent.intent === "inventory_sale_trace_for_item" ||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
detectedIntent.intent === previousIntent
detectedIntent.intent === sourceIntent
) {
return {
intent: "inventory_purchase_to_sale_chain",
@@ -1368,7 +1418,7 @@ function deriveIntentWithFollowupContext(
previousIsInventoryFamily &&
hasFollowupSignal &&
hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) &&
(detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent)
(detectedIntent.intent === "unknown" || detectedIntent.intent === sourceIntent)
) {
return {
intent: "inventory_purchase_provenance_for_item",
@@ -1431,7 +1481,7 @@ function deriveIntentWithFollowupContext(
}
return {
intent: previousIntent,
intent: fallbackIntent ?? "unknown",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_from_followup_context"]
};
@@ -101,6 +101,13 @@ function tokenizeAnchor(value: string): string[] {
.filter((token) => token.length >= 2 && !PARTY_ANCHOR_STOPWORDS.has(token));
}
function tokenizeSearchableText(value: string): string[] {
return normalizeSearchText(value)
.split(" ")
.map((token) => token.trim())
.filter(Boolean);
}
function anchorTokenVariants(token: string): string[] {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
@@ -123,9 +130,43 @@ function anchorTokenVariants(token: string): string[] {
return Array.from(variants);
}
function normalizePartyTokenSkeleton(value: string): string {
return normalizeSearchText(value).replace(/\s+/g, "").replace(/[аеёиоуыэюяaeiouy]+/giu, "");
}
function fuzzyPartyTokenMatches(candidate: string, token: string): boolean {
const normalizedCandidate = normalizeSearchText(candidate);
const normalizedToken = normalizeSearchText(token);
if (!normalizedCandidate || !normalizedToken) {
return false;
}
if (normalizedCandidate === normalizedToken) {
return true;
}
if (
normalizedCandidate.length < 4 ||
normalizedToken.length < 4 ||
/\d/u.test(normalizedCandidate) ||
/\d/u.test(normalizedToken)
) {
return false;
}
const candidateSkeleton = normalizePartyTokenSkeleton(normalizedCandidate);
const tokenSkeleton = normalizePartyTokenSkeleton(normalizedToken);
if (candidateSkeleton.length < 3 || tokenSkeleton.length < 3) {
return false;
}
return (
candidateSkeleton === tokenSkeleton ||
candidateSkeleton.startsWith(tokenSkeleton) ||
tokenSkeleton.startsWith(candidateSkeleton)
);
}
function matchesAnchorText(searchable: string, anchor: string): boolean {
const searchableNormalized = normalizeSearchText(searchable);
const searchableLatin = transliterateCyrillicToLatin(searchableNormalized);
const searchableTokens = tokenizeSearchableText(searchable);
const tokens = tokenizeAnchor(anchor);
if (tokens.length === 0) {
const direct = normalizeSearchText(anchor);
@@ -138,7 +179,11 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
const variants = anchorTokenVariants(token);
return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin);
return (
searchableNormalized.includes(variant) ||
searchableLatin.includes(tokenLatin) ||
searchableTokens.some((candidate) => fuzzyPartyTokenMatches(candidate, variant))
);
});
});
if (fullMatch) {