ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов 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`). - Targeted regression pack: `4` files / `318` tests passed (`assistantLivingRouter`, `assistantLivingChatMode`, `addressQueryRuntimeM23`, `assistantSoftPolicyReply`).
- Type build: `npm --prefix llm_normalizer/backend run build` passed. - 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): Acceptance (Stage 3):
1. LLM outputs strictly validated schema for extraction/decomposition (no free-form). 1. LLM outputs strictly validated schema for extraction/decomposition (no free-form).
2. Deterministic guards can block or downgrade answers when evidence insufficient. 2. Deterministic guards can block or downgrade answers when evidence insufficient.
3. False route drifts and generic responses reduced in regression packs. 3. False route drifts and generic responses reduced in regression packs.
4. Manual markup shows increase in “correct/grounded” labels. 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 ## 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; return false;
} }
function hasLifecycleSegmentationSignal(text) { 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) { 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) { if (hasPaymentRiskLexeme) {
return false; return false;
} }
@ -614,11 +621,11 @@ function hasCounterpartyActivityLifecycleSignal(text) {
if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) { if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) {
return true; return true;
} }
if (/(?:сколько|скока|скок)\s+/iu.test(text)) { if (/(?:сколько|скока|скок)\s+/iu.test(text) && !hasLifecycleSegmentationSignal(text)) {
return false; return false;
} }
const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test(text); 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 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 hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text);
const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/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) { function hasSupplierTailRiskSignal(text) {
const hasSupplier = /(?:поставщик|supplier|vendor)/iu.test(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 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); return hasSupplier && hasTail && (hasRisk || hasPeriodCue);
} }
function hasReceivablesLatencyRiskSignal(text) { function hasReceivablesLatencyRiskSignal(text) {
@ -822,10 +829,20 @@ function hasReceivablesLatencyRiskSignal(text) {
const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text); const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text);
const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text); const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text);
const hasDelay = /(?:длинн|долг|просроч|задерж|висят|тревог|too\s+long|late)/iu.test(text); const hasDelay = /(?:длинн|долг|просроч|задерж|висят|тревог|too\s+long|late)/iu.test(text);
const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); const hasOverdueDeadlineCue = /(?:срок(?:и|а)?(?:\s+оплат[ыы]?)?[\s\S]{0,24}(?:прош|выш|истек|истёк)|срок(?:и|а)?\s+давно\s+прошл|давно\s+пора\s+оплат|давно\s+не\s+оплач)/iu.test(text);
const hasPeriodOrRiskCue = /(?:за\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); 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 true;
} }
return (hasBuyer || hasCounterparty) && hasNonPayment && hasPeriodOrRiskCue; return (hasBuyer || hasCounterparty) && hasNonPayment && hasPeriodOrRiskCue;
@ -833,20 +850,38 @@ function hasReceivablesLatencyRiskSignal(text) {
function hasSettlementGapSignal(text) { function hasSettlementGapSignal(text) {
const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text); const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text);
const hasDocument = /(?:док(?:и|умент|ументы|ументов)|docs?|documents?)/iu.test(text); const hasDocument = /(?:док(?:и|умент|ументы|ументов)|docs?|documents?)/iu.test(text);
const hasShipment = /(?:отгруз|реализ|shipment|delivery|товар|услуг)/iu.test(text);
const hasAdvance = /(?:аванс|предоплат)/iu.test(text); const hasAdvance = /(?:аванс|предоплат)/iu.test(text);
const hasClosureLexeme = /(?:закрыти|взаиморасч|акт|сч[её]т(?:ов|а|ы)?)/iu.test(text);
const hasNoDocumentForClosing = /(?:нет|без)\s+(?:док(?:и|умент|ументы|ументов)|закрывающ)/iu.test(text) && const hasNoDocumentForClosing = /(?:нет|без)\s+(?:док(?:и|умент|ументы|ументов)|закрывающ)/iu.test(text) &&
/(?:закрыти|взаиморасч|акт)/iu.test(text); hasClosureLexeme;
const hasNoDocumentForClosingReversed = /(?:док(?:и|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) && const hasNoDocumentForClosingReversed = /(?:док(?:и|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) &&
/(?:закрыти|взаиморасч|акт)/iu.test(text); hasClosureLexeme;
const hasNoPayments = /(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) || const hasNoPayments = /(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) ||
/(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text); /(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text);
const hasDocsWithoutPayments = hasDocument && hasNoPayments; const hasDocsWithoutPayments = hasDocument && hasNoPayments;
const hasPaymentsWithoutClosingDocs = hasPayment && (hasNoDocumentForClosing || hasNoDocumentForClosingReversed); 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 && const hasUnclosedAdvanceGap = hasAdvance &&
(/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт)/iu.test(text) || (/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт|давно\s+пора\s+закрыть)/iu.test(text) ||
hasAdvanceStuckRisk ||
hasNoDocumentForClosing || hasNoDocumentForClosing ||
hasNoDocumentForClosingReversed); hasNoDocumentForClosingReversed);
return hasPaymentsWithoutClosingDocs || hasDocsWithoutPayments || hasUnclosedAdvanceGap; return (hasPaymentsWithoutClosingDocs ||
hasPaymentsWithoutSettlementClosure ||
hasDocsWithoutPayments ||
hasShipmentWithoutClosingDocs ||
hasClosingWithoutSupportingDocs ||
hasUnclosedAdvanceGap);
} }
function hasReconciliationMismatchSignal(text) { function hasReconciliationMismatchSignal(text) {
const hasCounterparty = /(?:контрагент|поставщик|клиент|покупател|customer|supplier|counterparty)/iu.test(text); const hasCounterparty = /(?:контрагент|поставщик|клиент|покупател|customer|supplier|counterparty)/iu.test(text);
@ -1196,6 +1231,13 @@ function resolveAddressIntent(userMessage) {
reasons: ["receivables_payment_lag_signal_detected"] 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)) { if (hasSupplierTailRiskSignal(text)) {
return { return {
intent: "list_payables_counterparties", intent: "list_payables_counterparties",
@ -1225,6 +1267,7 @@ function resolveAddressIntent(userMessage) {
}; };
} }
if (hasAny(text, OPEN_ITEMS_HINTS) && if (hasAny(text, OPEN_ITEMS_HINTS) &&
!hasCounterpartyDebtLongevitySignal(text) &&
/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(text)) { /(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(text)) {
return { return {
intent: "open_items_by_counterparty_or_contract", intent: "open_items_by_counterparty_or_contract",

View File

@ -608,6 +608,87 @@ function applyIntentSpecificFilter(intent, rows) {
} }
return 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) { function hasExplicitPeriodWindow(filters) {
return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) ||
(typeof filters.period_to === "string" && filters.period_to.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_contracts_by_counterparty" ||
intent === "list_documents_by_contract" || intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract" || intent === "bank_operations_by_contract" ||
intent === "list_payables_counterparties" ||
intent === "list_receivables_counterparties" ||
intent === "open_items_by_counterparty_or_contract" || intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts"); intent === "list_open_contracts");
} }
@ -1197,10 +1280,19 @@ class AddressQueryService {
const composeOptionsFromFilters = (filterSet) => ({ const composeOptionsFromFilters = (filterSet) => ({
userMessage, userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, 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); 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") { if (intent.intent === "unknown") {
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
mode, mode,
@ -1220,27 +1312,6 @@ class AddressQueryService {
reasons: baseReasons 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) { if (recipeSelection.selected_recipe === null) {
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
mode, mode,
@ -1334,11 +1405,40 @@ class AddressQueryService {
} }
} }
} }
const plan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters); let plan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters);
const mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: plan.query, query: plan.query,
limit: plan.limit 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) { if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
@ -1395,7 +1495,17 @@ class AddressQueryService {
}); });
const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching); const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching);
const filterByAnchors = anchorFilter.rows; 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 rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length);
const stageStatus = deriveMcpStageStatus({ const stageStatus = deriveMcpStageStatus({
rawRowsReceived: mcp.raw_rows.length, rawRowsReceived: mcp.raw_rows.length,
@ -1474,7 +1584,9 @@ class AddressQueryService {
} }
if (filteredRows.length === 0 && if (filteredRows.length === 0 &&
isAnchorRecoveryIntent(intent.intent) && 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) const currentLimit = typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit)
? Math.max(1, Math.trunc(filters.extracted_filters.limit)) ? Math.max(1, Math.trunc(filters.extracted_filters.limit))
: plan.limit; : plan.limit;
@ -1515,7 +1627,17 @@ class AddressQueryService {
}); });
const expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching); const expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching);
const expandedRowsByAnchor = expandedAnchorFilter.rows; 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) { if (expandedFilteredRows.length > 0) {
const expandedRowDiagnostics = deriveRowStageDiagnostics(expandedMcp.raw_rows, expandedNormalizedRows.length, expandedNormalizedRows.length); const expandedRowDiagnostics = deriveRowStageDiagnostics(expandedMcp.raw_rows, expandedNormalizedRows.length, expandedNormalizedRows.length);
const expandedStageStatus = deriveMcpStageStatus({ const expandedStageStatus = deriveMcpStageStatus({
@ -1615,7 +1737,17 @@ class AddressQueryService {
}); });
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching); const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
const broadenedRowsByAnchor = broadenedAnchorFilter.rows; 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) { if (broadenedFilteredRows.length > 0) {
const broadenedRowDiagnostics = deriveRowStageDiagnostics(broadenedMcp.raw_rows, broadenedNormalizedRows.length, broadenedNormalizedRows.length); const broadenedRowDiagnostics = deriveRowStageDiagnostics(broadenedMcp.raw_rows, broadenedNormalizedRows.length, broadenedNormalizedRows.length);
const broadenedStageStatus = deriveMcpStageStatus({ const broadenedStageStatus = deriveMcpStageStatus({
@ -1722,7 +1854,17 @@ class AddressQueryService {
}); });
const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching); const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching);
const historicalRowsByAnchor = historicalAnchorFilter.rows; 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) { if (historicalFilteredRows.length > 0) {
const historicalRowDiagnostics = deriveRowStageDiagnostics(historicalMcp.raw_rows, historicalNormalizedRows.length, historicalNormalizedRows.length); const historicalRowDiagnostics = deriveRowStageDiagnostics(historicalMcp.raw_rows, historicalNormalizedRows.length, historicalNormalizedRows.length);
const historicalStageStatus = deriveMcpStageStatus({ const historicalStageStatus = deriveMcpStageStatus({

View File

@ -218,6 +218,20 @@ __WHERE_OUT__
Регистратор Регистратор
`; `;
const COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE = ` const COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE = `
ВЫБРАТЬ
НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД) КАК Период,
"CP_CUSTOMER_ACTIVITY_YEAR" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
СГРУППИРОВАТЬ ПО
БанкПоступление.Контрагент,
НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД)
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ВЫБРАТЬ
МАКСИМУМ(БанкПоступление.Дата) КАК Период, МАКСИМУМ(БанкПоступление.Дата) КАК Период,
"CP_CUSTOMER_ACTIVITY" КАК Регистратор, "CP_CUSTOMER_ACTIVITY" КАК Регистратор,
@ -231,6 +245,7 @@ __WHERE_IN__
СГРУППИРОВАТЬ ПО СГРУППИРОВАТЬ ПО
БанкПоступление.Контрагент БанкПоступление.Контрагент
УПОРЯДОЧИТЬ ПО УПОРЯДОЧИТЬ ПО
Регистратор,
Сумма УБЫВ, Сумма УБЫВ,
Период УБЫВ Период УБЫВ
`; `;
@ -502,7 +517,7 @@ const BASE_RECIPES = [
optional_filters: ["as_of_date", "counterparty", "contract", "limit"], optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 64, default_limit: 64,
account_scope: ["60", "76"], account_scope: ["60", "76"],
account_scope_mode: "preferred" account_scope_mode: "strict"
}, },
{ {
recipe_id: "address_movements_receivables_v1", recipe_id: "address_movements_receivables_v1",
@ -512,7 +527,7 @@ const BASE_RECIPES = [
optional_filters: ["as_of_date", "counterparty", "contract", "limit"], optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 64, default_limit: 64,
account_scope: ["62", "76"], account_scope: ["62", "76"],
account_scope_mode: "preferred" account_scope_mode: "strict"
}, },
{ {
recipe_id: "address_open_contracts_candidates_v1", recipe_id: "address_open_contracts_candidates_v1",
@ -522,7 +537,7 @@ const BASE_RECIPES = [
optional_filters: ["as_of_date", "organization", "limit"], optional_filters: ["as_of_date", "organization", "limit"],
default_limit: 128, default_limit: 128,
account_scope: ["60", "62", "76"], account_scope: ["60", "62", "76"],
account_scope_mode: "preferred" account_scope_mode: "strict"
}, },
{ {
recipe_id: "address_open_items_by_party_or_contract_v1", recipe_id: "address_open_items_by_party_or_contract_v1",

View File

@ -156,6 +156,86 @@ function normalizeQuestionText(value) {
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .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) { function needsVatWhyExplanation(userMessage) {
const text = normalizeQuestionText(userMessage); const text = normalizeQuestionText(userMessage);
if (!text) { if (!text) {
@ -270,6 +350,16 @@ function detectCounterpartyLifecycleFocus(userMessage) {
} }
return "active_customers_period"; 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) { function detectMinOpsForAvgCheck(userMessage) {
const text = normalizeQuestionText(userMessage); const text = normalizeQuestionText(userMessage);
if (!text) { if (!text) {
@ -345,6 +435,7 @@ function extractRequestedYearFromQuestion(userMessage) {
return 2000 + shortYear; return 2000 + shortYear;
} }
function extractCounterpartyName(row) { function extractCounterpartyName(row) {
const skipTokenPattern = /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu;
for (const token of row.analytics) { for (const token of row.analytics) {
const normalized = String(token ?? "").trim(); const normalized = String(token ?? "").trim();
if (!normalized) { if (!normalized) {
@ -353,10 +444,178 @@ function extractCounterpartyName(row) {
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
continue; 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 normalized;
} }
return null; 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) { function extractContractName(row) {
for (const token of row.analytics) { for (const token of row.analytics) {
const normalized = String(token ?? "").trim(); const normalized = String(token ?? "").trim();
@ -744,7 +1003,37 @@ function composeFactualReply(intent, rows, options = {}) {
} }
if (intent === "counterparty_activity_lifecycle") { if (intent === "counterparty_activity_lifecycle") {
const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY"); 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(); 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) { for (const row of activityRows) {
const name = extractCounterpartyName(row); const name = extractCounterpartyName(row);
if (!name) { if (!name) {
@ -753,48 +1042,90 @@ function composeFactualReply(intent, rows, options = {}) {
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
const current = byCounterparty.get(name); const current = byCounterparty.get(name);
if (!current) { 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; continue;
} }
if (opsCount > current.opsCount) { if (activityYearRows.length === 0 && opsCount > current.opsCount) {
current.opsCount = opsCount; current.opsCount = opsCount;
} }
if ((row.period ?? "") > (current.lastPeriod ?? "")) { if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period; 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) { if (right.opsCount !== left.opsCount) {
return right.opsCount - left.opsCount; return right.opsCount - left.opsCount;
} }
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? ""); return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
}); });
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
const scopeLabel = focus === "active_customers_all_time" const scopeLabel = focus === "active_customers_all_time"
? "за все время" ? "за все время"
: requestedYear : requestedYear
? `в ${requestedYear} году` ? `в ${requestedYear} году`
: "в выбранном периоде"; : "в выбранном периоде";
const lines = [ const lines = longevityQuestion
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`, ? [
"Собран профиль активности заказчиков (bank-doc activity aggregate).", `Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`,
`Строк агрегата: ${rows.length}.` "Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.",
]; `Строк агрегата: ${rows.length}.`
]
: [
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (counterparties.length === 0) { if (counterparties.length === 0) {
lines.push("По выбранному окну активности заказчики не найдены."); lines.push(longevityQuestion
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам."
: "По выбранному окну активности заказчики не найдены.");
return { return {
responseType: "FACTUAL_SUMMARY", responseType: "FACTUAL_SUMMARY",
text: lines.join("\n") 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) => { 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}` : ""; 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) { if (counterparties.length > visible.length) {
lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`); lines.push(longevityQuestion
? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).`
: `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`);
} }
return { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
@ -1171,6 +1502,7 @@ function composeFactualReply(intent, rows, options = {}) {
} }
if (intent === "list_open_contracts") { if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows); const contracts = contractCandidatesFromRows(rows);
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [ const lines = [
"Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).", "Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).",
`Строк движения: ${rows.length}.`, `Строк движения: ${rows.length}.`,
@ -1179,6 +1511,13 @@ function composeFactualReply(intent, rows, options = {}) {
if (contracts.length > 0) { if (contracts.length > 0) {
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)); 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 { else {
lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback."); lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback.");
lines.push(...formatTopRows(rows, 6)); lines.push(...formatTopRows(rows, 6));
@ -1189,39 +1528,102 @@ function composeFactualReply(intent, rows, options = {}) {
}; };
} }
if (intent === "list_payables_counterparties") { if (intent === "list_payables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [ const lines = [
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).", "Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
`Строк в выборке: ${rows.length}.`, `Строк в выборке: ${rows.length}.`,
...(rows.length > 0 `Контрагентов с сигналом: ${counterparties.length}.`
? ["Ниже примеры строк для ручной проверки."]
: ["Явных признаков системной задолженности по доступному срезу не найдено."]),
...formatTopRows(rows, 6)
]; ];
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 { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n")
}; };
} }
if (intent === "list_receivables_counterparties") { 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 = [ const lines = [
"Проверил покупателей с признаками затянутой оплаты (контур 62/76).", "Проверил покупателей с признаками затянутой оплаты (контур 62/76).",
`Строк в выборке: ${rows.length}.`, `Строк в выборке: ${rows.length}.`,
...(rows.length > 0 `Контрагентов с сигналом: ${counterparties.length}.`
? ["Ниже примеры строк, которые стоит проверить в первую очередь."]
: ["Явных признаков затяжной дебиторки по доступному срезу не найдено."]),
...formatTopRows(rows, 6)
]; ];
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 { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n")
}; };
} }
if (intent === "open_items_by_counterparty_or_contract") { if (intent === "open_items_by_counterparty_or_contract") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [ const lines = [
"Собраны открытые позиции по указанному фильтру (контрагент/договор).", "Собраны открытые позиции по взаиморасчетам.",
`Строк отобрано: ${rows.length}.`, `Строк отобрано: ${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 { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n")

View File

@ -3150,7 +3150,11 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
hasDeepAnalysisPreferenceSignal(rawMessageForGate) || hasDeepAnalysisPreferenceSignal(rawMessageForGate) ||
hasDeepAnalysisPreferenceSignal(repairedInputMessage); hasDeepAnalysisPreferenceSignal(repairedInputMessage);
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage); 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 llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode);
const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence); const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence);
const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
@ -3181,7 +3185,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
llmContractIntent === "unknown"; llmContractIntent === "unknown";
const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; const hasAnyAddressSignal = hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) || const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
hasDataRetrievalRequestSignal(rawMessageForGate) || hasDataRetrievalRequestSignal(rawMessageForGate) ||
hasAccountingSignal(rawMessageForGate) || hasAccountingSignal(rawMessageForGate) ||
@ -3217,11 +3221,13 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
decision: "run_address_lane", decision: "run_address_lane",
reason: hasClassifierSignal reason: hasClassifierSignal
? "address_mode_classifier_detected" ? "address_mode_classifier_detected"
: hasLlmCanonicalSignal : hasIntentSignal
? "llm_canonical_candidate_detected" ? "address_intent_resolver_detected"
: hasLlmCanonicalDataSignal : hasLlmCanonicalSignal
? "llm_canonical_data_signal_detected" ? "llm_canonical_candidate_detected"
: "address_signal_detected" : hasLlmCanonicalDataSignal
? "llm_canonical_data_signal_detected"
: "address_signal_detected"
}; };
} }
if (followupContext) { if (followupContext) {
@ -3705,8 +3711,8 @@ function hasDataRetrievalRequestSignal(text) {
if (hasBroadInterrogative && hasBroadBusinessObject) { if (hasBroadInterrogative && hasBroadBusinessObject) {
return true; 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 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)/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) { if (hasRussianRetrievalAction && hasRussianRetrievalObject) {
return true; 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 { 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 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 { function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
const hasPaymentRiskLexeme = const hasPaymentRiskLexeme =
/(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|долг|задолж)/iu.test( /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(
text text
); );
if (hasPaymentRiskLexeme) { if (hasPaymentRiskLexeme) {
@ -680,14 +694,14 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) { if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) {
return true; return true;
} }
if (/(?:сколько|скока|скок)\s+/iu.test(text)) { if (/(?:сколько|скока|скок)\s+/iu.test(text) && !hasLifecycleSegmentationSignal(text)) {
return false; return false;
} }
const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test( const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test(
text text
); );
const hasActivityLexeme = const hasActivityLexeme =
/(?:работал(?:и)?|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test( /(?:работал(?:и)?|работа(?:ет|ют)|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test(
text text
); );
const hasTimeWindowLexeme = const hasTimeWindowLexeme =
@ -936,9 +950,9 @@ function hasOpenContractsListSignal(text: string): boolean {
function hasSupplierTailRiskSignal(text: string): boolean { function hasSupplierTailRiskSignal(text: string): boolean {
const hasSupplier = /(?:поставщик|supplier|vendor)/iu.test(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 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); return hasSupplier && hasTail && (hasRisk || hasPeriodCue);
} }
@ -948,11 +962,30 @@ function hasReceivablesLatencyRiskSignal(text: string): boolean {
const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text); const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text);
const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text); const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text);
const hasDelay = /(?:длинн|долг|просроч|задерж|висят|тревог|too\s+long|late)/iu.test(text); const hasDelay = /(?:длинн|долг|просроч|задерж|висят|тревог|too\s+long|late)/iu.test(text);
const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); const hasOverdueDeadlineCue =
const hasPeriodOrRiskCue = /(?:за\s+текущ|на\s+конец|тревог|просроч|задерж|долг|длинн)/iu.test(text); /(?:срок(?:и|а)?(?:\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 = const hasBetweenShipmentAndPayment =
/между[\s\S]{0,80}(?:отправк|отгруз|реализ)[\s\S]{0,80}(?:оплат|платеж|платёж|payment)/iu.test(text); /между[\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 true;
} }
return (hasBuyer || hasCounterparty) && hasNonPayment && hasPeriodOrRiskCue; return (hasBuyer || hasCounterparty) && hasNonPayment && hasPeriodOrRiskCue;
@ -961,24 +994,50 @@ function hasReceivablesLatencyRiskSignal(text: string): boolean {
function hasSettlementGapSignal(text: string): boolean { function hasSettlementGapSignal(text: string): boolean {
const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text); const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text);
const hasDocument = /(?:док(?:и|умент|ументы|ументов)|docs?|documents?)/iu.test(text); const hasDocument = /(?:док(?:и|умент|ументы|ументов)|docs?|documents?)/iu.test(text);
const hasShipment = /(?:отгруз|реализ|shipment|delivery|товар|услуг)/iu.test(text);
const hasAdvance = /(?:аванс|предоплат)/iu.test(text); const hasAdvance = /(?:аванс|предоплат)/iu.test(text);
const hasClosureLexeme = /(?:закрыти|взаиморасч|акт|сч[её]т(?:ов|а|ы)?)/iu.test(text);
const hasNoDocumentForClosing = const hasNoDocumentForClosing =
/(?:нет|без)\s+(?:док(?:и|умент|ументы|ументов)|закрывающ)/iu.test(text) && /(?:нет|без)\s+(?:док(?:и|умент|ументы|ументов)|закрывающ)/iu.test(text) &&
/(?:закрыти|взаиморасч|акт)/iu.test(text); hasClosureLexeme;
const hasNoDocumentForClosingReversed = const hasNoDocumentForClosingReversed =
/(?:док(?:и|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) && /(?:док(?:и|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) &&
/(?:закрыти|взаиморасч|акт)/iu.test(text); hasClosureLexeme;
const hasNoPayments = const hasNoPayments =
/(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) || /(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) ||
/(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text); /(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text);
const hasDocsWithoutPayments = hasDocument && hasNoPayments; const hasDocsWithoutPayments = hasDocument && hasNoPayments;
const hasPaymentsWithoutClosingDocs = hasPayment && (hasNoDocumentForClosing || hasNoDocumentForClosingReversed); 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 = const hasUnclosedAdvanceGap =
hasAdvance && hasAdvance &&
(/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт)/iu.test(text) || (/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт|давно\s+пора\s+закрыть)/iu.test(text) ||
hasAdvanceStuckRisk ||
hasNoDocumentForClosing || hasNoDocumentForClosing ||
hasNoDocumentForClosingReversed); hasNoDocumentForClosingReversed);
return hasPaymentsWithoutClosingDocs || hasDocsWithoutPayments || hasUnclosedAdvanceGap; return (
hasPaymentsWithoutClosingDocs ||
hasPaymentsWithoutSettlementClosure ||
hasDocsWithoutPayments ||
hasShipmentWithoutClosingDocs ||
hasClosingWithoutSupportingDocs ||
hasUnclosedAdvanceGap
);
} }
function hasReconciliationMismatchSignal(text: string): boolean { 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)) { if (hasSupplierTailRiskSignal(text)) {
return { return {
intent: "list_payables_counterparties", intent: "list_payables_counterparties",
@ -1410,6 +1477,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if ( if (
hasAny(text, OPEN_ITEMS_HINTS) && hasAny(text, OPEN_ITEMS_HINTS) &&
!hasCounterpartyDebtLongevitySignal(text) &&
/(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test( /(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(
text text
) )

View File

@ -715,6 +715,103 @@ function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddres
return rows; 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 { function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean {
return ( return (
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || (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_contracts_by_counterparty" ||
intent === "list_documents_by_contract" || intent === "list_documents_by_contract" ||
intent === "bank_operations_by_contract" || intent === "bank_operations_by_contract" ||
intent === "list_payables_counterparties" ||
intent === "list_receivables_counterparties" ||
intent === "open_items_by_counterparty_or_contract" || intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" intent === "list_open_contracts"
); );
@ -1483,10 +1582,20 @@ export class AddressQueryService {
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({ const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
userMessage, userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, 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); 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") { if (intent.intent === "unknown") {
return buildLimitedExecutionResult({ 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) { if (recipeSelection.selected_recipe === null) {
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
mode, mode,
@ -1631,11 +1716,40 @@ export class AddressQueryService {
} }
} }
const plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters); let plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters);
const mcp = await executeAddressMcpQuery({ let mcp = await executeAddressMcpQuery({
query: plan.query, query: plan.query,
limit: plan.limit 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) { if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
@ -1696,7 +1810,21 @@ export class AddressQueryService {
}); });
const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching); const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching);
const filterByAnchors = anchorFilter.rows; 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 rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length);
const stageStatus = deriveMcpStageStatus({ const stageStatus = deriveMcpStageStatus({
rawRowsReceived: mcp.raw_rows.length, rawRowsReceived: mcp.raw_rows.length,
@ -1782,7 +1910,9 @@ export class AddressQueryService {
if ( if (
filteredRows.length === 0 && filteredRows.length === 0 &&
isAnchorRecoveryIntent(intent.intent) && 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 = const currentLimit =
typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit) 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 expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching);
const expandedRowsByAnchor = expandedAnchorFilter.rows; 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) { if (expandedFilteredRows.length > 0) {
const expandedRowDiagnostics = deriveRowStageDiagnostics( const expandedRowDiagnostics = deriveRowStageDiagnostics(
expandedMcp.raw_rows, expandedMcp.raw_rows,
@ -1938,7 +2082,21 @@ export class AddressQueryService {
}); });
const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching); const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching);
const broadenedRowsByAnchor = broadenedAnchorFilter.rows; 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) { if (broadenedFilteredRows.length > 0) {
const broadenedRowDiagnostics = deriveRowStageDiagnostics( const broadenedRowDiagnostics = deriveRowStageDiagnostics(
broadenedMcp.raw_rows, broadenedMcp.raw_rows,
@ -2059,7 +2217,21 @@ export class AddressQueryService {
}); });
const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching); const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching);
const historicalRowsByAnchor = historicalAnchorFilter.rows; 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) { if (historicalFilteredRows.length > 0) {
const historicalRowDiagnostics = deriveRowStageDiagnostics( const historicalRowDiagnostics = deriveRowStageDiagnostics(
historicalMcp.raw_rows, historicalMcp.raw_rows,

View File

@ -226,6 +226,20 @@ __WHERE_OUT__
`; `;
const COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE = ` const COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE = `
ВЫБРАТЬ
НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД) КАК Период,
"CP_CUSTOMER_ACTIVITY_YEAR" КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
КОЛИЧЕСТВО(*) КАК Сумма,
ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент
ИЗ
Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление
__WHERE_IN__
СГРУППИРОВАТЬ ПО
БанкПоступление.Контрагент,
НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД)
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ВЫБРАТЬ
МАКСИМУМ(БанкПоступление.Дата) КАК Период, МАКСИМУМ(БанкПоступление.Дата) КАК Период,
"CP_CUSTOMER_ACTIVITY" КАК Регистратор, "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"], optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 64, default_limit: 64,
account_scope: ["60", "76"], account_scope: ["60", "76"],
account_scope_mode: "preferred" account_scope_mode: "strict"
}, },
{ {
recipe_id: "address_movements_receivables_v1", recipe_id: "address_movements_receivables_v1",
@ -527,7 +542,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
optional_filters: ["as_of_date", "counterparty", "contract", "limit"], optional_filters: ["as_of_date", "counterparty", "contract", "limit"],
default_limit: 64, default_limit: 64,
account_scope: ["62", "76"], account_scope: ["62", "76"],
account_scope_mode: "preferred" account_scope_mode: "strict"
}, },
{ {
recipe_id: "address_open_contracts_candidates_v1", recipe_id: "address_open_contracts_candidates_v1",
@ -537,7 +552,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
optional_filters: ["as_of_date", "organization", "limit"], optional_filters: ["as_of_date", "organization", "limit"],
default_limit: 128, default_limit: 128,
account_scope: ["60", "62", "76"], account_scope: ["60", "62", "76"],
account_scope_mode: "preferred" account_scope_mode: "strict"
}, },
{ {
recipe_id: "address_open_items_by_party_or_contract_v1", recipe_id: "address_open_items_by_party_or_contract_v1",

View File

@ -13,6 +13,7 @@ interface ComposeFactualReplyOptions {
userMessage?: string; userMessage?: string;
periodFrom?: string; periodFrom?: string;
periodTo?: string; periodTo?: string;
asOfDate?: string;
} }
type PeriodProfileFocus = type PeriodProfileFocus =
@ -239,6 +240,99 @@ function normalizeQuestionText(value: string | null | undefined): string {
.trim(); .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 { function needsVatWhyExplanation(userMessage: string | null | undefined): boolean {
const text = normalizeQuestionText(userMessage); const text = normalizeQuestionText(userMessage);
if (!text) { if (!text) {
@ -380,6 +474,23 @@ function detectCounterpartyLifecycleFocus(userMessage: string | null | undefined
return "active_customers_period"; 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 { function detectMinOpsForAvgCheck(userMessage: string | null | undefined): number {
const text = normalizeQuestionText(userMessage); const text = normalizeQuestionText(userMessage);
if (!text) { if (!text) {
@ -461,6 +572,9 @@ function extractRequestedYearFromQuestion(userMessage: string | null | undefined
} }
function extractCounterpartyName(row: ComposeStageRow): string | null { function extractCounterpartyName(row: ComposeStageRow): string | null {
const skipTokenPattern =
/(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu;
for (const token of row.analytics) { for (const token of row.analytics) {
const normalized = String(token ?? "").trim(); const normalized = String(token ?? "").trim();
if (!normalized) { if (!normalized) {
@ -469,11 +583,216 @@ function extractCounterpartyName(row: ComposeStageRow): string | null {
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
continue; 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 normalized;
} }
return null; 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 { function extractContractName(row: ComposeStageRow): string | null {
for (const token of row.analytics) { for (const token of row.analytics) {
const normalized = String(token ?? "").trim(); const normalized = String(token ?? "").trim();
@ -946,7 +1265,44 @@ export function composeFactualReply(
const activityRows = rows.filter( const activityRows = rows.filter(
(row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY" (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) { for (const row of activityRows) {
const name = extractCounterpartyName(row); const name = extractCounterpartyName(row);
if (!name) { if (!name) {
@ -955,26 +1311,48 @@ export function composeFactualReply(
const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); const opsCount = Math.max(0, Math.trunc(row.amount ?? 0));
const current = byCounterparty.get(name); const current = byCounterparty.get(name);
if (!current) { 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; continue;
} }
if (opsCount > current.opsCount) { if (activityYearRows.length === 0 && opsCount > current.opsCount) {
current.opsCount = opsCount; current.opsCount = opsCount;
} }
if ((row.period ?? "") > (current.lastPeriod ?? "")) { if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period; 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) { if (right.opsCount !== left.opsCount) {
return right.opsCount - left.opsCount; return right.opsCount - left.opsCount;
} }
return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? ""); return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? "");
}); });
const focus = detectCounterpartyLifecycleFocus(options.userMessage);
const requestedYear = extractRequestedYearFromQuestion(options.userMessage);
const scopeLabel = const scopeLabel =
focus === "active_customers_all_time" focus === "active_customers_all_time"
? "за все время" ? "за все время"
@ -982,29 +1360,53 @@ export function composeFactualReply(
? `в ${requestedYear} году` ? `в ${requestedYear} году`
: "в выбранном периоде"; : "в выбранном периоде";
const lines: string[] = [ const lines: string[] = longevityQuestion
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`, ? [
"Собран профиль активности заказчиков (bank-doc activity aggregate).", `Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`,
`Строк агрегата: ${rows.length}.` "Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.",
]; `Строк агрегата: ${rows.length}.`
]
: [
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (counterparties.length === 0) { if (counterparties.length === 0) {
lines.push("По выбранному окну активности заказчики не найдены."); lines.push(
longevityQuestion
? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам."
: "По выбранному окну активности заказчики не найдены."
);
return { return {
responseType: "FACTUAL_SUMMARY", responseType: "FACTUAL_SUMMARY",
text: lines.join("\n") 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( lines.push(
...visible.map((item, index) => { ...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}` : ""; 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) { if (counterparties.length > visible.length) {
lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`); lines.push(
longevityQuestion
? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).`
: `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`
);
} }
return { return {
@ -1508,6 +1910,7 @@ export function composeFactualReply(
if (intent === "list_open_contracts") { if (intent === "list_open_contracts") {
const contracts = contractCandidatesFromRows(rows); const contracts = contractCandidatesFromRows(rows);
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [ const lines = [
"Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).", "Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).",
`Строк движения: ${rows.length}.`, `Строк движения: ${rows.length}.`,
@ -1515,6 +1918,17 @@ export function composeFactualReply(
]; ];
if (contracts.length > 0) { if (contracts.length > 0) {
lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)); 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 { } else {
lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback."); lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback.");
lines.push(...formatTopRows(rows, 6)); lines.push(...formatTopRows(rows, 6));
@ -1526,14 +1940,28 @@ export function composeFactualReply(
} }
if (intent === "list_payables_counterparties") { if (intent === "list_payables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [ const lines = [
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).", "Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
`Строк в выборке: ${rows.length}.`, `Строк в выборке: ${rows.length}.`,
...(rows.length > 0 `Контрагентов с сигналом: ${counterparties.length}.`
? ["Ниже примеры строк для ручной проверки."]
: ["Явных признаков системной задолженности по доступному срезу не найдено."]),
...formatTopRows(rows, 6)
]; ];
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 { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n")
@ -1541,14 +1969,67 @@ export function composeFactualReply(
} }
if (intent === "list_receivables_counterparties") { 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 = [ const lines = [
"Проверил покупателей с признаками затянутой оплаты (контур 62/76).", "Проверил покупателей с признаками затянутой оплаты (контур 62/76).",
`Строк в выборке: ${rows.length}.`, `Строк в выборке: ${rows.length}.`,
...(rows.length > 0 `Контрагентов с сигналом: ${counterparties.length}.`
? ["Ниже примеры строк, которые стоит проверить в первую очередь."]
: ["Явных признаков затяжной дебиторки по доступному срезу не найдено."]),
...formatTopRows(rows, 6)
]; ];
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 { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n")
@ -1556,11 +2037,26 @@ export function composeFactualReply(
} }
if (intent === "open_items_by_counterparty_or_contract") { if (intent === "open_items_by_counterparty_or_contract") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [ const lines = [
"Собраны открытые позиции по указанному фильтру (контрагент/договор).", "Собраны открытые позиции по взаиморасчетам.",
`Строк отобрано: ${rows.length}.`, `Строк отобрано: ${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 { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n")

View File

@ -3106,7 +3106,11 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
hasDeepAnalysisPreferenceSignal(rawMessageForGate) || hasDeepAnalysisPreferenceSignal(rawMessageForGate) ||
hasDeepAnalysisPreferenceSignal(repairedInputMessage); hasDeepAnalysisPreferenceSignal(repairedInputMessage);
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage); 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 llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode);
const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence); const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence);
const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
@ -3137,7 +3141,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
llmContractIntent === "unknown"; llmContractIntent === "unknown";
const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; const hasAnyAddressSignal =
hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal;
const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) || const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) ||
hasDataRetrievalRequestSignal(rawMessageForGate) || hasDataRetrievalRequestSignal(rawMessageForGate) ||
hasAccountingSignal(rawMessageForGate) || hasAccountingSignal(rawMessageForGate) ||
@ -3173,6 +3178,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
decision: "run_address_lane", decision: "run_address_lane",
reason: hasClassifierSignal reason: hasClassifierSignal
? "address_mode_classifier_detected" ? "address_mode_classifier_detected"
: hasIntentSignal
? "address_intent_resolver_detected"
: hasLlmCanonicalSignal : hasLlmCanonicalSignal
? "llm_canonical_candidate_detected" ? "llm_canonical_candidate_detected"
: hasLlmCanonicalDataSignal : hasLlmCanonicalDataSignal
@ -3661,8 +3668,8 @@ function hasDataRetrievalRequestSignal(text) {
if (hasBroadInterrogative && hasBroadBusinessObject) { if (hasBroadInterrogative && hasBroadBusinessObject) {
return true; 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 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)/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) { if (hasRussianRetrievalAction && hasRussianRetrievalObject) {
return true; return true;
} }

View File

@ -925,6 +925,90 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("Активные заказчики в 2020 году: 1."); 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", () => { it("returns contract usage overview summary", () => {
const reply = composeFactualReply("contract_usage_overview", [ const reply = composeFactualReply("contract_usage_overview", [
{ {
@ -1594,6 +1678,14 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("counterparty_activity_lifecycle"); 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", () => { it("resolves supplier lifecycle segmentation wording into lifecycle intent", () => {
const result = resolveAddressIntent("Раздели поставщиков на регулярных и эпизодических по активности."); const result = resolveAddressIntent("Раздели поставщиков на регулярных и эпизодических по активности.");
expect(result.intent).toBe("counterparty_activity_lifecycle"); 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"); 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", () => { it("routes reconciliation mismatch wording into open contracts intent", () => {
const result = resolveAddressIntent( const result = resolveAddressIntent(
"Покажи контрагентов, по которым сальдо скорее всего не совпадет с их актом сверки. Может, стоит поторопиться и запросить сверку?" "Покажи контрагентов, по которым сальдо скорее всего не совпадет с их актом сверки. Может, стоит поторопиться и запросить сверку?"
@ -1780,6 +1877,23 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("list_open_contracts"); 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", () => { it("routes documents-without-payments wording into open contracts intent", () => {
const result = resolveAddressIntent( 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 () => { it("injects as_of_date from analysis context when user message has no explicit period", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle("Покажи контрагентов с незакрытыми хвостами", { 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"); 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 () => { it("routes payments-without-closing-docs wording into open contracts lane", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle( 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"); 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 () => { it("routes stale advances wording into open contracts lane without missing-anchor fallback", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle( 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"); 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 () => { it("routes documents-without-payments wording into open contracts lane", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle( 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); 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 () => { it("routes stale contracts wording into contract usage overview recipe", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle("Какие договоры давно не использовались?"); 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); 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 service = new AddressQueryService();
const result = await service.tryHandle("show open items by contract"); const result = await service.tryHandle("show open items by contract");
expect(result?.handled).toBe(true); expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON"); expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract");
expect(result?.debug.limited_reason_category).toBe("missing_anchor"); expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(result?.debug.mcp_call_status).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 () => { 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.livingMode).toBe("address_data");
expect(decision.toolGateDecision).toBe("run_address_lane"); 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"); 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": "на выбранный период можно показать не закрытые договора - очевидно что по контексту это можно упростить до домена открытых договоров", "comment": "на выбранный период можно показать не закрытые договора - очевидно что по контексту это можно упростить до домена открытых договоров",
"manual_case_decision": "needs_dialog_policy_fix", "manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:57:36.621Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:13:08.915Z", "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": { "context": {
"message_id": "msg-zs_PZOy4zu", "message_id": "msg-zs_PZOy4zu",
"trace_id": "address-KYzulAwWgo", "trace_id": "address-KYzulAwWgo",
@ -279,11 +279,11 @@
"comment": "в чем проблема чекнуть это в 1с? это же не сложно вродже?", "comment": "в чем проблема чекнуть это в 1с? это же не сложно вродже?",
"manual_case_decision": "needs_routing_extension", "manual_case_decision": "needs_routing_extension",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:56:58.642Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:16:08.588Z", "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": { "context": {
"message_id": "msg-3otxbY6nSu", "message_id": "msg-3otxbY6nSu",
"trace_id": "address-VpPvao4Vua", "trace_id": "address-VpPvao4Vua",
@ -333,11 +333,11 @@
"comment": "необходим вывод открытых договоров не закрытых актими или финальными выплатами", "comment": "необходим вывод открытых договоров не закрытых актими или финальными выплатами",
"manual_case_decision": "needs_routing_extension", "manual_case_decision": "needs_routing_extension",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:56:26.168Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:18:21.929Z", "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": { "context": {
"message_id": "msg-Rviqs5LOve", "message_id": "msg-Rviqs5LOve",
"trace_id": "address-2YTJldRV28", "trace_id": "address-2YTJldRV28",
@ -414,11 +414,11 @@
"comment": "надо отрабатывать маршрут - вопрос простой и полезный", "comment": "надо отрабатывать маршрут - вопрос простой и полезный",
"manual_case_decision": "candidate_for_implementation", "manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:55:49.575Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:21:09.469Z", "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": { "context": {
"message_id": "msg-h4Uw2x6woE", "message_id": "msg-h4Uw2x6woE",
"trace_id": "address-mhaJ03Mjei", "trace_id": "address-mhaJ03Mjei",
@ -468,11 +468,11 @@
"comment": "почему мы вообще отвечаем так шаблонно?? зачем нам ллм? может на три уровня на старте ллм поставить?разбор контекста - декомпозиция - и если не можем отработать то ответ уже человеческий в контексте? сейчас постояннно одинаковые ответы это бесит", "comment": "почему мы вообще отвечаем так шаблонно?? зачем нам ллм? может на три уровня на старте ллм поставить?разбор контекста - декомпозиция - и если не можем отработать то ответ уже человеческий в контексте? сейчас постояннно одинаковые ответы это бесит",
"manual_case_decision": "bad_test_case", "manual_case_decision": "bad_test_case",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:55:10.038Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:25:30.011Z", "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": { "context": {
"message_id": "msg-H7JqG6Ni0g", "message_id": "msg-H7JqG6Ni0g",
"trace_id": "address-fGojfD8Utf", "trace_id": "address-fGojfD8Utf",
@ -522,11 +522,11 @@
"comment": "очень важный кейс - надо отрабатывать - причем потенциально мы должны это умееть - ут надо показать кто из заказчиков сидит с отрытыми договорами на дату рассмотрения", "comment": "очень важный кейс - надо отрабатывать - причем потенциально мы должны это умееть - ут надо показать кто из заказчиков сидит с отрытыми договорами на дату рассмотрения",
"manual_case_decision": "candidate_for_implementation", "manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:53:55.569Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T09:27:54.906Z", "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": { "context": {
"message_id": "msg-3RW8I_1Y4f", "message_id": "msg-3RW8I_1Y4f",
"trace_id": "address-3FNYpnavQE", "trace_id": "address-3FNYpnavQE",
@ -576,11 +576,11 @@
"comment": "мы модем показать договора заведенные без оплавт на период рассмотрения - тема не сложная надо отработать", "comment": "мы модем показать договора заведенные без оплавт на период рассмотрения - тема не сложная надо отработать",
"manual_case_decision": "needs_routing_extension", "manual_case_decision": "needs_routing_extension",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:53:31.252Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T21:06:49.639Z", "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": { "context": {
"message_id": "msg-MWxfeEbPmS", "message_id": "msg-MWxfeEbPmS",
"trace_id": "address-V5-7tJrBPM", "trace_id": "address-V5-7tJrBPM",
@ -603,11 +603,11 @@
"comment": "тут надо сапоставить договора с датами и отсутствие платежей по ним или старые платежи авансовые - надо дороботать - вопрос простой и важный", "comment": "тут надо сапоставить договора с датами и отсутствие платежей по ним или старые платежи авансовые - надо дороботать - вопрос простой и важный",
"manual_case_decision": "candidate_for_implementation", "manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:52:52.569Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T21:08:53.728Z", "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": { "context": {
"message_id": "msg-GwBH6jyVi_", "message_id": "msg-GwBH6jyVi_",
"trace_id": "address-719KtaE1Li", "trace_id": "address-719KtaE1Li",
@ -630,11 +630,11 @@
"comment": "технический ответ - такого быть не должно", "comment": "технический ответ - такого быть не должно",
"manual_case_decision": "needs_dialog_policy_fix", "manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:52:12.080Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T21:09:36.200Z", "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": { "context": {
"message_id": "msg-xCoMu24uIa", "message_id": "msg-xCoMu24uIa",
"trace_id": "Idd369iAGgAGpm", "trace_id": "Idd369iAGgAGpm",
@ -657,11 +657,11 @@
"comment": "ушло не в ту ветку - ответ совершенно не в кассу", "comment": "ушло не в ту ветку - ответ совершенно не в кассу",
"manual_case_decision": "needs_dialog_policy_fix", "manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:38:35.315Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-10T21:10:27.894Z", "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": { "context": {
"message_id": "msg-_p_ppJ9bfV", "message_id": "msg-_p_ppJ9bfV",
"trace_id": "chat-QQxVBg9vSO", "trace_id": "chat-QQxVBg9vSO",
@ -684,11 +684,11 @@
"comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов", "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов",
"manual_case_decision": "candidate_for_implementation", "manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T19:37:36.816Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:44:50.920Z", "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": { "context": {
"message_id": "msg-tL1QVBeDxY", "message_id": "msg-tL1QVBeDxY",
"trace_id": "Ylge9xWuRuJLvV", "trace_id": "Ylge9xWuRuJLvV",
@ -711,11 +711,11 @@
"comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - однозначно к дорабюотке - анализируем заказчиков которые чаще встречаются по годам и выводим топ 10", "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - однозначно к дорабюотке - анализируем заказчиков которые чаще встречаются по годам и выводим топ 10",
"manual_case_decision": "needs_dialog_policy_fix", "manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T20:33:42.820Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:45:50.144Z", "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": { "context": {
"message_id": "msg-rj83nhGOV7", "message_id": "msg-rj83nhGOV7",
"trace_id": "address-H9VJ13GWWC", "trace_id": "address-H9VJ13GWWC",
@ -738,11 +738,11 @@
"comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - простой вопрос - показываем просто открытые договора без приходов денег", "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - простой вопрос - показываем просто открытые договора без приходов денег",
"manual_case_decision": "candidate_for_implementation", "manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T18:08:27.601Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:46:41.463Z", "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": { "context": {
"message_id": "msg-2kDN4UKCbY", "message_id": "msg-2kDN4UKCbY",
"trace_id": "address-L0WwEsakCe", "trace_id": "address-L0WwEsakCe",
@ -765,11 +765,11 @@
"comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - надо внедрять", "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - надо внедрять",
"manual_case_decision": "candidate_for_implementation", "manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T18:32:00.081Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:47:04.120Z", "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": { "context": {
"message_id": "msg--ZmllegVvV", "message_id": "msg--ZmllegVvV",
"trace_id": "cG51b5sIOWKwTi", "trace_id": "cG51b5sIOWKwTi",
@ -792,11 +792,11 @@
"comment": "покеаываем клиентов с открытими договорами которые висят более месяца бенз денег - выводимм том 10", "comment": "покеаываем клиентов с открытими договорами которые висят более месяца бенз денег - выводимм том 10",
"manual_case_decision": "candidate_for_implementation", "manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T18:07:46.388Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:48:11.847Z", "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": { "context": {
"message_id": "msg-6GpvEMKGcQ", "message_id": "msg-6GpvEMKGcQ",
"trace_id": "address-lGGvDiH21w", "trace_id": "address-lGGvDiH21w",
@ -819,11 +819,11 @@
"comment": "однозначно на расширение доменов", "comment": "однозначно на расширение доменов",
"manual_case_decision": "candidate_for_implementation", "manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T18:07:26.769Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:49:02.831Z", "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": { "context": {
"message_id": "msg-bZZQxlpKcO", "message_id": "msg-bZZQxlpKcO",
"trace_id": "address-JiGLTVe3_0", "trace_id": "address-JiGLTVe3_0",
@ -846,11 +846,11 @@
"comment": "проблема тыт шум - важно показать договора с незакрытыми доками - топ по времени висения - от самой длинной дистании до кооротной", "comment": "проблема тыт шум - важно показать договора с незакрытыми доками - топ по времени висения - от самой длинной дистании до кооротной",
"manual_case_decision": "needs_dialog_policy_fix", "manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T18:06:44.816Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:50:25.257Z", "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": { "context": {
"message_id": "msg-3Leq0R75YH", "message_id": "msg-3Leq0R75YH",
"trace_id": "E1O-azugbPvNG9", "trace_id": "E1O-azugbPvNG9",
@ -873,11 +873,11 @@
"comment": "нужен анализ и отработка домена", "comment": "нужен анализ и отработка домена",
"manual_case_decision": "candidate_for_implementation", "manual_case_decision": "candidate_for_implementation",
"annotation_author": "manual_reviewer", "annotation_author": "manual_reviewer",
"resolved": false, "resolved": true,
"resolved_at": null, "resolved_at": "2026-04-11T18:05:57.111Z",
"resolved_by": null, "resolved_by": "manual_reviewer",
"created_at": "2026-04-11T12:50:48.487Z", "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": { "context": {
"message_id": "msg-6Fqd6XrBv2", "message_id": "msg-6Fqd6XrBv2",
"trace_id": "address-TnBvKVAVfu", "trace_id": "address-TnBvKVAVfu",