ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов Stage 3.7 ХВОСТЫ фикс маршрутов по домену задолжностей

This commit is contained in:
2026-04-11 23:34:59 +03:00
parent 160ed18fe5
commit d65969d2ff
16 changed files with 2248 additions and 237 deletions
+56 -13
View File
@@ -259,6 +259,7 @@ const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [
"кто ушел",
"кто ушёл",
"только один раз",
"дольше всего",
"дольше всех",
"долгоживущие контрагенты",
"регулярные поставщики",
@@ -601,10 +602,16 @@ function hasCounterpartyPopulationAndRolesSignal(text) {
return false;
}
function hasLifecycleSegmentationSignal(text) {
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text);
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|дольше\s+всего|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text);
}
function hasCounterpartyDebtLongevitySignal(text) {
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) {
const hasPaymentRiskLexeme = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|долг|задолж)/iu.test(text);
const hasPaymentRiskLexeme = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(text);
if (hasPaymentRiskLexeme) {
return false;
}
@@ -614,11 +621,11 @@ function hasCounterpartyActivityLifecycleSignal(text) {
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(text);
const hasActivityLexeme = /(?:работал(?:и)?|работа(?:ет|ют)|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test(text);
const hasTimeWindowLexeme = /(?:за\s+вс[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|в\s+конкретн(?:ом|ый)\s+год|за\s+год|в\s+году)/iu.test(text);
const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text);
const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text);
@@ -811,9 +818,9 @@ function hasOpenContractsListSignal(text) {
}
function hasSupplierTailRiskSignal(text) {
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);
}
function hasReceivablesLatencyRiskSignal(text) {
@@ -822,10 +829,20 @@ function hasReceivablesLatencyRiskSignal(text) {
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;
@@ -833,20 +850,38 @@ function hasReceivablesLatencyRiskSignal(text) {
function hasSettlementGapSignal(text) {
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) {
const hasCounterparty = /(?:контрагент|поставщик|клиент|покупател|customer|supplier|counterparty)/iu.test(text);
@@ -1196,6 +1231,13 @@ function resolveAddressIntent(userMessage) {
reasons: ["receivables_payment_lag_signal_detected"]
};
}
if (hasCounterpartyDebtLongevitySignal(text)) {
return {
intent: "list_receivables_counterparties",
confidence: "medium",
reasons: ["receivables_debt_lifecycle_signal_detected"]
};
}
if (hasSupplierTailRiskSignal(text)) {
return {
intent: "list_payables_counterparties",
@@ -1225,6 +1267,7 @@ function resolveAddressIntent(userMessage) {
};
}
if (hasAny(text, OPEN_ITEMS_HINTS) &&
!hasCounterpartyDebtLongevitySignal(text) &&
/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(text)) {
return {
intent: "open_items_by_counterparty_or_contract",
+172 -30
View File
@@ -608,6 +608,87 @@ function applyIntentSpecificFilter(intent, rows) {
}
return rows;
}
function parseIsoDateUtcTimestamp(value) {
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) {
return (intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" ||
intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract");
}
function resolveFutureGuardReferenceDate(analysisDate, filters) {
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) {
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, intent, referenceDate) {
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 = [];
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) {
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0));
@@ -630,6 +711,8 @@ function isAnchorRecoveryIntent(intent) {
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");
}
@@ -1197,10 +1280,19 @@ class AddressQueryService {
const composeOptionsFromFilters = (filterSet) => ({
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 = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
const recipeSelection = (0, addressRecipeCatalog_1.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 = (0, addressRecipeCatalog_1.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({
mode,
@@ -1220,27 +1312,6 @@ class AddressQueryService {
reasons: baseReasons
});
}
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,
@@ -1334,11 +1405,40 @@ class AddressQueryService {
}
}
}
const plan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters);
const mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
let plan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters);
let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: plan.query,
limit: plan.limit
});
if (mcp.error &&
recipeSelection.selected_recipe.recipe_id === "address_movements_receivables_v1" &&
isMissingSubcontoFieldError(mcp.error)) {
const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", filters.extracted_filters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
const fallbackPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, filters.extracted_filters);
const fallbackMcp = await (0, addressMcpClient_1.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);
return buildLimitedExecutionResult({
@@ -1395,7 +1495,17 @@ 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,
@@ -1474,7 +1584,9 @@ 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)
? Math.max(1, Math.trunc(filters.extracted_filters.limit))
: plan.limit;
@@ -1515,7 +1627,17 @@ 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, expandedNormalizedRows.length, expandedNormalizedRows.length);
const expandedStageStatus = deriveMcpStageStatus({
@@ -1615,7 +1737,17 @@ 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, broadenedNormalizedRows.length, broadenedNormalizedRows.length);
const broadenedStageStatus = deriveMcpStageStatus({
@@ -1722,7 +1854,17 @@ 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, historicalNormalizedRows.length, historicalNormalizedRows.length);
const historicalStageStatus = deriveMcpStageStatus({
@@ -218,6 +218,20 @@ __WHERE_OUT__
Регистратор
`;
const COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE = `
ВЫБРАТЬ
НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД) КАК Период,
"CP_CUSTOMER_ACTIVITY_YEAR" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
СГРУППИРОВАТЬ ПО
БанкПоступление.Контрагент,
НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД)
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
МАКСИМУМ(БанкПоступление.Дата) КАК Период,
"CP_CUSTOMER_ACTIVITY" КАК Регистратор,
@@ -231,6 +245,7 @@ __WHERE_IN__
СГРУППИРОВАТЬ ПО
БанкПоступление.Контрагент
УПОРЯДОЧИТЬ ПО
Регистратор,
Сумма УБЫВ,
Период УБЫВ
`;
@@ -502,7 +517,7 @@ const BASE_RECIPES = [
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",
@@ -512,7 +527,7 @@ const BASE_RECIPES = [
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",
@@ -522,7 +537,7 @@ const BASE_RECIPES = [
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",
@@ -156,6 +156,86 @@ function normalizeQuestionText(value) {
.replace(/\s+/g, " ")
.trim();
}
function normalizeIsoDateOnly(value) {
const parsed = parseIsoDateToken(value);
if (!parsed) {
return null;
}
return toIsoDate(parsed.year, parsed.month, parsed.day);
}
function toUtcDayTimestamp(isoDate) {
const parsed = parseIsoDateToken(isoDate);
if (!parsed) {
return null;
}
return Date.UTC(parsed.year, parsed.month - 1, parsed.day);
}
function resolveReceivablesAsOfDate(options) {
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) {
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) {
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 = [];
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) {
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) {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -270,6 +350,16 @@ function detectCounterpartyLifecycleFocus(userMessage) {
}
return "active_customers_period";
}
function hasCounterpartyLifecycleLongevityQuestion(userMessage) {
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) {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -345,6 +435,7 @@ function extractRequestedYearFromQuestion(userMessage) {
return 2000 + shortYear;
}
function extractCounterpartyName(row) {
const skipTokenPattern = /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu;
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
if (!normalized) {
@@ -353,10 +444,178 @@ function extractCounterpartyName(row) {
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;
}
function buildCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
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);
});
}
function pickContractStartDateFromRow(row) {
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, right) {
if (!left) {
return right;
}
if (!right) {
return left;
}
return right < left ? right : left;
}
function maxIsoDate(left, right) {
if (!left) {
return right;
}
if (!right) {
return left;
}
return right > left ? right : left;
}
function buildCounterpartyDebtAgingAggregate(rows, asOfDate) {
const byCounterparty = new Map();
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 = 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) {
for (const token of row.analytics) {
const normalized = String(token ?? "").trim();
@@ -744,7 +1003,37 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "counterparty_activity_lifecycle") {
const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY");
const activityYearRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR");
const byCounterparty = new Map();
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(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) {
@@ -753,48 +1042,90 @@ function composeFactualReply(intent, rows, options = {}) {
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(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"
? "за все время"
: requestedYear
? `в ${requestedYear} году`
: "в выбранном периоде";
const lines = [
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
const lines = 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 {
responseType: "FACTUAL_LIST",
@@ -1171,6 +1502,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows);
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [
"Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).",
`Строк движения: ${rows.length}.`,
@@ -1179,6 +1511,13 @@ function composeFactualReply(intent, rows, options = {}) {
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));
@@ -1189,39 +1528,102 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
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")
};
}
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 = [
"Проверил должников по сроку жизни задолженности (контур 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")
};
}
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")
+15 -9
View File
@@ -3150,7 +3150,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);
@@ -3181,7 +3185,7 @@ 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) ||
@@ -3217,11 +3221,13 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
decision: "run_address_lane",
reason: hasClassifierSignal
? "address_mode_classifier_detected"
: hasLlmCanonicalSignal
? "llm_canonical_candidate_detected"
: hasLlmCanonicalDataSignal
? "llm_canonical_data_signal_detected"
: "address_signal_detected"
: hasIntentSignal
? "address_intent_resolver_detected"
: hasLlmCanonicalSignal
? "llm_canonical_candidate_detected"
: hasLlmCanonicalDataSignal
? "llm_canonical_data_signal_detected"
: "address_signal_detected"
};
}
if (followupContext) {
@@ -3705,8 +3711,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;
}