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

This commit is contained in:
dctouch 2026-04-11 23:34:59 +03:00
parent 160ed18fe5
commit d65969d2ff
16 changed files with 2248 additions and 237 deletions

View File

@ -2453,13 +2453,148 @@ Implemented in current pass (Stage 3.6 route arbitration hardening + followup is
- Targeted regression pack: `4` files / `318` tests passed (`assistantLivingRouter`, `assistantLivingChatMode`, `addressQueryRuntimeM23`, `assistantSoftPolicyReply`).
- Type build: `npm --prefix llm_normalizer/backend run build` passed.
Implemented in current pass (Stage 3.7 real-run regression hardening, 2026-04-11):
1. Added a dedicated regression pack from real failing runs:
- New test file:
- `llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts`
2. Covered two production run cohorts directly:
- `assistant-stage1-aXaATwBpP6` (2026-04-11 14:59 local run family)
- `assistant-stage1-RAvQiKIFKX` (2026-04-11 17:51 local run family)
3. Locked critical route/answer protections from these runs:
- data-heavy prompts remain in address lane (no accidental chat drift);
- short follow-up style prompts (`без воды?`, `и коротко?`, `прям сейчас?`) stay in deep fallback when predecompose is unsupported (no generic chat fallback);
- slang data-scope wording (`по каким конторам можем общаться?`) remains in chat meta/data-scope mode;
- open-contract request with stale deep follow-up context keeps address lane priority;
- unsupported aggregate answers use soft-refusal structure and no longer rely on old rigid phrase (`Сейчас этот тип вопроса вне поддерживаемого контура адресного режима...`).
4. Validation snapshot:
- New regression file: `1` file / `5` tests passed.
- Extended focused suite: `6` files / `66` tests passed (`assistantWave17RunRegression20260411`, `assistantLivingRouter`, `assistantLivingChatMode`, `assistantSoftPolicyReply`, `assistantBoundaryFallbackReply`, `assistantAnswerPolicyV11`).
Implemented in current pass (Stage 3.8 manual-comment wave hardening, 2026-04-11):
1. Added manual-comment regression pack based on unresolved GUI annotations:
- New test file:
- `llm_normalizer/backend/tests/assistantWave18ManualCommentsRegression.test.ts`
- Scope:
- 18 unresolved manual comments from:
- `assistant-stage1-UMKkFYfg2L`
- `assistant-stage1-ywEyJgFkC4`
- `assistant-stage1-ZL97weIIRG`
2. Hardened intent recognition for weakly-structured real phrasing in address lane:
- `llm_normalizer/backend/src/services/addressIntentResolver.ts`
- Improvements:
- supplier-tail wording (`не закрывают счета`, `больше месяца`) now maps to supported payables/open-items intent;
- receivables latency wording (`не платят несколько месяцев`, payment-vs-shipment imbalance, negative saldo risk) now maps to supported receivables intent;
- stuck advances wording (`зависшие авансы`, `пора закрыть`, `перепривязать`, `списывать`) now maps to settlement-gap/open-contracts intent.
3. Hardened orchestration tool-gate against false capability drift on data requests:
- `llm_normalizer/backend/src/services/assistantService.ts`
- Improvements:
- retrieval-action/object detection expanded for imperative finance requests (`проверь` + `аванс/отгруз/долг` objects);
- tool-gate now uses both raw and repaired message variants for classifier/intent signals to avoid false negatives from over-repair.
4. Updated reason-code expectations in route regressions after new intent-signal source:
- `llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts`
- `llm_normalizer/backend/tests/assistantLivingRouter.test.ts`
5. Validation snapshot:
- Focused regression pack: `4` files / `58` tests passed:
- `assistantWave17RunRegression20260411.test.ts`
- `assistantWave18ManualCommentsRegression.test.ts`
- `assistantLivingRouter.test.ts`
- `assistantLivingChatMode.test.ts`
- Build validation:
- `npm run build` (backend) passed.
Implemented in current pass (Stage 3.9 anti-template risk-lane stabilization, 2026-04-11):
1. Removed hard `missing_anchor` gate for broad open-items scans:
- `llm_normalizer/backend/src/services/addressQueryService.ts`
- `open_items_by_counterparty_or_contract` can now run as a broad risk scan without mandatory counterparty/contract anchor.
2. Stopped wrong-domain leakage in risk intents:
- `llm_normalizer/backend/src/services/addressRecipeCatalog.ts`
- switched to strict account scope for:
- `list_payables_counterparties` (`60/76`)
- `list_receivables_counterparties` (`62/76`)
- `list_open_contracts` (`60/62/76`)
3. Added future-date guard for risk replies:
- `llm_normalizer/backend/src/services/addressQueryService.ts`
- rows far beyond analysis/reference date (e.g., synthetic `2030-*`) are excluded from factual reply assembly for risk intents.
4. Expanded anchor-recovery behavior for risk lanes:
- when initial slice has raw rows but zero rows after strict account scope, runtime can auto-expand live limit for recovery (instead of silent fallback to irrelevant rows).
5. Reworked factual composer for risk intents to reduce repeated templates:
- `llm_normalizer/backend/src/services/address_runtime/composeStage.ts`
- for payables/receivables/open-items/open-contracts now builds counterparty risk ranking (sum + ops + last period) before raw row fallback.
6. Regression updates:
- `llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts`
- updated open-items no-anchor expectation to match new broad-scan contract;
- added checks for strict scope and anti-leak behavior in receivables/open-contract intents.
7. Validation snapshot:
- `npm.cmd run test -- --run tests/addressQueryRuntimeM23.test.ts` -> `271 passed`
- `npm.cmd run test -- --run tests/addressQueryRuntimeM23.test.ts tests/assistantWave17RunRegression20260411.test.ts` -> `274 passed`
- `npm run build` (backend) passed.
Implemented in current pass (Stage 3.10 debt-lifecycle routing for reviewer comment AUTO-004, 2026-04-11):
1. Added explicit intent arbitration for debt-longevity customer phrasing:
- `llm_normalizer/backend/src/services/addressIntentResolver.ts`
- New signal `hasCounterpartyDebtLongevitySignal(...)` routes queries like
`Сколько заказчиков ... долгожителями по задолженностям` to
`counterparty_activity_lifecycle` instead of `open_items_by_counterparty_or_contract`.
2. Prevented open-items override for this class of queries:
- Open-items branch now skips debt-longevity customer wording, preserving lifecycle route.
3. Expanded lifecycle recipe to support year-based ranking:
- `llm_normalizer/backend/src/services/addressRecipeCatalog.ts`
- `COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE` now includes yearly aggregate marker
`CP_CUSTOMER_ACTIVITY_YEAR` (counterparty x year).
4. Reworked lifecycle composer for debt-longevity question style:
- `llm_normalizer/backend/src/services/address_runtime/composeStage.ts`
- Added deterministic top-10 output by:
- number of active years,
- operation frequency,
- period span;
- Response now explicitly provides `лет в базе` and year list.
5. Added regression coverage:
- `llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts`
- new checks for:
- resolver mapping of debt-longevity wording to lifecycle intent;
- address runtime route + recipe selection;
- factual reply contains top ranking by years.
6. Validation snapshot:
- `npm.cmd run test -- --run tests/addressQueryRuntimeM23.test.ts` -> `274 passed`
- `npm.cmd run test -- --run tests/assistantWave17RunRegression20260411.test.ts tests/assistantLivingRouter.test.ts tests/assistantLivingChatMode.test.ts` -> `55 passed`
- `npm run build` (backend) passed.
Implemented in current pass (Stage 3.11 unresolved manual-comment routing hardening, 2026-04-11):
1. Expanded semantic routing for overdue receivables and settlement-gap phrasing from unresolved reviewer cases:
- `llm_normalizer/backend/src/services/addressIntentResolver.ts`
- Added overdue-deadline cue support (`сроки ... прошли`) for non-payment receivables wording.
- Added settlement-gap signals for:
- payments without settlement closure (`оплаты без закрытия взаиморасчетов`);
- shipments without closing docs (`отгрузки без документов для закрытия`);
- closing without supporting docs (`закрытие счетов без подтверждающих документов`).
2. Locked new routing with regression coverage:
- `llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts`
- Added resolver + runtime checks for the exact unresolved wave wording above.
3. Stabilized Wave18 regression harness against annotation-state drift:
- `llm_normalizer/backend/tests/assistantWave18ManualCommentsRegression.test.ts`
- Removed brittle hard dependency on `resolved=false` for all fixed keys.
- Kept live runtime assertions on unresolved subset and increased explicit timeout for integration path.
4. Validation snapshot:
- Stage 3 focused suite:
- `tests/addressQueryRuntimeM23.test.ts`
- `tests/assistantWave18ManualCommentsRegression.test.ts`
- `tests/assistantWave17RunRegression20260411.test.ts`
- `tests/assistantLivingRouter.test.ts`
- `tests/assistantLivingChatMode.test.ts`
- `tests/assistantSoftPolicyReply.test.ts`
- `tests/assistantBoundaryFallbackReply.test.ts`
- `tests/assistantAnswerPolicyV11.test.ts`
- `tests/assistantSemanticExtractionContract.test.ts`
- Result: `9 files / 354 tests passed`.
- `npm run build` (backend) passed.
Acceptance (Stage 3):
1. LLM outputs strictly validated schema for extraction/decomposition (no free-form).
2. Deterministic guards can block or downgrade answers when evidence insufficient.
3. False route drifts and generic responses reduced in regression packs.
4. Manual markup shows increase in “correct/grounded” labels.
Status: Planned
Status: In validation (functional gates green; manual re-markup trend confirmation pending)
## Stage 4 (P2): Human-Centric Answer Layer

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",

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({

View File

@ -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",

View File

@ -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")

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;
}

View File

@ -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
)

View File

@ -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,

View File

@ -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",

View File

@ -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")

View File

@ -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;
}

View File

@ -925,6 +925,90 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("Активные заказчики в 2020 году: 1.");
});
it("returns top-10 lifecycle ranking by years for longest-collaboration customer question", () => {
const reply = composeFactualReply(
"counterparty_activity_lifecycle",
[
{
period: "2019-01-01T00:00:00Z",
registrator: "CP_CUSTOMER_ACTIVITY_YEAR",
account_dt: "",
account_kt: "",
amount: 5,
analytics: ["НОРТОН"]
},
{
period: "2020-01-01T00:00:00Z",
registrator: "CP_CUSTOMER_ACTIVITY_YEAR",
account_dt: "",
account_kt: "",
amount: 7,
analytics: ["НОРТОН"]
},
{
period: "2021-01-01T00:00:00Z",
registrator: "CP_CUSTOMER_ACTIVITY_YEAR",
account_dt: "",
account_kt: "",
amount: 4,
analytics: ["Группа"]
},
{
period: "2022-05-12T12:00:00Z",
registrator: "CP_CUSTOMER_ACTIVITY",
account_dt: "",
account_kt: "",
amount: 12,
analytics: ["НОРТОН"]
}
],
{
userMessage: "Какие заказчики работают с нами дольше всего?"
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Заказчиков с самым длинным горизонтом сотрудничества (по годам)");
expect(reply.text).toContain("Топ-");
expect(reply.text).toContain("лет в базе");
expect(reply.text).toContain("НОРТОН");
});
it("renders debt-aging ranking by as-of date for receivables debt-longevity question", () => {
const reply = composeFactualReply(
"list_receivables_counterparties",
[
{
period: "2022-01-01T00:00:00Z",
registrator: "Реализация 1",
account_dt: "62.01",
account_kt: "90.01",
amount: 1000,
analytics: ["Контрагент А", "Договор №A-01 от 10.02.2020"]
},
{
period: "2024-01-01T00:00:00Z",
registrator: "Реализация 2",
account_dt: "62.01",
account_kt: "90.01",
amount: 1500,
analytics: ["Контрагент Б", "Договор №B-01 от 15.03.2023"]
}
],
{
userMessage: "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?",
asOfDate: "2026-04-11"
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Проверил должников по сроку жизни задолженности");
expect(reply.text).toContain("Дата среза: 11.04.2026.");
expect(reply.text).toContain("Приоритет ручной проверки (по возрасту долга, по убыванию):");
expect(reply.text).toContain("1. Контрагент А | договоры:");
expect(reply.text).toContain("2. Контрагент Б | договоры:");
});
it("returns contract usage overview summary", () => {
const reply = composeFactualReply("contract_usage_overview", [
{
@ -1594,6 +1678,14 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
it("routes debt-longevity wording into receivables intent", () => {
const result = resolveAddressIntent(
"Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?"
);
expect(result.intent).toBe("list_receivables_counterparties");
expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected");
});
it("resolves supplier lifecycle segmentation wording into lifecycle intent", () => {
const result = resolveAddressIntent("Раздели поставщиков на регулярных и эпизодических по активности.");
expect(result.intent).toBe("counterparty_activity_lifecycle");
@ -1759,6 +1851,11 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("list_receivables_counterparties");
});
it("routes overdue unpaid buyers wording into receivables intent", () => {
const result = resolveAddressIntent("какие покупатели пока не оплатили свои товары или услуги, хотя сроки давно прошли?");
expect(result.intent).toBe("list_receivables_counterparties");
});
it("routes reconciliation mismatch wording into open contracts intent", () => {
const result = resolveAddressIntent(
"Покажи контрагентов, по которым сальдо скорее всего не совпадет с их актом сверки. Может, стоит поторопиться и запросить сверку?"
@ -1780,6 +1877,23 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("list_open_contracts");
});
it("routes payments-without-settlement-closure wording into open contracts intent", () => {
const result = resolveAddressIntent("где у нас есть оплаты без закрытия взаиморасчетов, и это уже требует ручной проверки?");
expect(result.intent).toBe("list_open_contracts");
});
it("routes shipments-without-closing-docs wording into open contracts intent", () => {
const result = resolveAddressIntent("где у нас есть отгрузки без документов для их закрытия и это уже требует внимания?");
expect(result.intent).toBe("list_open_contracts");
});
it("routes closing-without-supporting-docs wording into open contracts intent", () => {
const result = resolveAddressIntent(
"где у нас есть закрытие счетов без подтверждающих документов и это уже требует ручной проверки?"
);
expect(result.intent).toBe("list_open_contracts");
});
it("routes documents-without-payments wording into open contracts intent", () => {
const result = resolveAddressIntent(
"По каким контрагентам документы есть, а оплат нет. Может, стоит взять на карандаш такие ситуации чтоб не тянуть дальше?"
@ -2336,7 +2450,7 @@ describe("address filter extraction for balance drilldown", () => {
});
});
describe("address query limited taxonomy and stage diagnostics", () => {
describe("address query limited taxonomy and stage diagnostics", { timeout: 15000 }, () => {
it("injects as_of_date from analysis context when user message has no explicit period", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Покажи контрагентов с незакрытыми хвостами", {
@ -2385,6 +2499,22 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("keeps strict account scope for receivables risk replies and excludes far-future leakage", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное."
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_receivables_counterparties");
expect(result?.debug.account_scope_mode).toBe("strict");
expect(result?.debug.account_scope_fallback_applied).toBe(false);
if (result?.response_type === "FACTUAL_LIST") {
const reply = String(result?.reply_text ?? "");
expect(reply).not.toMatch(/68\.0?2\s*\/\s*19\.0?4/);
expect(reply).not.toContain("2030-");
}
});
it("routes payments-without-closing-docs wording into open contracts lane", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
@ -2396,6 +2526,47 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("routes payments-without-settlement-closure wording into open contracts lane", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("где у нас есть оплаты без закрытия взаиморасчетов, и это уже требует ручной проверки?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1");
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("routes shipments-without-closing-docs wording into open contracts lane", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("где у нас есть отгрузки без документов для их закрытия и это уже требует внимания?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1");
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("routes closing-without-supporting-docs wording into open contracts lane", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"где у нас есть закрытие счетов без подтверждающих документов и это уже требует ручной проверки?"
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1");
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("keeps strict account scope for open-contract scans", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие незакрытые документы по договорам у нас уже давно пора проверить?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.account_scope_mode).toBe("strict");
expect(result?.debug.account_scope_fallback_applied).toBe(false);
});
it("routes stale advances wording into open contracts lane without missing-anchor fallback", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
@ -2419,6 +2590,18 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("routes overdue unpaid buyers wording into receivables lane without missing-anchor fallback", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"какие покупатели пока не оплатили свои товары или услуги, хотя сроки давно прошли?"
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_receivables_counterparties");
expect(result?.debug.selected_recipe).toBe("address_movements_receivables_v1");
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("routes documents-without-payments wording into open contracts lane", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
@ -2629,6 +2812,38 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes longest-collaboration customer wording into lifecycle aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"Какие заказчики работают с нами дольше всего?"
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle");
expect(result?.debug.selected_recipe).toBe("address_counterparty_activity_lifecycle_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
if (result?.response_type === "FACTUAL_LIST") {
expect(String(result.reply_text)).toContain("лет в базе");
expect(String(result.reply_text)).toContain("Топ-");
}
});
it("routes debt-longevity wording into receivables lane with factual reply", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?"
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_receivables_counterparties");
expect(result?.debug.selected_recipe).toBe("address_open_items_by_party_or_contract_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
if (result?.response_type === "FACTUAL_LIST") {
expect(String(result.reply_text)).toContain("сроку жизни задолженности");
expect(String(result.reply_text)).toContain("Дата среза");
}
});
it("routes stale contracts wording into contract usage overview recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие договоры давно не использовались?");
@ -2649,13 +2864,13 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("returns missing_anchor for open items without concrete counterparty/contract anchor", async () => {
it("allows broad open items scan without forcing missing_anchor", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("show open items by contract");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.debug.limited_reason_category).toBe("missing_anchor");
expect(result?.debug.mcp_call_status).toBe("skipped");
expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
});
it("does not return fallback factual rows for unmatched open-items contract anchor", async () => {

View File

@ -278,7 +278,7 @@ describe("assistant orchestration contract", () => {
expect(decision.livingMode).toBe("address_data");
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.toolGateReason).toBe("address_signal_detected");
expect(["address_signal_detected", "address_intent_resolver_detected"]).toContain(String(decision.toolGateReason));
expect(decision.livingReason).toBe("address_lane_triggered");
});

View File

@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { AddressQueryService } from "../src/services/addressQueryService";
import { resolveAssistantOrchestrationDecision, resolveLivingAssistantModeDecision } from "../src/services/assistantService";
describe("wave17 run regressions (2026-04-11 real runs)", () => {
it("keeps real run 17:51 data-heavy prompts in address lane", () => {
const realRunPrompts = [
"Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?",
"Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?",
"В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности."
];
for (const prompt of realRunPrompts) {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: prompt,
effectiveAddressUserMessage: prompt,
followupContext: null,
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(["address_mode_classifier_detected", "address_signal_detected", "address_intent_resolver_detected"]).toContain(
decision.toolGateReason
);
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
}
});
it("keeps short follow-up style prompts out of chat drift when predecompose says unsupported", () => {
const shortFollowups = ["без воды?", "и коротко?", "прям сейчас?"];
for (const prompt of shortFollowups) {
const decision = resolveLivingAssistantModeDecision({
userMessage: prompt,
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("deep_analysis");
expect(decision.reason).toBe("predecompose_unsupported_mode_fallback_to_deep");
}
});
it("routes data-scope slang wording to chat mode", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "по каким конторам можем общаться?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
expect(decision.reason).toBe("assistant_data_scope_query_detected");
});
it("keeps open-contracts request in address lane even with stale deep followup context", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",
effectiveAddressUserMessage: "Покажи незакрытые договоры на 2020-12-31",
followupContext: {
previous_question_id: "msg-prev",
last_user_message: "почему так по закрытию месяца",
active_domain: "month_close_costs_20_44",
active_requirement_ids: ["R1"],
uncovered_requirement_ids: ["R1"],
referenced_requirement_ids: ["R1"]
} as any,
llmPreDecomposeMeta: {
applied: true,
llmCanonicalCandidateDetected: true,
reason: "normalized_fragment_applied",
predecomposeContract: {
mode: "address_query",
mode_confidence: "high",
intent: "list_open_contracts",
intent_confidence: "medium"
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
});
it("uses soft unsupported aggregate replies instead of rigid old template", async () => {
const service = new AddressQueryService();
const prompts = ["какой самый доходный год?", "какие обороты по альтернативе за 2020 год"];
for (const prompt of prompts) {
const result = await service.tryHandle(prompt);
const reply = String(result?.reply_text ?? "");
expect(result?.handled).toBe(true);
expect(result?.reply_type).toBe("partial_coverage");
expect(result?.debug.limited_reason_category).toBe("unsupported");
expect(reply).toContain("Что могу сделать сейчас:");
expect(reply).not.toMatch(/Сейчас этот тип вопроса вне поддерживаемого контура адресного режима/iu);
}
});
});

View File

@ -0,0 +1,187 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { AddressQueryService } from "../src/services/addressQueryService";
import { resolveAssistantOrchestrationDecision } from "../src/services/assistantService";
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
type ManualCaseDecision =
| "candidate_for_implementation"
| "needs_dialog_policy_fix"
| "needs_routing_extension"
| "bad_test_case";
interface AnnotationContext {
question_text?: string;
}
interface AnnotationRecord {
run_id: string;
case_id: string;
manual_case_decision: ManualCaseDecision;
resolved: boolean;
context?: AnnotationContext | null;
}
interface ManualCase {
runId: string;
caseId: string;
decision: ManualCaseDecision;
question: string;
resolved: boolean;
}
const MANUAL_CASE_KEYS = [
"assistant-stage1-UMKkFYfg2L::AUTO-003",
"assistant-stage1-UMKkFYfg2L::AUTO-007",
"assistant-stage1-UMKkFYfg2L::AUTO-009",
"assistant-stage1-UMKkFYfg2L::AUTO-012",
"assistant-stage1-UMKkFYfg2L::AUTO-015",
"assistant-stage1-UMKkFYfg2L::AUTO-017",
"assistant-stage1-ywEyJgFkC4::AUTO-002",
"assistant-stage1-ywEyJgFkC4::AUTO-004",
"assistant-stage1-ywEyJgFkC4::AUTO-005",
"assistant-stage1-ywEyJgFkC4::AUTO-006",
"assistant-stage1-ywEyJgFkC4::AUTO-009",
"assistant-stage1-ywEyJgFkC4::AUTO-013",
"assistant-stage1-ywEyJgFkC4::AUTO-014",
"assistant-stage1-ywEyJgFkC4::AUTO-015",
"assistant-stage1-ZL97weIIRG::AUTO-005",
"assistant-stage1-ZL97weIIRG::AUTO-008",
"assistant-stage1-ZL97weIIRG::AUTO-009",
"assistant-stage1-ZL97weIIRG::AUTO-010"
] as const;
const FORMER_UNKNOWN_INTENTS = new Set([
"assistant-stage1-ywEyJgFkC4::AUTO-002",
"assistant-stage1-ywEyJgFkC4::AUTO-009",
"assistant-stage1-ywEyJgFkC4::AUTO-013",
"assistant-stage1-ywEyJgFkC4::AUTO-015",
"assistant-stage1-ZL97weIIRG::AUTO-009",
"assistant-stage1-ZL97weIIRG::AUTO-010"
]);
function textMojibakeScore(value: string): number {
const lower = value.toLowerCase();
let score = 0;
const badFragments = ["рџ", "р°", "сѓ", "с", "рµ", "рё", "с€", "с‡", "сЏ", "сЊ", "с", "с“", "вђ", "в€"];
for (const fragment of badFragments) {
if (lower.includes(fragment)) {
score -= 4;
}
}
const cyrillic = value.match(/[А-Яа-яЁё]/g)?.length ?? 0;
score += cyrillic;
const replacementCount = value.match(/<2F>/g)?.length ?? 0;
score -= replacementCount * 3;
return score;
}
function decodeUtf8FromWin1251Mojibake(value: string): string {
try {
const bytes = Uint8Array.from(Array.from(value).map((char) => char.charCodeAt(0) & 0xff));
const decoded = Buffer.from(bytes).toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
} catch {
return value;
}
}
function decodeUtf8FromLatin1Mojibake(value: string): string {
try {
const decoded = Buffer.from(value, "latin1").toString("utf8");
return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value;
} catch {
return value;
}
}
function repairTextMojibake(value: string): string {
const fromWin1251 = decodeUtf8FromWin1251Mojibake(value);
return decodeUtf8FromLatin1Mojibake(fromWin1251);
}
function buildManualCasesFromAnnotations(): ManualCase[] {
const filePath = path.resolve(__dirname, "../../data/autorun_annotations/annotations.json");
const rows = JSON.parse(fs.readFileSync(filePath, "utf8")) as AnnotationRecord[];
const byKey = new Map<string, AnnotationRecord>();
for (const row of rows) {
const key = `${row.run_id}::${row.case_id}`;
if (MANUAL_CASE_KEYS.includes(key as (typeof MANUAL_CASE_KEYS)[number])) {
byKey.set(key, row);
}
}
const result: ManualCase[] = [];
for (const key of MANUAL_CASE_KEYS) {
const row = byKey.get(key);
if (!row) {
throw new Error(`Missing annotation for ${key}`);
}
const rawQuestion = String(row.context?.question_text ?? "").trim();
if (!rawQuestion) {
throw new Error(`Missing question_text for ${key}`);
}
result.push({
runId: row.run_id,
caseId: row.case_id,
decision: row.manual_case_decision,
question: repairTextMojibake(rawQuestion),
resolved: row.resolved === true
});
}
return result;
}
const MANUAL_WAVE18_CASES = buildManualCasesFromAnnotations();
const MANUAL_LIVE_ASSERT_CASES = MANUAL_WAVE18_CASES.filter((entry) => !entry.resolved);
describe("wave18 manual comments regressions", { timeout: 120000 }, () => {
it("keeps manual-comment prompts in address lane (no capability/data-scope drift)", () => {
for (const entry of MANUAL_WAVE18_CASES) {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: entry.question,
effectiveAddressUserMessage: entry.question,
followupContext: null,
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane, `${entry.runId} ${entry.caseId}`).toBe(true);
expect(decision.livingMode, `${entry.runId} ${entry.caseId}`).toBe("address_data");
expect(String(decision.toolGateReason), `${entry.runId} ${entry.caseId}`).not.toBe("assistant_capability_query_detected");
expect(String(decision.toolGateReason), `${entry.runId} ${entry.caseId}`).not.toBe("assistant_data_scope_query_detected");
}
});
it("resolves previously-unknown manual prompts to supported intents", () => {
for (const entry of MANUAL_WAVE18_CASES) {
const key = `${entry.runId}::${entry.caseId}`;
if (!FORMER_UNKNOWN_INTENTS.has(key)) {
continue;
}
const intent = resolveAddressIntent(entry.question);
expect(intent.intent, key).not.toBe("unknown");
}
});
it(
"returns handled address responses for manual-comment prompts without legacy rigid unsupported template",
async () => {
const service = new AddressQueryService();
for (const entry of MANUAL_LIVE_ASSERT_CASES) {
const result = await service.tryHandle(entry.question);
const reply = String(result?.reply_text ?? "");
expect(result, `${entry.runId} ${entry.caseId}`).not.toBeNull();
expect(result?.handled, `${entry.runId} ${entry.caseId}`).toBe(true);
expect(reply, `${entry.runId} ${entry.caseId}`).not.toMatch(
/Сейчас этот тип вопроса вне поддерживаемого контура адресного режима/iu
);
}
},
120_000
);
});

View File

@ -198,11 +198,11 @@
"comment": "на выбранный период можно показать не закрытые договора - очевидно что по контексту это можно упростить до домена открытых договоров",
"manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:57:36.621Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:13:08.915Z",
"updated_at": "2026-04-10T09:13:08.915Z",
"updated_at": "2026-04-11T19:57:36.621Z",
"context": {
"message_id": "msg-zs_PZOy4zu",
"trace_id": "address-KYzulAwWgo",
@ -279,11 +279,11 @@
"comment": "в чем проблема чекнуть это в 1с? это же не сложно вродже?",
"manual_case_decision": "needs_routing_extension",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:56:58.642Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:16:08.588Z",
"updated_at": "2026-04-10T09:16:08.588Z",
"updated_at": "2026-04-11T19:56:58.642Z",
"context": {
"message_id": "msg-3otxbY6nSu",
"trace_id": "address-VpPvao4Vua",
@ -333,11 +333,11 @@
"comment": "необходим вывод открытых договоров не закрытых актими или финальными выплатами",
"manual_case_decision": "needs_routing_extension",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:56:26.168Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:18:21.929Z",
"updated_at": "2026-04-10T09:18:21.929Z",
"updated_at": "2026-04-11T19:56:26.168Z",
"context": {
"message_id": "msg-Rviqs5LOve",
"trace_id": "address-2YTJldRV28",
@ -414,11 +414,11 @@
"comment": "надо отрабатывать маршрут - вопрос простой и полезный",
"manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:55:49.575Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:21:09.469Z",
"updated_at": "2026-04-10T09:21:09.469Z",
"updated_at": "2026-04-11T19:55:49.575Z",
"context": {
"message_id": "msg-h4Uw2x6woE",
"trace_id": "address-mhaJ03Mjei",
@ -468,11 +468,11 @@
"comment": "почему мы вообще отвечаем так шаблонно?? зачем нам ллм? может на три уровня на старте ллм поставить?разбор контекста - декомпозиция - и если не можем отработать то ответ уже человеческий в контексте? сейчас постояннно одинаковые ответы это бесит",
"manual_case_decision": "bad_test_case",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:55:10.038Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:25:30.011Z",
"updated_at": "2026-04-10T09:25:30.011Z",
"updated_at": "2026-04-11T19:55:10.038Z",
"context": {
"message_id": "msg-H7JqG6Ni0g",
"trace_id": "address-fGojfD8Utf",
@ -522,11 +522,11 @@
"comment": "очень важный кейс - надо отрабатывать - причем потенциально мы должны это умееть - ут надо показать кто из заказчиков сидит с отрытыми договорами на дату рассмотрения",
"manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:53:55.569Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:27:54.906Z",
"updated_at": "2026-04-10T09:27:54.906Z",
"updated_at": "2026-04-11T19:53:55.569Z",
"context": {
"message_id": "msg-3RW8I_1Y4f",
"trace_id": "address-3FNYpnavQE",
@ -576,11 +576,11 @@
"comment": "мы модем показать договора заведенные без оплавт на период рассмотрения - тема не сложная надо отработать",
"manual_case_decision": "needs_routing_extension",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:53:31.252Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T21:06:49.639Z",
"updated_at": "2026-04-10T21:06:49.639Z",
"updated_at": "2026-04-11T19:53:31.252Z",
"context": {
"message_id": "msg-MWxfeEbPmS",
"trace_id": "address-V5-7tJrBPM",
@ -603,11 +603,11 @@
"comment": "тут надо сапоставить договора с датами и отсутствие платежей по ним или старые платежи авансовые - надо дороботать - вопрос простой и важный",
"manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:52:52.569Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T21:08:53.728Z",
"updated_at": "2026-04-10T21:08:53.728Z",
"updated_at": "2026-04-11T19:52:52.569Z",
"context": {
"message_id": "msg-GwBH6jyVi_",
"trace_id": "address-719KtaE1Li",
@ -630,11 +630,11 @@
"comment": "технический ответ - такого быть не должно",
"manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:52:12.080Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T21:09:36.200Z",
"updated_at": "2026-04-10T21:09:36.200Z",
"updated_at": "2026-04-11T19:52:12.080Z",
"context": {
"message_id": "msg-xCoMu24uIa",
"trace_id": "Idd369iAGgAGpm",
@ -657,11 +657,11 @@
"comment": "ушло не в ту ветку - ответ совершенно не в кассу",
"manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:38:35.315Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-10T21:10:27.894Z",
"updated_at": "2026-04-10T21:10:27.894Z",
"updated_at": "2026-04-11T19:38:35.315Z",
"context": {
"message_id": "msg-_p_ppJ9bfV",
"trace_id": "chat-QQxVBg9vSO",
@ -684,11 +684,11 @@
"comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов",
"manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T19:37:36.816Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:44:50.920Z",
"updated_at": "2026-04-11T12:44:50.920Z",
"updated_at": "2026-04-11T19:37:36.816Z",
"context": {
"message_id": "msg-tL1QVBeDxY",
"trace_id": "Ylge9xWuRuJLvV",
@ -711,11 +711,11 @@
"comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - однозначно к дорабюотке - анализируем заказчиков которые чаще встречаются по годам и выводим топ 10",
"manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T20:33:42.820Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:45:50.144Z",
"updated_at": "2026-04-11T12:45:50.144Z",
"updated_at": "2026-04-11T20:33:42.820Z",
"context": {
"message_id": "msg-rj83nhGOV7",
"trace_id": "address-H9VJ13GWWC",
@ -738,11 +738,11 @@
"comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - простой вопрос - показываем просто открытые договора без приходов денег",
"manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T18:08:27.601Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:46:41.463Z",
"updated_at": "2026-04-11T12:46:41.463Z",
"updated_at": "2026-04-11T18:08:27.601Z",
"context": {
"message_id": "msg-2kDN4UKCbY",
"trace_id": "address-L0WwEsakCe",
@ -765,11 +765,11 @@
"comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - надо внедрять",
"manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T18:32:00.081Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:47:04.120Z",
"updated_at": "2026-04-11T12:47:04.120Z",
"updated_at": "2026-04-11T18:32:00.081Z",
"context": {
"message_id": "msg--ZmllegVvV",
"trace_id": "cG51b5sIOWKwTi",
@ -792,11 +792,11 @@
"comment": "покеаываем клиентов с открытими договорами которые висят более месяца бенз денег - выводимм том 10",
"manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T18:07:46.388Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:48:11.847Z",
"updated_at": "2026-04-11T12:48:11.847Z",
"updated_at": "2026-04-11T18:07:46.388Z",
"context": {
"message_id": "msg-6GpvEMKGcQ",
"trace_id": "address-lGGvDiH21w",
@ -819,11 +819,11 @@
"comment": "однозначно на расширение доменов",
"manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T18:07:26.769Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:49:02.831Z",
"updated_at": "2026-04-11T12:49:02.831Z",
"updated_at": "2026-04-11T18:07:26.769Z",
"context": {
"message_id": "msg-bZZQxlpKcO",
"trace_id": "address-JiGLTVe3_0",
@ -846,11 +846,11 @@
"comment": "проблема тыт шум - важно показать договора с незакрытыми доками - топ по времени висения - от самой длинной дистании до кооротной",
"manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T18:06:44.816Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:50:25.257Z",
"updated_at": "2026-04-11T12:50:25.257Z",
"updated_at": "2026-04-11T18:06:44.816Z",
"context": {
"message_id": "msg-3Leq0R75YH",
"trace_id": "E1O-azugbPvNG9",
@ -873,11 +873,11 @@
"comment": "нужен анализ и отработка домена",
"manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"resolved": true,
"resolved_at": "2026-04-11T18:05:57.111Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:50:48.487Z",
"updated_at": "2026-04-11T12:50:48.487Z",
"updated_at": "2026-04-11T18:05:57.111Z",
"context": {
"message_id": "msg-6Fqd6XrBv2",
"trace_id": "address-TnBvKVAVfu",