ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов Stage 3.7 ХВОСТЫ фикс маршрутов по домену задолжностей
This commit is contained in:
@@ -270,6 +270,7 @@ const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [
|
||||
"кто ушел",
|
||||
"кто ушёл",
|
||||
"только один раз",
|
||||
"дольше всего",
|
||||
"дольше всех",
|
||||
"долгоживущие контрагенты",
|
||||
"регулярные поставщики",
|
||||
@@ -661,14 +662,27 @@ function hasCounterpartyPopulationAndRolesSignal(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasLifecycleSegmentationSignal(text: string): boolean {
|
||||
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(
|
||||
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|дольше\s+всего|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function hasCounterpartyDebtLongevitySignal(text: string): boolean {
|
||||
const hasCounterpartyLexeme =
|
||||
/(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasDebtLexeme = /(?:долг(?:и|ов|а|у)?|задолж(?:енность|енности|енностям|ал|али)?|просроч|хвост)/iu.test(text);
|
||||
const hasLongevityCue =
|
||||
/(?:долгожив|долгожител|несколько\s+месяц|по\s+годам|дольше|лет|год(?:ам|а|у|ы)?|на\s+этот\s+момент|длительн)/iu.test(
|
||||
text
|
||||
);
|
||||
return hasCounterpartyLexeme && hasDebtLexeme && hasLongevityCue;
|
||||
}
|
||||
|
||||
function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
|
||||
const hasPaymentRiskLexeme =
|
||||
/(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|долг|задолж)/iu.test(
|
||||
/(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(
|
||||
text
|
||||
);
|
||||
if (hasPaymentRiskLexeme) {
|
||||
@@ -680,14 +694,14 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
|
||||
if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:сколько|скока|скок)\s+/iu.test(text)) {
|
||||
if (/(?:сколько|скока|скок)\s+/iu.test(text) && !hasLifecycleSegmentationSignal(text)) {
|
||||
return false;
|
||||
}
|
||||
const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasActivityLexeme =
|
||||
/(?:работал(?:и)?|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test(
|
||||
/(?:работал(?:и)?|работа(?:ет|ют)|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test(
|
||||
text
|
||||
);
|
||||
const hasTimeWindowLexeme =
|
||||
@@ -936,9 +950,9 @@ function hasOpenContractsListSignal(text: string): boolean {
|
||||
|
||||
function hasSupplierTailRiskSignal(text: string): boolean {
|
||||
const hasSupplier = /(?:поставщик|supplier|vendor)/iu.test(text);
|
||||
const hasTail = /(?:хвост|висят|незакрыт|задолж|долг|просроч)/iu.test(text);
|
||||
const hasTail = /(?:хвост|висят|незакрыт|не\s+закрыв|задолж|долг|просроч|сч[её]т)/iu.test(text);
|
||||
const hasRisk = /(?:систематич|регулярн|проблем|тревог|не\s+разов|больше\s+похож)/iu.test(text);
|
||||
const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц)/iu.test(text);
|
||||
const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text);
|
||||
return hasSupplier && hasTail && (hasRisk || hasPeriodCue);
|
||||
}
|
||||
|
||||
@@ -948,11 +962,30 @@ function hasReceivablesLatencyRiskSignal(text: string): boolean {
|
||||
const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text);
|
||||
const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text);
|
||||
const hasDelay = /(?:длинн|долг|просроч|задерж|висят|тревог|too\s+long|late)/iu.test(text);
|
||||
const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text);
|
||||
const hasPeriodOrRiskCue = /(?:за\s+текущ|на\s+конец|тревог|просроч|задерж|долг|длинн)/iu.test(text);
|
||||
const hasOverdueDeadlineCue =
|
||||
/(?:срок(?:и|а)?(?:\s+оплат[ыы]?)?[\s\S]{0,24}(?:прош|выш|истек|истёк)|срок(?:и|а)?\s+давно\s+прошл|давно\s+пора\s+оплат|давно\s+не\s+оплач)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|не\s+оплат|не\s+оплач|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text);
|
||||
const hasPaymentShipmentImbalance =
|
||||
/(?:оплач(?:ено|ен[аоы]?|ивать|ивать)?\s+меньше[\s\S]{0,36}отгруж|недоплат[\s\S]{0,36}отгруж|отгруж[\s\S]{0,36}оплач(?:ено|ено\s+меньше))/iu.test(
|
||||
text
|
||||
);
|
||||
const hasNegativeSaldoRisk = /(?:сальд[оа]\s+(?:уже\s+)?отрицат|минусов(?:ое|ой)\s+сальдо|сальдо\s+в\s+минус)/iu.test(text);
|
||||
const hasPeriodOrRiskCue =
|
||||
/(?:за\s+текущ|на\s+конец|тревог|просроч|задерж|долг|длинн|несколько\s+месяц|больше\s+месяц)/iu.test(text) ||
|
||||
hasOverdueDeadlineCue ||
|
||||
hasNegativeSaldoRisk;
|
||||
const hasBetweenShipmentAndPayment =
|
||||
/между[\s\S]{0,80}(?:отправк|отгруз|реализ)[\s\S]{0,80}(?:оплат|платеж|платёж|payment)/iu.test(text);
|
||||
if (hasBuyer && hasPayment && ((hasShipment && hasDelay) || hasBetweenShipmentAndPayment)) {
|
||||
if (
|
||||
hasBuyer &&
|
||||
hasPayment &&
|
||||
((hasShipment && (hasDelay || hasOverdueDeadlineCue)) || hasBetweenShipmentAndPayment || hasPaymentShipmentImbalance)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if ((hasBuyer || hasCounterparty) && hasPaymentShipmentImbalance) {
|
||||
return true;
|
||||
}
|
||||
return (hasBuyer || hasCounterparty) && hasNonPayment && hasPeriodOrRiskCue;
|
||||
@@ -961,24 +994,50 @@ function hasReceivablesLatencyRiskSignal(text: string): boolean {
|
||||
function hasSettlementGapSignal(text: string): boolean {
|
||||
const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text);
|
||||
const hasDocument = /(?:док(?:и|умент|ументы|ументов)|docs?|documents?)/iu.test(text);
|
||||
const hasShipment = /(?:отгруз|реализ|shipment|delivery|товар|услуг)/iu.test(text);
|
||||
const hasAdvance = /(?:аванс|предоплат)/iu.test(text);
|
||||
const hasClosureLexeme = /(?:закрыти|взаиморасч|акт|сч[её]т(?:ов|а|ы)?)/iu.test(text);
|
||||
const hasNoDocumentForClosing =
|
||||
/(?:нет|без)\s+(?:док(?:и|умент|ументы|ументов)|закрывающ)/iu.test(text) &&
|
||||
/(?:закрыти|взаиморасч|акт)/iu.test(text);
|
||||
hasClosureLexeme;
|
||||
const hasNoDocumentForClosingReversed =
|
||||
/(?:док(?:и|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) &&
|
||||
/(?:закрыти|взаиморасч|акт)/iu.test(text);
|
||||
hasClosureLexeme;
|
||||
const hasNoPayments =
|
||||
/(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) ||
|
||||
/(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text);
|
||||
const hasDocsWithoutPayments = hasDocument && hasNoPayments;
|
||||
const hasPaymentsWithoutClosingDocs = hasPayment && (hasNoDocumentForClosing || hasNoDocumentForClosingReversed);
|
||||
const hasPaymentsWithoutSettlementClosure =
|
||||
hasPayment &&
|
||||
/(?:без|нет)\s+закрыти(?:я|й)?(?:\s+взаиморасч[её]тов)?/iu.test(text) &&
|
||||
hasClosureLexeme;
|
||||
const hasShipmentWithoutClosingDocs =
|
||||
hasShipment &&
|
||||
(hasNoDocumentForClosing ||
|
||||
hasNoDocumentForClosingReversed ||
|
||||
/(?:без|нет)\s+док(?:и|умент(?:ов|ы|а)?)\s+(?:для\s+)?(?:их\s+)?закрыти/u.test(text));
|
||||
const hasClosingWithoutSupportingDocs =
|
||||
hasClosureLexeme &&
|
||||
/(?:без|нет)\s+подтверждающ(?:их|его|ие)?\s+док(?:и|умент(?:ов|ы|а)?)/iu.test(text);
|
||||
const hasAdvanceStuckRisk =
|
||||
/(?:зависш(?:ий|ие|ая|ие\s+аванс)|давно\s+пора\s+закрыть|пора\s+закрывать|перепривяз(?:ать|к)|списыв(?:ать|ани|ан)|нереальн)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasUnclosedAdvanceGap =
|
||||
hasAdvance &&
|
||||
(/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт)/iu.test(text) ||
|
||||
(/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт|давно\s+пора\s+закрыть)/iu.test(text) ||
|
||||
hasAdvanceStuckRisk ||
|
||||
hasNoDocumentForClosing ||
|
||||
hasNoDocumentForClosingReversed);
|
||||
return hasPaymentsWithoutClosingDocs || hasDocsWithoutPayments || hasUnclosedAdvanceGap;
|
||||
return (
|
||||
hasPaymentsWithoutClosingDocs ||
|
||||
hasPaymentsWithoutSettlementClosure ||
|
||||
hasDocsWithoutPayments ||
|
||||
hasShipmentWithoutClosingDocs ||
|
||||
hasClosingWithoutSupportingDocs ||
|
||||
hasUnclosedAdvanceGap
|
||||
);
|
||||
}
|
||||
|
||||
function hasReconciliationMismatchSignal(text: string): boolean {
|
||||
@@ -1376,6 +1435,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCounterpartyDebtLongevitySignal(text)) {
|
||||
return {
|
||||
intent: "list_receivables_counterparties",
|
||||
confidence: "medium",
|
||||
reasons: ["receivables_debt_lifecycle_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasSupplierTailRiskSignal(text)) {
|
||||
return {
|
||||
intent: "list_payables_counterparties",
|
||||
@@ -1410,6 +1477,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
|
||||
if (
|
||||
hasAny(text, OPEN_ITEMS_HINTS) &&
|
||||
!hasCounterpartyDebtLongevitySignal(text) &&
|
||||
/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(
|
||||
text
|
||||
)
|
||||
|
||||
@@ -715,6 +715,103 @@ function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddres
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parseIsoDateUtcTimestamp(value: string | null | undefined): number | null {
|
||||
const source = String(value ?? "").trim();
|
||||
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||||
return null;
|
||||
}
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return null;
|
||||
}
|
||||
return Date.UTC(year, month - 1, day);
|
||||
}
|
||||
|
||||
function isCounterpartyRiskIntent(intent: AddressIntent): boolean {
|
||||
return (
|
||||
intent === "list_receivables_counterparties" ||
|
||||
intent === "list_payables_counterparties" ||
|
||||
intent === "list_open_contracts" ||
|
||||
intent === "open_items_by_counterparty_or_contract"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFutureGuardReferenceDate(analysisDate: string | null, filters: AddressFilterSet): string | null {
|
||||
if (analysisDate) {
|
||||
return analysisDate;
|
||||
}
|
||||
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
|
||||
if (asOfDate) {
|
||||
return asOfDate;
|
||||
}
|
||||
const periodTo = normalizeAnalysisDateHint(filters.period_to);
|
||||
if (periodTo) {
|
||||
return periodTo;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isMissingSubcontoFieldError(errorText: string | null | undefined): boolean {
|
||||
const normalized = String(errorText ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalized.includes("поле не найдено") &&
|
||||
(normalized.includes("субконтодт1") ||
|
||||
normalized.includes("subcontodt1") ||
|
||||
normalized.includes("subconto_dt1"))
|
||||
);
|
||||
}
|
||||
|
||||
function applyFutureDatedRowsGuard(
|
||||
rows: NormalizedAddressRow[],
|
||||
intent: AddressIntent,
|
||||
referenceDate: string | null
|
||||
): { rows: NormalizedAddressRow[]; droppedCount: number } {
|
||||
if (!isCounterpartyRiskIntent(intent) || rows.length === 0) {
|
||||
return {
|
||||
rows,
|
||||
droppedCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
const referenceTs = (() => {
|
||||
const explicitTs = parseIsoDateUtcTimestamp(referenceDate);
|
||||
if (explicitTs !== null) {
|
||||
return explicitTs;
|
||||
}
|
||||
const now = new Date();
|
||||
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
})();
|
||||
const guardTailMs = 31 * 24 * 60 * 60 * 1000;
|
||||
const latestAllowedTs = referenceTs + guardTailMs;
|
||||
|
||||
const keptRows: NormalizedAddressRow[] = [];
|
||||
let droppedCount = 0;
|
||||
for (const row of rows) {
|
||||
const rowTs = parseIsoDateUtcTimestamp(row.period);
|
||||
if (rowTs !== null && rowTs > latestAllowedTs) {
|
||||
droppedCount += 1;
|
||||
continue;
|
||||
}
|
||||
keptRows.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
rows: keptRows,
|
||||
droppedCount
|
||||
};
|
||||
}
|
||||
|
||||
function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
|
||||
return (
|
||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
|
||||
@@ -745,6 +842,8 @@ function isAnchorRecoveryIntent(intent: AddressIntent): boolean {
|
||||
intent === "list_contracts_by_counterparty" ||
|
||||
intent === "list_documents_by_contract" ||
|
||||
intent === "bank_operations_by_contract" ||
|
||||
intent === "list_payables_counterparties" ||
|
||||
intent === "list_receivables_counterparties" ||
|
||||
intent === "open_items_by_counterparty_or_contract" ||
|
||||
intent === "list_open_contracts"
|
||||
);
|
||||
@@ -1483,10 +1582,20 @@ export class AddressQueryService {
|
||||
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
|
||||
userMessage,
|
||||
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
|
||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined
|
||||
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
|
||||
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined
|
||||
});
|
||||
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, filters.extracted_filters);
|
||||
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
|
||||
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
|
||||
const debtLifecycleReceivablesScenario =
|
||||
intent.intent === "list_receivables_counterparties" &&
|
||||
Array.isArray(intent.reasons) &&
|
||||
intent.reasons.includes("receivables_debt_lifecycle_signal_detected");
|
||||
const recipeIntent = debtLifecycleReceivablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent;
|
||||
const recipeSelection = selectAddressRecipe(recipeIntent, filters.extracted_filters);
|
||||
if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) {
|
||||
baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle");
|
||||
}
|
||||
|
||||
if (intent.intent === "unknown") {
|
||||
return buildLimitedExecutionResult({
|
||||
@@ -1508,30 +1617,6 @@ export class AddressQueryService {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
intent.intent === "open_items_by_counterparty_or_contract" &&
|
||||
!filters.extracted_filters.counterparty &&
|
||||
!filters.extracted_filters.contract
|
||||
) {
|
||||
return buildLimitedExecutionResult({
|
||||
mode,
|
||||
shape,
|
||||
intent,
|
||||
filters: filters.extracted_filters,
|
||||
missingRequiredFilters: ["counterparty_or_contract"],
|
||||
selectedRecipe: null,
|
||||
anchor,
|
||||
mcpCallStatus: "skipped",
|
||||
rowsFetched: 0,
|
||||
rowsMatched: 0,
|
||||
category: "missing_anchor",
|
||||
reasonText: "для open_items нужен якорь контрагента или договора",
|
||||
nextStep: "укажите контрагента или номер/название договора",
|
||||
limitations: ["open_items_requires_counterparty_or_contract_filter"],
|
||||
reasons: baseReasons
|
||||
});
|
||||
}
|
||||
|
||||
if (recipeSelection.selected_recipe === null) {
|
||||
return buildLimitedExecutionResult({
|
||||
mode,
|
||||
@@ -1631,11 +1716,40 @@ export class AddressQueryService {
|
||||
}
|
||||
}
|
||||
|
||||
const plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters);
|
||||
const mcp = await executeAddressMcpQuery({
|
||||
let plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters);
|
||||
let mcp = await executeAddressMcpQuery({
|
||||
query: plan.query,
|
||||
limit: plan.limit
|
||||
});
|
||||
if (
|
||||
mcp.error &&
|
||||
recipeSelection.selected_recipe.recipe_id === "address_movements_receivables_v1" &&
|
||||
isMissingSubcontoFieldError(mcp.error)
|
||||
) {
|
||||
const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", filters.extracted_filters);
|
||||
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
|
||||
const fallbackPlan = buildAddressRecipePlan(fallbackSelection.selected_recipe, filters.extracted_filters);
|
||||
const fallbackMcp = await executeAddressMcpQuery({
|
||||
query: fallbackPlan.query,
|
||||
limit: fallbackPlan.limit
|
||||
});
|
||||
if (!fallbackMcp.error) {
|
||||
plan = fallbackPlan;
|
||||
mcp = fallbackMcp;
|
||||
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) {
|
||||
baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items");
|
||||
}
|
||||
} else {
|
||||
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) {
|
||||
baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) {
|
||||
baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mcp.error) {
|
||||
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
|
||||
@@ -1696,7 +1810,21 @@ export class AddressQueryService {
|
||||
});
|
||||
const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching);
|
||||
const filterByAnchors = anchorFilter.rows;
|
||||
const filteredRows = applyIntentSpecificFilter(intent.intent, filterByAnchors);
|
||||
const filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors);
|
||||
const filteredRowsFutureGuard = applyFutureDatedRowsGuard(
|
||||
filteredRowsBeforeFutureGuard,
|
||||
intent.intent,
|
||||
futureGuardReferenceDate
|
||||
);
|
||||
const filteredRows = filteredRowsFutureGuard.rows;
|
||||
if (filteredRowsFutureGuard.droppedCount > 0) {
|
||||
if (!filters.warnings.includes("future_rows_excluded_from_response")) {
|
||||
filters.warnings.push("future_rows_excluded_from_response");
|
||||
}
|
||||
if (!baseReasons.includes("future_rows_excluded_from_response")) {
|
||||
baseReasons.push("future_rows_excluded_from_response");
|
||||
}
|
||||
}
|
||||
const rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length);
|
||||
const stageStatus = deriveMcpStageStatus({
|
||||
rawRowsReceived: mcp.raw_rows.length,
|
||||
@@ -1782,7 +1910,9 @@ export class AddressQueryService {
|
||||
if (
|
||||
filteredRows.length === 0 &&
|
||||
isAnchorRecoveryIntent(intent.intent) &&
|
||||
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")
|
||||
(stageStatus === "materialized_but_not_anchor_matched" ||
|
||||
stageStatus === "materialized_but_filtered_out_by_recipe" ||
|
||||
stageStatus === "raw_rows_received_but_not_materialized")
|
||||
) {
|
||||
const currentLimit =
|
||||
typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit)
|
||||
@@ -1827,7 +1957,21 @@ export class AddressQueryService {
|
||||
});
|
||||
const expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching);
|
||||
const expandedRowsByAnchor = expandedAnchorFilter.rows;
|
||||
const expandedFilteredRows = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor);
|
||||
const expandedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor);
|
||||
const expandedFutureGuard = applyFutureDatedRowsGuard(
|
||||
expandedFilteredRowsBeforeFutureGuard,
|
||||
intent.intent,
|
||||
resolveFutureGuardReferenceDate(analysisDate, expandedLimitFilters)
|
||||
);
|
||||
const expandedFilteredRows = expandedFutureGuard.rows;
|
||||
if (expandedFutureGuard.droppedCount > 0) {
|
||||
if (!filters.warnings.includes("future_rows_excluded_from_response")) {
|
||||
filters.warnings.push("future_rows_excluded_from_response");
|
||||
}
|
||||
if (!baseReasons.includes("future_rows_excluded_from_response")) {
|
||||
baseReasons.push("future_rows_excluded_from_response");
|
||||
}
|
||||
}
|
||||
if (expandedFilteredRows.length > 0) {
|
||||
const expandedRowDiagnostics = deriveRowStageDiagnostics(
|
||||
expandedMcp.raw_rows,
|
||||
@@ -1938,7 +2082,21 @@ export class AddressQueryService {
|
||||
});
|
||||
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
|
||||
const broadenedRowsByAnchor = broadenedAnchorFilter.rows;
|
||||
const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
|
||||
const broadenedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor);
|
||||
const broadenedFutureGuard = applyFutureDatedRowsGuard(
|
||||
broadenedFilteredRowsBeforeFutureGuard,
|
||||
intent.intent,
|
||||
resolveFutureGuardReferenceDate(analysisDate, autoBroadenedFilters)
|
||||
);
|
||||
const broadenedFilteredRows = broadenedFutureGuard.rows;
|
||||
if (broadenedFutureGuard.droppedCount > 0) {
|
||||
if (!filters.warnings.includes("future_rows_excluded_from_response")) {
|
||||
filters.warnings.push("future_rows_excluded_from_response");
|
||||
}
|
||||
if (!baseReasons.includes("future_rows_excluded_from_response")) {
|
||||
baseReasons.push("future_rows_excluded_from_response");
|
||||
}
|
||||
}
|
||||
if (broadenedFilteredRows.length > 0) {
|
||||
const broadenedRowDiagnostics = deriveRowStageDiagnostics(
|
||||
broadenedMcp.raw_rows,
|
||||
@@ -2059,7 +2217,21 @@ export class AddressQueryService {
|
||||
});
|
||||
const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching);
|
||||
const historicalRowsByAnchor = historicalAnchorFilter.rows;
|
||||
const historicalFilteredRows = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor);
|
||||
const historicalFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor);
|
||||
const historicalFutureGuard = applyFutureDatedRowsGuard(
|
||||
historicalFilteredRowsBeforeFutureGuard,
|
||||
intent.intent,
|
||||
resolveFutureGuardReferenceDate(analysisDate, historicalFilters)
|
||||
);
|
||||
const historicalFilteredRows = historicalFutureGuard.rows;
|
||||
if (historicalFutureGuard.droppedCount > 0) {
|
||||
if (!filters.warnings.includes("future_rows_excluded_from_response")) {
|
||||
filters.warnings.push("future_rows_excluded_from_response");
|
||||
}
|
||||
if (!baseReasons.includes("future_rows_excluded_from_response")) {
|
||||
baseReasons.push("future_rows_excluded_from_response");
|
||||
}
|
||||
}
|
||||
if (historicalFilteredRows.length > 0) {
|
||||
const historicalRowDiagnostics = deriveRowStageDiagnostics(
|
||||
historicalMcp.raw_rows,
|
||||
|
||||
@@ -226,6 +226,20 @@ __WHERE_OUT__
|
||||
`;
|
||||
|
||||
const COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE = `
|
||||
ВЫБРАТЬ
|
||||
НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД) КАК Период,
|
||||
"CP_CUSTOMER_ACTIVITY_YEAR" КАК Регистратор,
|
||||
"" КАК СчетДт,
|
||||
"" КАК СчетКт,
|
||||
КОЛИЧЕСТВО(*) КАК Сумма,
|
||||
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
|
||||
ИЗ
|
||||
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
|
||||
__WHERE_IN__
|
||||
СГРУППИРОВАТЬ ПО
|
||||
БанкПоступление.Контрагент,
|
||||
НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД)
|
||||
ОБЪЕДИНИТЬ ВСЕ
|
||||
ВЫБРАТЬ
|
||||
МАКСИМУМ(БанкПоступление.Дата) КАК Период,
|
||||
"CP_CUSTOMER_ACTIVITY" КАК Регистратор,
|
||||
@@ -239,6 +253,7 @@ __WHERE_IN__
|
||||
СГРУППИРОВАТЬ ПО
|
||||
БанкПоступление.Контрагент
|
||||
УПОРЯДОЧИТЬ ПО
|
||||
Регистратор,
|
||||
Сумма УБЫВ,
|
||||
Период УБЫВ
|
||||
`;
|
||||
@@ -517,7 +532,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
|
||||
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
|
||||
default_limit: 64,
|
||||
account_scope: ["60", "76"],
|
||||
account_scope_mode: "preferred"
|
||||
account_scope_mode: "strict"
|
||||
},
|
||||
{
|
||||
recipe_id: "address_movements_receivables_v1",
|
||||
@@ -527,7 +542,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
|
||||
optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
|
||||
default_limit: 64,
|
||||
account_scope: ["62", "76"],
|
||||
account_scope_mode: "preferred"
|
||||
account_scope_mode: "strict"
|
||||
},
|
||||
{
|
||||
recipe_id: "address_open_contracts_candidates_v1",
|
||||
@@ -537,7 +552,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
|
||||
optional_filters: ["as_of_date", "organization", "limit"],
|
||||
default_limit: 128,
|
||||
account_scope: ["60", "62", "76"],
|
||||
account_scope_mode: "preferred"
|
||||
account_scope_mode: "strict"
|
||||
},
|
||||
{
|
||||
recipe_id: "address_open_items_by_party_or_contract_v1",
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ComposeFactualReplyOptions {
|
||||
userMessage?: string;
|
||||
periodFrom?: string;
|
||||
periodTo?: string;
|
||||
asOfDate?: string;
|
||||
}
|
||||
|
||||
type PeriodProfileFocus =
|
||||
@@ -239,6 +240,99 @@ function normalizeQuestionText(value: string | null | undefined): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeIsoDateOnly(value: string | null | undefined): string | null {
|
||||
const parsed = parseIsoDateToken(value);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return toIsoDate(parsed.year, parsed.month, parsed.day);
|
||||
}
|
||||
|
||||
function toUtcDayTimestamp(isoDate: string | null | undefined): number | null {
|
||||
const parsed = parseIsoDateToken(isoDate);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return Date.UTC(parsed.year, parsed.month - 1, parsed.day);
|
||||
}
|
||||
|
||||
function resolveReceivablesAsOfDate(options: ComposeFactualReplyOptions): string {
|
||||
const explicit = normalizeIsoDateOnly(options.asOfDate);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
const periodTo = normalizeIsoDateOnly(options.periodTo);
|
||||
if (periodTo) {
|
||||
return periodTo;
|
||||
}
|
||||
const now = new Date();
|
||||
return toIsoDate(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate());
|
||||
}
|
||||
|
||||
function hasReceivablesDebtAgingFocus(userMessage: string | null | undefined): boolean {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const hasDebtSignal = /(?:долг(?:и|ов|а|у)?|задолж|дебитор|не плат|неоплач|просроч|хвост)/iu.test(text);
|
||||
const hasLongevitySignal =
|
||||
/(?:долгожив|долгожител|дольше|длительн|несколько\s+месяц|возраст|по\s+времен|с\s+момент|на\s+этот\s+момент)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasCounterpartySignal = /(?:заказчик|клиент|покупател|контрагент|должник|counterpart|customer|client|buyer)/iu.test(
|
||||
text
|
||||
);
|
||||
return hasDebtSignal && hasLongevitySignal && hasCounterpartySignal;
|
||||
}
|
||||
|
||||
function formatAgeYearsMonthsDays(daysRaw: number): string {
|
||||
const safeDays = Math.max(0, Math.floor(daysRaw));
|
||||
const years = Math.floor(safeDays / 365);
|
||||
const remAfterYears = safeDays % 365;
|
||||
const months = Math.floor(remAfterYears / 30);
|
||||
const days = remAfterYears % 30;
|
||||
const parts: string[] = [];
|
||||
if (years > 0) {
|
||||
parts.push(`${years} г.`);
|
||||
}
|
||||
if (months > 0) {
|
||||
parts.push(`${months} мес.`);
|
||||
}
|
||||
if (parts.length === 0 || days > 0) {
|
||||
parts.push(`${days} дн.`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function extractContractDateFromToken(token: string): string | null {
|
||||
const source = String(token ?? "").trim();
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
const lower = source.toLowerCase();
|
||||
if (!/(?:договор|contract|дог\.)/iu.test(lower)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isoMatch = source.match(/\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/);
|
||||
if (isoMatch) {
|
||||
const isoCandidate = isoMatch[0];
|
||||
return normalizeIsoDateOnly(isoCandidate);
|
||||
}
|
||||
|
||||
const ruMatch = source.match(/\b(0[1-9]|[12]\d|3[01])[./-](0[1-9]|1[0-2])[./-]((?:19|20)\d{2})\b/);
|
||||
if (!ruMatch) {
|
||||
return null;
|
||||
}
|
||||
const day = Number(ruMatch[1]);
|
||||
const month = Number(ruMatch[2]);
|
||||
const year = Number(ruMatch[3]);
|
||||
if (!Number.isFinite(day) || !Number.isFinite(month) || !Number.isFinite(year)) {
|
||||
return null;
|
||||
}
|
||||
return toIsoDate(year, month, day);
|
||||
}
|
||||
|
||||
function needsVatWhyExplanation(userMessage: string | null | undefined): boolean {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
@@ -380,6 +474,23 @@ function detectCounterpartyLifecycleFocus(userMessage: string | null | undefined
|
||||
return "active_customers_period";
|
||||
}
|
||||
|
||||
function hasCounterpartyLifecycleLongevityQuestion(userMessage: string | null | undefined): boolean {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const hasCounterpartyLexeme =
|
||||
/(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasLongevityCue =
|
||||
/(?:долгожив|долгожител|дольше(?:\s+всех)?|сам(?:ые|ый)\s+стар(?:ые|ый)|лет\s+в\s+базе|лет\s+с\s+нами|longest|oldest)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasImplicitCounterpartyQuestion = /(?:кто\s+с\s+нами|кто\s+у\s+нас)/iu.test(text);
|
||||
return (hasCounterpartyLexeme || hasImplicitCounterpartyQuestion) && hasLongevityCue;
|
||||
}
|
||||
|
||||
function detectMinOpsForAvgCheck(userMessage: string | null | undefined): number {
|
||||
const text = normalizeQuestionText(userMessage);
|
||||
if (!text) {
|
||||
@@ -461,6 +572,9 @@ function extractRequestedYearFromQuestion(userMessage: string | null | undefined
|
||||
}
|
||||
|
||||
function extractCounterpartyName(row: ComposeStageRow): string | null {
|
||||
const skipTokenPattern =
|
||||
/(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu;
|
||||
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
if (!normalized) {
|
||||
@@ -469,11 +583,216 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (/^\d+(?:[./-]\d+)*$/.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (!/[a-zа-я]/iu.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (skipTokenPattern.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface CounterpartyRiskAggregate {
|
||||
name: string;
|
||||
totalAmount: number;
|
||||
operations: number;
|
||||
firstPeriod: string | null;
|
||||
lastPeriod: string | null;
|
||||
}
|
||||
|
||||
function buildCounterpartyRiskAggregate(rows: ComposeStageRow[]): CounterpartyRiskAggregate[] {
|
||||
const byCounterparty = new Map<string, CounterpartyRiskAggregate>();
|
||||
|
||||
for (const row of rows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const amountRaw = row.amount ?? 0;
|
||||
if (!Number.isFinite(amountRaw)) {
|
||||
continue;
|
||||
}
|
||||
const amount = Math.abs(amountRaw);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
totalAmount: amount,
|
||||
operations: 1,
|
||||
firstPeriod: row.period,
|
||||
lastPeriod: row.period
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.totalAmount += amount;
|
||||
current.operations += 1;
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byCounterparty.values()).sort((left, right) => {
|
||||
if (right.totalAmount !== left.totalAmount) {
|
||||
return right.totalAmount - left.totalAmount;
|
||||
}
|
||||
if (right.operations !== left.operations) {
|
||||
return right.operations - left.operations;
|
||||
}
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
}
|
||||
|
||||
interface CounterpartyDebtAgingAggregate extends CounterpartyRiskAggregate {
|
||||
debtAgeStartDate: string | null;
|
||||
debtAgeDays: number | null;
|
||||
debtAgeSource: "contract_date" | "first_movement";
|
||||
contractDateDetected: boolean;
|
||||
contracts: string[];
|
||||
}
|
||||
|
||||
function pickContractStartDateFromRow(row: ComposeStageRow): string | null {
|
||||
for (const token of row.analytics) {
|
||||
const detected = extractContractDateFromToken(token);
|
||||
if (detected) {
|
||||
return detected;
|
||||
}
|
||||
}
|
||||
const byRegistrator = extractContractDateFromToken(row.registrator);
|
||||
if (byRegistrator) {
|
||||
return byRegistrator;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function minIsoDate(left: string | null, right: string | null): string | null {
|
||||
if (!left) {
|
||||
return right;
|
||||
}
|
||||
if (!right) {
|
||||
return left;
|
||||
}
|
||||
return right < left ? right : left;
|
||||
}
|
||||
|
||||
function maxIsoDate(left: string | null, right: string | null): string | null {
|
||||
if (!left) {
|
||||
return right;
|
||||
}
|
||||
if (!right) {
|
||||
return left;
|
||||
}
|
||||
return right > left ? right : left;
|
||||
}
|
||||
|
||||
function buildCounterpartyDebtAgingAggregate(
|
||||
rows: ComposeStageRow[],
|
||||
asOfDate: string
|
||||
): CounterpartyDebtAgingAggregate[] {
|
||||
const byCounterparty = new Map<
|
||||
string,
|
||||
{
|
||||
base: CounterpartyRiskAggregate;
|
||||
minContractDate: string | null;
|
||||
contracts: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const amountRaw = row.amount ?? 0;
|
||||
if (!Number.isFinite(amountRaw)) {
|
||||
continue;
|
||||
}
|
||||
const amount = Math.abs(amountRaw);
|
||||
const rowIso = normalizeIsoDateOnly(row.period);
|
||||
const contractDate = pickContractStartDateFromRow(row);
|
||||
const contractName = extractContractName(row);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
base: {
|
||||
name,
|
||||
totalAmount: amount,
|
||||
operations: 1,
|
||||
firstPeriod: rowIso,
|
||||
lastPeriod: rowIso
|
||||
},
|
||||
minContractDate: contractDate,
|
||||
contracts: new Set(contractName ? [contractName] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.base.totalAmount += amount;
|
||||
current.base.operations += 1;
|
||||
current.base.firstPeriod = minIsoDate(current.base.firstPeriod, rowIso);
|
||||
current.base.lastPeriod = maxIsoDate(current.base.lastPeriod, rowIso);
|
||||
current.minContractDate = minIsoDate(current.minContractDate, contractDate);
|
||||
if (contractName) {
|
||||
current.contracts.add(contractName);
|
||||
}
|
||||
}
|
||||
|
||||
const asOfTs = toUtcDayTimestamp(asOfDate);
|
||||
const finalized = Array.from(byCounterparty.values()).map((entry) => {
|
||||
const debtAgeStartDate = entry.minContractDate ?? entry.base.firstPeriod ?? null;
|
||||
const startTs = toUtcDayTimestamp(debtAgeStartDate);
|
||||
const debtAgeSource: CounterpartyDebtAgingAggregate["debtAgeSource"] = entry.minContractDate
|
||||
? "contract_date"
|
||||
: "first_movement";
|
||||
const debtAgeDays =
|
||||
asOfTs !== null && startTs !== null && asOfTs >= startTs
|
||||
? Math.floor((asOfTs - startTs) / (24 * 60 * 60 * 1000))
|
||||
: null;
|
||||
return {
|
||||
...entry.base,
|
||||
debtAgeStartDate,
|
||||
debtAgeDays,
|
||||
debtAgeSource,
|
||||
contractDateDetected: Boolean(entry.minContractDate),
|
||||
contracts: Array.from(entry.contracts.values()).slice(0, 3)
|
||||
};
|
||||
});
|
||||
|
||||
return finalized.sort((left, right) => {
|
||||
const leftAge = left.debtAgeDays ?? -1;
|
||||
const rightAge = right.debtAgeDays ?? -1;
|
||||
if (rightAge !== leftAge) {
|
||||
return rightAge - leftAge;
|
||||
}
|
||||
if (right.totalAmount !== left.totalAmount) {
|
||||
return right.totalAmount - left.totalAmount;
|
||||
}
|
||||
if (right.operations !== left.operations) {
|
||||
return right.operations - left.operations;
|
||||
}
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
}
|
||||
|
||||
function extractContractName(row: ComposeStageRow): string | null {
|
||||
for (const token of row.analytics) {
|
||||
const normalized = String(token ?? "").trim();
|
||||
@@ -946,7 +1265,44 @@ export function composeFactualReply(
|
||||
const activityRows = rows.filter(
|
||||
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY"
|
||||
);
|
||||
const byCounterparty = new Map<string, { name: string; opsCount: number; lastPeriod: string | null }>();
|
||||
const activityYearRows = rows.filter(
|
||||
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR"
|
||||
);
|
||||
const byCounterparty = new Map<
|
||||
string,
|
||||
{ name: string; opsCount: number; lastPeriod: string | null; firstPeriod: string | null; years: Set<number> }
|
||||
>();
|
||||
|
||||
for (const row of activityYearRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const year = extractYearFromIso(row.period);
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
years: new Set<number>(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.opsCount += opsCount;
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of activityRows) {
|
||||
const name = extractCounterpartyName(row);
|
||||
if (!name) {
|
||||
@@ -955,26 +1311,48 @@ export function composeFactualReply(
|
||||
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
|
||||
const current = byCounterparty.get(name);
|
||||
if (!current) {
|
||||
byCounterparty.set(name, { name, opsCount, lastPeriod: row.period });
|
||||
const year = extractYearFromIso(row.period);
|
||||
byCounterparty.set(name, {
|
||||
name,
|
||||
opsCount,
|
||||
lastPeriod: row.period,
|
||||
firstPeriod: row.period,
|
||||
years: new Set<number>(year !== null ? [year] : [])
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (opsCount > current.opsCount) {
|
||||
if (activityYearRows.length === 0 && opsCount > current.opsCount) {
|
||||
current.opsCount = opsCount;
|
||||
}
|
||||
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
|
||||
current.lastPeriod = row.period;
|
||||
}
|
||||
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
|
||||
current.firstPeriod = row.period;
|
||||
}
|
||||
const year = extractYearFromIso(row.period);
|
||||
if (year !== null) {
|
||||
current.years.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
const counterparties = Array.from(byCounterparty.values()).sort((left, right) => {
|
||||
const counterpartiesRaw = Array.from(byCounterparty.values());
|
||||
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
|
||||
const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage);
|
||||
const rankingLimit = detectRankingLimit(options.userMessage, 10);
|
||||
const counterparties = counterpartiesRaw.sort((left, right) => {
|
||||
if (longevityQuestion) {
|
||||
const yearsDiff = right.years.size - left.years.size;
|
||||
if (yearsDiff !== 0) {
|
||||
return yearsDiff;
|
||||
}
|
||||
}
|
||||
if (right.opsCount !== left.opsCount) {
|
||||
return right.opsCount - left.opsCount;
|
||||
}
|
||||
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
|
||||
});
|
||||
|
||||
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
|
||||
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
|
||||
const scopeLabel =
|
||||
focus === "active_customers_all_time"
|
||||
? "за все время"
|
||||
@@ -982,29 +1360,53 @@ export function composeFactualReply(
|
||||
? `в ${requestedYear} году`
|
||||
: "в выбранном периоде";
|
||||
|
||||
const lines: string[] = [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
const lines: string[] = longevityQuestion
|
||||
? [
|
||||
`Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`,
|
||||
"Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
]
|
||||
: [
|
||||
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
|
||||
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
|
||||
`Строк агрегата: ${rows.length}.`
|
||||
];
|
||||
|
||||
if (counterparties.length === 0) {
|
||||
lines.push("По выбранному окну активности заказчики не найдены.");
|
||||
lines.push(
|
||||
longevityQuestion
|
||||
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам."
|
||||
: "По выбранному окну активности заказчики не найдены."
|
||||
);
|
||||
return {
|
||||
responseType: "FACTUAL_SUMMARY",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
const visible = counterparties.slice(0, 120);
|
||||
const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120);
|
||||
if (longevityQuestion) {
|
||||
lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`);
|
||||
}
|
||||
lines.push(
|
||||
...visible.map((item, index) => {
|
||||
const years = Array.from(item.years).sort((a, b) => a - b);
|
||||
const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : "";
|
||||
const periodSpan =
|
||||
item.firstPeriod && item.lastPeriod ? ` | период: ${item.firstPeriod}..${item.lastPeriod}` : "";
|
||||
if (longevityQuestion) {
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`;
|
||||
}
|
||||
const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : "";
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}`;
|
||||
return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`;
|
||||
})
|
||||
);
|
||||
if (counterparties.length > visible.length) {
|
||||
lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
|
||||
lines.push(
|
||||
longevityQuestion
|
||||
? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).`
|
||||
: `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1508,6 +1910,7 @@ export function composeFactualReply(
|
||||
|
||||
if (intent === "list_open_contracts") {
|
||||
const contracts = contractCandidatesFromRows(rows);
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const lines = [
|
||||
"Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).",
|
||||
`Строк движения: ${rows.length}.`,
|
||||
@@ -1515,6 +1918,17 @@ export function composeFactualReply(
|
||||
];
|
||||
if (contracts.length > 0) {
|
||||
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
|
||||
} else if (counterparties.length > 0) {
|
||||
lines.push(`Контрагентов с сигналом незакрытых хвостов: ${counterparties.length}.`);
|
||||
lines.push(
|
||||
...counterparties
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
|
||||
)
|
||||
);
|
||||
lines.push("Договорные якоря в этом live-срезе не выделены, поэтому показан контрагентный рейтинг риска.");
|
||||
} else {
|
||||
lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback.");
|
||||
lines.push(...formatTopRows(rows, 6));
|
||||
@@ -1526,14 +1940,28 @@ export function composeFactualReply(
|
||||
}
|
||||
|
||||
if (intent === "list_payables_counterparties") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const lines = [
|
||||
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
|
||||
`Строк в выборке: ${rows.length}.`,
|
||||
...(rows.length > 0
|
||||
? ["Ниже примеры строк для ручной проверки."]
|
||||
: ["Явных признаков системной задолженности по доступному срезу не найдено."]),
|
||||
...formatTopRows(rows, 6)
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
if (counterparties.length > 0) {
|
||||
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
|
||||
lines.push(
|
||||
...counterparties
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
|
||||
)
|
||||
);
|
||||
lines.push("Примеры исходных строк:");
|
||||
lines.push(...formatTopRows(rows, 4));
|
||||
} else {
|
||||
lines.push("Явных признаков системной задолженности по доступному срезу не найдено.");
|
||||
lines.push(...formatTopRows(rows, 6));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
@@ -1541,14 +1969,67 @@ export function composeFactualReply(
|
||||
}
|
||||
|
||||
if (intent === "list_receivables_counterparties") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage);
|
||||
if (debtAgingFocus) {
|
||||
const asOfDate = resolveReceivablesAsOfDate(options);
|
||||
const aging = buildCounterpartyDebtAgingAggregate(rows, asOfDate);
|
||||
const detectedContractDates = aging.filter((item) => item.contractDateDetected).length;
|
||||
const lines: string[] = [
|
||||
"Проверил должников по сроку жизни задолженности (контур 62/76).",
|
||||
`Дата среза: ${formatDateRu(asOfDate)}.`,
|
||||
`Строк в выборке: ${rows.length}.`,
|
||||
`Контрагентов с сигналом: ${aging.length}.`
|
||||
];
|
||||
|
||||
if (aging.length > 0) {
|
||||
lines.push("Приоритет ручной проверки (по возрасту долга, по убыванию):");
|
||||
lines.push(
|
||||
...aging.slice(0, 10).map((item, index) => {
|
||||
const ageLabel = item.debtAgeDays !== null ? formatAgeYearsMonthsDays(item.debtAgeDays) : "н/д";
|
||||
const startDateLabel = item.debtAgeStartDate ? formatDateRu(item.debtAgeStartDate) : "не определена";
|
||||
const startSourceLabel =
|
||||
item.debtAgeSource === "contract_date" ? "дата договора" : "первое движение по договору/контрагенту";
|
||||
const contractsLabel =
|
||||
item.contracts.length > 0 ? item.contracts.join("; ") : "договор не выделен в срезе";
|
||||
return `${index + 1}. ${item.name} | договоры: ${contractsLabel} | возраст долга: ${ageLabel} | старт: ${startDateLabel} (${startSourceLabel}) | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}`;
|
||||
})
|
||||
);
|
||||
lines.push(
|
||||
detectedContractDates > 0
|
||||
? `Дата договора выделена для ${detectedContractDates} из ${aging.length} контрагентов; для остальных использован старт первого движения.`
|
||||
: "Явная дата договора в live-строках не выделена, возраст рассчитан от первого движения."
|
||||
);
|
||||
} else {
|
||||
lines.push("Явных признаков затяжной дебиторки по доступному срезу не найдено.");
|
||||
}
|
||||
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
};
|
||||
}
|
||||
const lines = [
|
||||
"Проверил покупателей с признаками затянутой оплаты (контур 62/76).",
|
||||
`Строк в выборке: ${rows.length}.`,
|
||||
...(rows.length > 0
|
||||
? ["Ниже примеры строк, которые стоит проверить в первую очередь."]
|
||||
: ["Явных признаков затяжной дебиторки по доступному срезу не найдено."]),
|
||||
...formatTopRows(rows, 6)
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
if (counterparties.length > 0) {
|
||||
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
|
||||
lines.push(
|
||||
...counterparties
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
|
||||
)
|
||||
);
|
||||
lines.push("Примеры исходных строк:");
|
||||
lines.push(...formatTopRows(rows, 4));
|
||||
} else {
|
||||
lines.push("Явных признаков затяжной дебиторки по доступному срезу не найдено.");
|
||||
lines.push(...formatTopRows(rows, 6));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
@@ -1556,11 +2037,26 @@ export function composeFactualReply(
|
||||
}
|
||||
|
||||
if (intent === "open_items_by_counterparty_or_contract") {
|
||||
const counterparties = buildCounterpartyRiskAggregate(rows);
|
||||
const lines = [
|
||||
"Собраны открытые позиции по указанному фильтру (контрагент/договор).",
|
||||
"Собраны открытые позиции по взаиморасчетам.",
|
||||
`Строк отобрано: ${rows.length}.`,
|
||||
...formatTopRows(rows, 6)
|
||||
`Контрагентов с сигналом: ${counterparties.length}.`
|
||||
];
|
||||
if (counterparties.length > 0) {
|
||||
lines.push(
|
||||
...counterparties
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(item, index) =>
|
||||
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
|
||||
)
|
||||
);
|
||||
lines.push("Примеры исходных строк:");
|
||||
lines.push(...formatTopRows(rows, 4));
|
||||
} else {
|
||||
lines.push(...formatTopRows(rows, 6));
|
||||
}
|
||||
return {
|
||||
responseType: "FACTUAL_LIST",
|
||||
text: lines.join("\n")
|
||||
|
||||
@@ -3106,7 +3106,11 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
hasDeepAnalysisPreferenceSignal(rawMessageForGate) ||
|
||||
hasDeepAnalysisPreferenceSignal(repairedInputMessage);
|
||||
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage);
|
||||
const hasClassifierSignal = modeDetection.mode === "address_query";
|
||||
const modeDetectionRaw = (0, addressQueryClassifier_1.detectAddressQuestionMode)(String(addressInputMessage ?? ""));
|
||||
const hasClassifierSignal = modeDetection.mode === "address_query" || modeDetectionRaw.mode === "address_query";
|
||||
const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(repairedInputMessage || addressInputMessage);
|
||||
const intentResolutionRaw = (0, addressIntentResolver_1.resolveAddressIntent)(String(addressInputMessage ?? ""));
|
||||
const hasIntentSignal = intentResolution.intent !== "unknown" || intentResolutionRaw.intent !== "unknown";
|
||||
const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode);
|
||||
const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence);
|
||||
const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||
@@ -3137,7 +3141,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
|
||||
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
|
||||
llmContractIntent === "unknown";
|
||||
const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
|
||||
const hasAnyAddressSignal =
|
||||
hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
|
||||
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
|
||||
hasDataRetrievalRequestSignal(rawMessageForGate) ||
|
||||
hasAccountingSignal(rawMessageForGate) ||
|
||||
@@ -3173,6 +3178,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
|
||||
decision: "run_address_lane",
|
||||
reason: hasClassifierSignal
|
||||
? "address_mode_classifier_detected"
|
||||
: hasIntentSignal
|
||||
? "address_intent_resolver_detected"
|
||||
: hasLlmCanonicalSignal
|
||||
? "llm_canonical_candidate_detected"
|
||||
: hasLlmCanonicalDataSignal
|
||||
@@ -3661,8 +3668,8 @@ function hasDataRetrievalRequestSignal(text) {
|
||||
if (hasBroadInterrogative && hasBroadBusinessObject) {
|
||||
return true;
|
||||
}
|
||||
const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a)(?:$|[\s,.!?;:])/iu.test(lower);
|
||||
const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446)/iu.test(lower);
|
||||
const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a|\u043f\u0440\u043e\u0432\u0435\u0440\u044c|\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c)(?:$|[\s,.!?;:])/iu.test(lower);
|
||||
const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433)/iu.test(lower);
|
||||
if (hasRussianRetrievalAction && hasRussianRetrievalObject) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user