From d65969d2ff318ae03ea22f273f3f248161c5e5ff Mon Sep 17 00:00:00 2001 From: dctouch Date: Sat, 11 Apr 2026 23:34:59 +0300 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=9B=D0=9E=D0=91=D0=90=D0=9B=D0=AC?= =?UTF-8?q?=D0=9D=D0=AB=D0=99=20=D0=A0=D0=95=D0=A4=D0=90=D0=9A=D0=A2=D0=9E?= =?UTF-8?q?=D0=A0=D0=98=D0=9D=D0=93=20=D0=90=D0=A0=D0=A5=D0=98=D0=A2=D0=95?= =?UTF-8?q?=D0=9A=D0=A2=D0=A3=D0=A0=D0=AB=20-=20=D0=A0=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=20=20Stage=203.7=20=20=D0=A5=D0=92=D0=9E?= =?UTF-8?q?=D0=A1=D0=A2=D0=AB=20=D1=84=D0=B8=D0=BA=D1=81=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D1=88=D1=80=D1=83=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D1=83=20=D0=B7=D0=B0=D0=B4=D0=BE?= =?UTF-8?q?=D0=BB=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TECH/1CLLMARCH-FACT.md | 137 ++++- .../dist/services/addressIntentResolver.js | 69 ++- .../dist/services/addressQueryService.js | 202 ++++++- .../dist/services/addressRecipeCatalog.js | 21 +- .../services/address_runtime/composeStage.js | 450 +++++++++++++- .../backend/dist/services/assistantService.js | 24 +- .../src/services/addressIntentResolver.ts | 94 ++- .../src/services/addressQueryService.ts | 238 ++++++-- .../src/services/addressRecipeCatalog.ts | 21 +- .../services/address_runtime/composeStage.ts | 548 +++++++++++++++++- .../backend/src/services/assistantService.ts | 15 +- .../tests/addressQueryRuntimeM23.test.ts | 225 ++++++- .../tests/assistantLivingRouter.test.ts | 2 +- ...sistantWave17RunRegression20260411.test.ts | 108 ++++ ...tantWave18ManualCommentsRegression.test.ts | 187 ++++++ .../data/autorun_annotations/annotations.json | 144 ++--- 16 files changed, 2248 insertions(+), 237 deletions(-) create mode 100644 llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts create mode 100644 llm_normalizer/backend/tests/assistantWave18ManualCommentsRegression.test.ts diff --git a/docs/TECH/1CLLMARCH-FACT.md b/docs/TECH/1CLLMARCH-FACT.md index ea45fba..1faacf6 100644 --- a/docs/TECH/1CLLMARCH-FACT.md +++ b/docs/TECH/1CLLMARCH-FACT.md @@ -2453,13 +2453,148 @@ Implemented in current pass (Stage 3.6 route arbitration hardening + followup is - Targeted regression pack: `4` files / `318` tests passed (`assistantLivingRouter`, `assistantLivingChatMode`, `addressQueryRuntimeM23`, `assistantSoftPolicyReply`). - Type build: `npm --prefix llm_normalizer/backend run build` passed. +Implemented in current pass (Stage 3.7 real-run regression hardening, 2026-04-11): +1. Added a dedicated regression pack from real failing runs: + - New test file: + - `llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts` +2. Covered two production run cohorts directly: + - `assistant-stage1-aXaATwBpP6` (2026-04-11 14:59 local run family) + - `assistant-stage1-RAvQiKIFKX` (2026-04-11 17:51 local run family) +3. Locked critical route/answer protections from these runs: + - data-heavy prompts remain in address lane (no accidental chat drift); + - short follow-up style prompts (`без воды?`, `и коротко?`, `прям сейчас?`) stay in deep fallback when predecompose is unsupported (no generic chat fallback); + - slang data-scope wording (`по каким конторам можем общаться?`) remains in chat meta/data-scope mode; + - open-contract request with stale deep follow-up context keeps address lane priority; + - unsupported aggregate answers use soft-refusal structure and no longer rely on old rigid phrase (`Сейчас этот тип вопроса вне поддерживаемого контура адресного режима...`). +4. Validation snapshot: + - New regression file: `1` file / `5` tests passed. + - Extended focused suite: `6` files / `66` tests passed (`assistantWave17RunRegression20260411`, `assistantLivingRouter`, `assistantLivingChatMode`, `assistantSoftPolicyReply`, `assistantBoundaryFallbackReply`, `assistantAnswerPolicyV11`). + +Implemented in current pass (Stage 3.8 manual-comment wave hardening, 2026-04-11): +1. Added manual-comment regression pack based on unresolved GUI annotations: + - New test file: + - `llm_normalizer/backend/tests/assistantWave18ManualCommentsRegression.test.ts` + - Scope: + - 18 unresolved manual comments from: + - `assistant-stage1-UMKkFYfg2L` + - `assistant-stage1-ywEyJgFkC4` + - `assistant-stage1-ZL97weIIRG` +2. Hardened intent recognition for weakly-structured real phrasing in address lane: + - `llm_normalizer/backend/src/services/addressIntentResolver.ts` + - Improvements: + - supplier-tail wording (`не закрывают счета`, `больше месяца`) now maps to supported payables/open-items intent; + - receivables latency wording (`не платят несколько месяцев`, payment-vs-shipment imbalance, negative saldo risk) now maps to supported receivables intent; + - stuck advances wording (`зависшие авансы`, `пора закрыть`, `перепривязать`, `списывать`) now maps to settlement-gap/open-contracts intent. +3. Hardened orchestration tool-gate against false capability drift on data requests: + - `llm_normalizer/backend/src/services/assistantService.ts` + - Improvements: + - retrieval-action/object detection expanded for imperative finance requests (`проверь` + `аванс/отгруз/долг` objects); + - tool-gate now uses both raw and repaired message variants for classifier/intent signals to avoid false negatives from over-repair. +4. Updated reason-code expectations in route regressions after new intent-signal source: + - `llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts` + - `llm_normalizer/backend/tests/assistantLivingRouter.test.ts` +5. Validation snapshot: + - Focused regression pack: `4` files / `58` tests passed: + - `assistantWave17RunRegression20260411.test.ts` + - `assistantWave18ManualCommentsRegression.test.ts` + - `assistantLivingRouter.test.ts` + - `assistantLivingChatMode.test.ts` + - Build validation: + - `npm run build` (backend) passed. + +Implemented in current pass (Stage 3.9 anti-template risk-lane stabilization, 2026-04-11): +1. Removed hard `missing_anchor` gate for broad open-items scans: + - `llm_normalizer/backend/src/services/addressQueryService.ts` + - `open_items_by_counterparty_or_contract` can now run as a broad risk scan without mandatory counterparty/contract anchor. +2. Stopped wrong-domain leakage in risk intents: + - `llm_normalizer/backend/src/services/addressRecipeCatalog.ts` + - switched to strict account scope for: + - `list_payables_counterparties` (`60/76`) + - `list_receivables_counterparties` (`62/76`) + - `list_open_contracts` (`60/62/76`) +3. Added future-date guard for risk replies: + - `llm_normalizer/backend/src/services/addressQueryService.ts` + - rows far beyond analysis/reference date (e.g., synthetic `2030-*`) are excluded from factual reply assembly for risk intents. +4. Expanded anchor-recovery behavior for risk lanes: + - when initial slice has raw rows but zero rows after strict account scope, runtime can auto-expand live limit for recovery (instead of silent fallback to irrelevant rows). +5. Reworked factual composer for risk intents to reduce repeated templates: + - `llm_normalizer/backend/src/services/address_runtime/composeStage.ts` + - for payables/receivables/open-items/open-contracts now builds counterparty risk ranking (sum + ops + last period) before raw row fallback. +6. Regression updates: + - `llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts` + - updated open-items no-anchor expectation to match new broad-scan contract; + - added checks for strict scope and anti-leak behavior in receivables/open-contract intents. +7. Validation snapshot: + - `npm.cmd run test -- --run tests/addressQueryRuntimeM23.test.ts` -> `271 passed` + - `npm.cmd run test -- --run tests/addressQueryRuntimeM23.test.ts tests/assistantWave17RunRegression20260411.test.ts` -> `274 passed` + - `npm run build` (backend) passed. + +Implemented in current pass (Stage 3.10 debt-lifecycle routing for reviewer comment AUTO-004, 2026-04-11): +1. Added explicit intent arbitration for debt-longevity customer phrasing: + - `llm_normalizer/backend/src/services/addressIntentResolver.ts` + - New signal `hasCounterpartyDebtLongevitySignal(...)` routes queries like + `Сколько заказчиков ... долгожителями по задолженностям` to + `counterparty_activity_lifecycle` instead of `open_items_by_counterparty_or_contract`. +2. Prevented open-items override for this class of queries: + - Open-items branch now skips debt-longevity customer wording, preserving lifecycle route. +3. Expanded lifecycle recipe to support year-based ranking: + - `llm_normalizer/backend/src/services/addressRecipeCatalog.ts` + - `COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE` now includes yearly aggregate marker + `CP_CUSTOMER_ACTIVITY_YEAR` (counterparty x year). +4. Reworked lifecycle composer for debt-longevity question style: + - `llm_normalizer/backend/src/services/address_runtime/composeStage.ts` + - Added deterministic top-10 output by: + - number of active years, + - operation frequency, + - period span; + - Response now explicitly provides `лет в базе` and year list. +5. Added regression coverage: + - `llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts` + - new checks for: + - resolver mapping of debt-longevity wording to lifecycle intent; + - address runtime route + recipe selection; + - factual reply contains top ranking by years. +6. Validation snapshot: + - `npm.cmd run test -- --run tests/addressQueryRuntimeM23.test.ts` -> `274 passed` + - `npm.cmd run test -- --run tests/assistantWave17RunRegression20260411.test.ts tests/assistantLivingRouter.test.ts tests/assistantLivingChatMode.test.ts` -> `55 passed` + - `npm run build` (backend) passed. + +Implemented in current pass (Stage 3.11 unresolved manual-comment routing hardening, 2026-04-11): +1. Expanded semantic routing for overdue receivables and settlement-gap phrasing from unresolved reviewer cases: + - `llm_normalizer/backend/src/services/addressIntentResolver.ts` + - Added overdue-deadline cue support (`сроки ... прошли`) for non-payment receivables wording. + - Added settlement-gap signals for: + - payments without settlement closure (`оплаты без закрытия взаиморасчетов`); + - shipments without closing docs (`отгрузки без документов для закрытия`); + - closing without supporting docs (`закрытие счетов без подтверждающих документов`). +2. Locked new routing with regression coverage: + - `llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts` + - Added resolver + runtime checks for the exact unresolved wave wording above. +3. Stabilized Wave18 regression harness against annotation-state drift: + - `llm_normalizer/backend/tests/assistantWave18ManualCommentsRegression.test.ts` + - Removed brittle hard dependency on `resolved=false` for all fixed keys. + - Kept live runtime assertions on unresolved subset and increased explicit timeout for integration path. +4. Validation snapshot: + - Stage 3 focused suite: + - `tests/addressQueryRuntimeM23.test.ts` + - `tests/assistantWave18ManualCommentsRegression.test.ts` + - `tests/assistantWave17RunRegression20260411.test.ts` + - `tests/assistantLivingRouter.test.ts` + - `tests/assistantLivingChatMode.test.ts` + - `tests/assistantSoftPolicyReply.test.ts` + - `tests/assistantBoundaryFallbackReply.test.ts` + - `tests/assistantAnswerPolicyV11.test.ts` + - `tests/assistantSemanticExtractionContract.test.ts` + - Result: `9 files / 354 tests passed`. + - `npm run build` (backend) passed. + Acceptance (Stage 3): 1. LLM outputs strictly validated schema for extraction/decomposition (no free-form). 2. Deterministic guards can block or downgrade answers when evidence insufficient. 3. False route drifts and generic responses reduced in regression packs. 4. Manual markup shows increase in “correct/grounded” labels. -Status: Planned +Status: In validation (functional gates green; manual re-markup trend confirmation pending) ## Stage 4 (P2): Human-Centric Answer Layer diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index fd55955..f60a273 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -259,6 +259,7 @@ const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [ "кто ушел", "кто ушёл", "только один раз", + "дольше всего", "дольше всех", "долгоживущие контрагенты", "регулярные поставщики", @@ -601,10 +602,16 @@ function hasCounterpartyPopulationAndRolesSignal(text) { return false; } function hasLifecycleSegmentationSignal(text) { - return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text); + return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|дольше\s+всего|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text); +} +function hasCounterpartyDebtLongevitySignal(text) { + const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test(text); + const hasDebtLexeme = /(?:долг(?:и|ов|а|у)?|задолж(?:енность|енности|енностям|ал|али)?|просроч|хвост)/iu.test(text); + const hasLongevityCue = /(?:долгожив|долгожител|несколько\s+месяц|по\s+годам|дольше|лет|год(?:ам|а|у|ы)?|на\s+этот\s+момент|длительн)/iu.test(text); + return hasCounterpartyLexeme && hasDebtLexeme && hasLongevityCue; } function hasCounterpartyActivityLifecycleSignal(text) { - const hasPaymentRiskLexeme = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|долг|задолж)/iu.test(text); + const hasPaymentRiskLexeme = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test(text); if (hasPaymentRiskLexeme) { return false; } @@ -614,11 +621,11 @@ function hasCounterpartyActivityLifecycleSignal(text) { if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) { return true; } - if (/(?:сколько|скока|скок)\s+/iu.test(text)) { + if (/(?:сколько|скока|скок)\s+/iu.test(text) && !hasLifecycleSegmentationSignal(text)) { return false; } const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test(text); - const hasActivityLexeme = /(?:работал(?:и)?|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test(text); + const hasActivityLexeme = /(?:работал(?:и)?|работа(?:ет|ют)|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test(text); const hasTimeWindowLexeme = /(?:за\s+вс[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|в\s+конкретн(?:ом|ый)\s+год|за\s+год|в\s+году)/iu.test(text); const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text); const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text); @@ -811,9 +818,9 @@ function hasOpenContractsListSignal(text) { } function hasSupplierTailRiskSignal(text) { const hasSupplier = /(?:поставщик|supplier|vendor)/iu.test(text); - const hasTail = /(?:хвост|висят|незакрыт|задолж|долг|просроч)/iu.test(text); + const hasTail = /(?:хвост|висят|незакрыт|не\s+закрыв|задолж|долг|просроч|сч[её]т)/iu.test(text); const hasRisk = /(?:систематич|регулярн|проблем|тревог|не\s+разов|больше\s+похож)/iu.test(text); - const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц)/iu.test(text); + const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text); return hasSupplier && hasTail && (hasRisk || hasPeriodCue); } function hasReceivablesLatencyRiskSignal(text) { @@ -822,10 +829,20 @@ function hasReceivablesLatencyRiskSignal(text) { const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text); const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text); const hasDelay = /(?:длинн|долг|просроч|задерж|висят|тревог|too\s+long|late)/iu.test(text); - const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); - const hasPeriodOrRiskCue = /(?:за\s+текущ|на\s+конец|тревог|просроч|задерж|долг|длинн)/iu.test(text); + const hasOverdueDeadlineCue = /(?:срок(?:и|а)?(?:\s+оплат[ыы]?)?[\s\S]{0,24}(?:прош|выш|истек|истёк)|срок(?:и|а)?\s+давно\s+прошл|давно\s+пора\s+оплат|давно\s+не\s+оплач)/iu.test(text); + const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|не\s+оплат|не\s+оплач|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); + const hasPaymentShipmentImbalance = /(?:оплач(?:ено|ен[аоы]?|ивать|ивать)?\s+меньше[\s\S]{0,36}отгруж|недоплат[\s\S]{0,36}отгруж|отгруж[\s\S]{0,36}оплач(?:ено|ено\s+меньше))/iu.test(text); + const hasNegativeSaldoRisk = /(?:сальд[оа]\s+(?:уже\s+)?отрицат|минусов(?:ое|ой)\s+сальдо|сальдо\s+в\s+минус)/iu.test(text); + const hasPeriodOrRiskCue = /(?:за\s+текущ|на\s+конец|тревог|просроч|задерж|долг|длинн|несколько\s+месяц|больше\s+месяц)/iu.test(text) || + hasOverdueDeadlineCue || + hasNegativeSaldoRisk; const hasBetweenShipmentAndPayment = /между[\s\S]{0,80}(?:отправк|отгруз|реализ)[\s\S]{0,80}(?:оплат|платеж|платёж|payment)/iu.test(text); - if (hasBuyer && hasPayment && ((hasShipment && hasDelay) || hasBetweenShipmentAndPayment)) { + if (hasBuyer && + hasPayment && + ((hasShipment && (hasDelay || hasOverdueDeadlineCue)) || hasBetweenShipmentAndPayment || hasPaymentShipmentImbalance)) { + return true; + } + if ((hasBuyer || hasCounterparty) && hasPaymentShipmentImbalance) { return true; } return (hasBuyer || hasCounterparty) && hasNonPayment && hasPeriodOrRiskCue; @@ -833,20 +850,38 @@ function hasReceivablesLatencyRiskSignal(text) { function hasSettlementGapSignal(text) { const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text); const hasDocument = /(?:док(?:и|умент|ументы|ументов)|docs?|documents?)/iu.test(text); + const hasShipment = /(?:отгруз|реализ|shipment|delivery|товар|услуг)/iu.test(text); const hasAdvance = /(?:аванс|предоплат)/iu.test(text); + const hasClosureLexeme = /(?:закрыти|взаиморасч|акт|сч[её]т(?:ов|а|ы)?)/iu.test(text); const hasNoDocumentForClosing = /(?:нет|без)\s+(?:док(?:и|умент|ументы|ументов)|закрывающ)/iu.test(text) && - /(?:закрыти|взаиморасч|акт)/iu.test(text); + hasClosureLexeme; const hasNoDocumentForClosingReversed = /(?:док(?:и|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) && - /(?:закрыти|взаиморасч|акт)/iu.test(text); + hasClosureLexeme; const hasNoPayments = /(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) || /(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text); const hasDocsWithoutPayments = hasDocument && hasNoPayments; const hasPaymentsWithoutClosingDocs = hasPayment && (hasNoDocumentForClosing || hasNoDocumentForClosingReversed); + const hasPaymentsWithoutSettlementClosure = hasPayment && + /(?:без|нет)\s+закрыти(?:я|й)?(?:\s+взаиморасч[её]тов)?/iu.test(text) && + hasClosureLexeme; + const hasShipmentWithoutClosingDocs = hasShipment && + (hasNoDocumentForClosing || + hasNoDocumentForClosingReversed || + /(?:без|нет)\s+док(?:и|умент(?:ов|ы|а)?)\s+(?:для\s+)?(?:их\s+)?закрыти/u.test(text)); + const hasClosingWithoutSupportingDocs = hasClosureLexeme && + /(?:без|нет)\s+подтверждающ(?:их|его|ие)?\s+док(?:и|умент(?:ов|ы|а)?)/iu.test(text); + const hasAdvanceStuckRisk = /(?:зависш(?:ий|ие|ая|ие\s+аванс)|давно\s+пора\s+закрыть|пора\s+закрывать|перепривяз(?:ать|к)|списыв(?:ать|ани|ан)|нереальн)/iu.test(text); const hasUnclosedAdvanceGap = hasAdvance && - (/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт)/iu.test(text) || + (/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт|давно\s+пора\s+закрыть)/iu.test(text) || + hasAdvanceStuckRisk || hasNoDocumentForClosing || hasNoDocumentForClosingReversed); - return hasPaymentsWithoutClosingDocs || hasDocsWithoutPayments || hasUnclosedAdvanceGap; + return (hasPaymentsWithoutClosingDocs || + hasPaymentsWithoutSettlementClosure || + hasDocsWithoutPayments || + hasShipmentWithoutClosingDocs || + hasClosingWithoutSupportingDocs || + hasUnclosedAdvanceGap); } function hasReconciliationMismatchSignal(text) { const hasCounterparty = /(?:контрагент|поставщик|клиент|покупател|customer|supplier|counterparty)/iu.test(text); @@ -1196,6 +1231,13 @@ function resolveAddressIntent(userMessage) { reasons: ["receivables_payment_lag_signal_detected"] }; } + if (hasCounterpartyDebtLongevitySignal(text)) { + return { + intent: "list_receivables_counterparties", + confidence: "medium", + reasons: ["receivables_debt_lifecycle_signal_detected"] + }; + } if (hasSupplierTailRiskSignal(text)) { return { intent: "list_payables_counterparties", @@ -1225,6 +1267,7 @@ function resolveAddressIntent(userMessage) { }; } if (hasAny(text, OPEN_ITEMS_HINTS) && + !hasCounterpartyDebtLongevitySignal(text) && /(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test(text)) { return { intent: "open_items_by_counterparty_or_contract", diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index e22928f..d1944e8 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -608,6 +608,87 @@ function applyIntentSpecificFilter(intent, rows) { } return rows; } +function parseIsoDateUtcTimestamp(value) { + const source = String(value ?? "").trim(); + const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + if (month < 1 || month > 12 || day < 1 || day > 31) { + return null; + } + return Date.UTC(year, month - 1, day); +} +function isCounterpartyRiskIntent(intent) { + return (intent === "list_receivables_counterparties" || + intent === "list_payables_counterparties" || + intent === "list_open_contracts" || + intent === "open_items_by_counterparty_or_contract"); +} +function resolveFutureGuardReferenceDate(analysisDate, filters) { + if (analysisDate) { + return analysisDate; + } + const asOfDate = normalizeAnalysisDateHint(filters.as_of_date); + if (asOfDate) { + return asOfDate; + } + const periodTo = normalizeAnalysisDateHint(filters.period_to); + if (periodTo) { + return periodTo; + } + return null; +} +function isMissingSubcontoFieldError(errorText) { + const normalized = String(errorText ?? "") + .toLowerCase() + .replace(/\s+/g, " "); + if (!normalized) { + return false; + } + return (normalized.includes("поле не найдено") && + (normalized.includes("субконтодт1") || + normalized.includes("subcontodt1") || + normalized.includes("subconto_dt1"))); +} +function applyFutureDatedRowsGuard(rows, intent, referenceDate) { + if (!isCounterpartyRiskIntent(intent) || rows.length === 0) { + return { + rows, + droppedCount: 0 + }; + } + const referenceTs = (() => { + const explicitTs = parseIsoDateUtcTimestamp(referenceDate); + if (explicitTs !== null) { + return explicitTs; + } + const now = new Date(); + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + })(); + const guardTailMs = 31 * 24 * 60 * 60 * 1000; + const latestAllowedTs = referenceTs + guardTailMs; + const keptRows = []; + let droppedCount = 0; + for (const row of rows) { + const rowTs = parseIsoDateUtcTimestamp(row.period); + if (rowTs !== null && rowTs > latestAllowedTs) { + droppedCount += 1; + continue; + } + keptRows.push(row); + } + return { + rows: keptRows, + droppedCount + }; +} function hasExplicitPeriodWindow(filters) { return ((typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || (typeof filters.period_to === "string" && filters.period_to.trim().length > 0)); @@ -630,6 +711,8 @@ function isAnchorRecoveryIntent(intent) { intent === "list_contracts_by_counterparty" || intent === "list_documents_by_contract" || intent === "bank_operations_by_contract" || + intent === "list_payables_counterparties" || + intent === "list_receivables_counterparties" || intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts"); } @@ -1197,10 +1280,19 @@ class AddressQueryService { const composeOptionsFromFilters = (filterSet) => ({ userMessage, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, - periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined + periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, + asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined }); + const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, filters.extracted_filters); let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters); - const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, filters.extracted_filters); + const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" && + Array.isArray(intent.reasons) && + intent.reasons.includes("receivables_debt_lifecycle_signal_detected"); + const recipeIntent = debtLifecycleReceivablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent; + const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, filters.extracted_filters); + if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) { + baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle"); + } if (intent.intent === "unknown") { return buildLimitedExecutionResult({ mode, @@ -1220,27 +1312,6 @@ class AddressQueryService { reasons: baseReasons }); } - if (intent.intent === "open_items_by_counterparty_or_contract" && - !filters.extracted_filters.counterparty && - !filters.extracted_filters.contract) { - return buildLimitedExecutionResult({ - mode, - shape, - intent, - filters: filters.extracted_filters, - missingRequiredFilters: ["counterparty_or_contract"], - selectedRecipe: null, - anchor, - mcpCallStatus: "skipped", - rowsFetched: 0, - rowsMatched: 0, - category: "missing_anchor", - reasonText: "для open_items нужен якорь контрагента или договора", - nextStep: "укажите контрагента или номер/название договора", - limitations: ["open_items_requires_counterparty_or_contract_filter"], - reasons: baseReasons - }); - } if (recipeSelection.selected_recipe === null) { return buildLimitedExecutionResult({ mode, @@ -1334,11 +1405,40 @@ class AddressQueryService { } } } - const plan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters); - const mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ + let plan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters); + let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ query: plan.query, limit: plan.limit }); + if (mcp.error && + recipeSelection.selected_recipe.recipe_id === "address_movements_receivables_v1" && + isMissingSubcontoFieldError(mcp.error)) { + const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", filters.extracted_filters); + if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) { + const fallbackPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, filters.extracted_filters); + const fallbackMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ + query: fallbackPlan.query, + limit: fallbackPlan.limit + }); + if (!fallbackMcp.error) { + plan = fallbackPlan; + mcp = fallbackMcp; + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items"); + } + } + else { + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed"); + } + } + } + else { + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable"); + } + } + } if (mcp.error) { const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); return buildLimitedExecutionResult({ @@ -1395,7 +1495,17 @@ class AddressQueryService { }); const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching); const filterByAnchors = anchorFilter.rows; - const filteredRows = applyIntentSpecificFilter(intent.intent, filterByAnchors); + const filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors); + const filteredRowsFutureGuard = applyFutureDatedRowsGuard(filteredRowsBeforeFutureGuard, intent.intent, futureGuardReferenceDate); + const filteredRows = filteredRowsFutureGuard.rows; + if (filteredRowsFutureGuard.droppedCount > 0) { + if (!filters.warnings.includes("future_rows_excluded_from_response")) { + filters.warnings.push("future_rows_excluded_from_response"); + } + if (!baseReasons.includes("future_rows_excluded_from_response")) { + baseReasons.push("future_rows_excluded_from_response"); + } + } const rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length); const stageStatus = deriveMcpStageStatus({ rawRowsReceived: mcp.raw_rows.length, @@ -1474,7 +1584,9 @@ class AddressQueryService { } if (filteredRows.length === 0 && isAnchorRecoveryIntent(intent.intent) && - (stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")) { + (stageStatus === "materialized_but_not_anchor_matched" || + stageStatus === "materialized_but_filtered_out_by_recipe" || + stageStatus === "raw_rows_received_but_not_materialized")) { const currentLimit = typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit) ? Math.max(1, Math.trunc(filters.extracted_filters.limit)) : plan.limit; @@ -1515,7 +1627,17 @@ class AddressQueryService { }); const expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching); const expandedRowsByAnchor = expandedAnchorFilter.rows; - const expandedFilteredRows = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor); + const expandedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor); + const expandedFutureGuard = applyFutureDatedRowsGuard(expandedFilteredRowsBeforeFutureGuard, intent.intent, resolveFutureGuardReferenceDate(analysisDate, expandedLimitFilters)); + const expandedFilteredRows = expandedFutureGuard.rows; + if (expandedFutureGuard.droppedCount > 0) { + if (!filters.warnings.includes("future_rows_excluded_from_response")) { + filters.warnings.push("future_rows_excluded_from_response"); + } + if (!baseReasons.includes("future_rows_excluded_from_response")) { + baseReasons.push("future_rows_excluded_from_response"); + } + } if (expandedFilteredRows.length > 0) { const expandedRowDiagnostics = deriveRowStageDiagnostics(expandedMcp.raw_rows, expandedNormalizedRows.length, expandedNormalizedRows.length); const expandedStageStatus = deriveMcpStageStatus({ @@ -1615,7 +1737,17 @@ class AddressQueryService { }); const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching); const broadenedRowsByAnchor = broadenedAnchorFilter.rows; - const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor); + const broadenedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor); + const broadenedFutureGuard = applyFutureDatedRowsGuard(broadenedFilteredRowsBeforeFutureGuard, intent.intent, resolveFutureGuardReferenceDate(analysisDate, autoBroadenedFilters)); + const broadenedFilteredRows = broadenedFutureGuard.rows; + if (broadenedFutureGuard.droppedCount > 0) { + if (!filters.warnings.includes("future_rows_excluded_from_response")) { + filters.warnings.push("future_rows_excluded_from_response"); + } + if (!baseReasons.includes("future_rows_excluded_from_response")) { + baseReasons.push("future_rows_excluded_from_response"); + } + } if (broadenedFilteredRows.length > 0) { const broadenedRowDiagnostics = deriveRowStageDiagnostics(broadenedMcp.raw_rows, broadenedNormalizedRows.length, broadenedNormalizedRows.length); const broadenedStageStatus = deriveMcpStageStatus({ @@ -1722,7 +1854,17 @@ class AddressQueryService { }); const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching); const historicalRowsByAnchor = historicalAnchorFilter.rows; - const historicalFilteredRows = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor); + const historicalFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor); + const historicalFutureGuard = applyFutureDatedRowsGuard(historicalFilteredRowsBeforeFutureGuard, intent.intent, resolveFutureGuardReferenceDate(analysisDate, historicalFilters)); + const historicalFilteredRows = historicalFutureGuard.rows; + if (historicalFutureGuard.droppedCount > 0) { + if (!filters.warnings.includes("future_rows_excluded_from_response")) { + filters.warnings.push("future_rows_excluded_from_response"); + } + if (!baseReasons.includes("future_rows_excluded_from_response")) { + baseReasons.push("future_rows_excluded_from_response"); + } + } if (historicalFilteredRows.length > 0) { const historicalRowDiagnostics = deriveRowStageDiagnostics(historicalMcp.raw_rows, historicalNormalizedRows.length, historicalNormalizedRows.length); const historicalStageStatus = deriveMcpStageStatus({ diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 290ef7e..f1e6643 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -218,6 +218,20 @@ __WHERE_OUT__ Регистратор `; const COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE = ` +ВЫБРАТЬ + НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД) КАК Период, + "CP_CUSTOMER_ACTIVITY_YEAR" КАК Регистратор, + "" КАК СчетДт, + "" КАК СчетКт, + КОЛИЧЕСТВО(*) КАК Сумма, + ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент +ИЗ + Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление +__WHERE_IN__ +СГРУППИРОВАТЬ ПО + БанкПоступление.Контрагент, + НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД) +ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ МАКСИМУМ(БанкПоступление.Дата) КАК Период, "CP_CUSTOMER_ACTIVITY" КАК Регистратор, @@ -231,6 +245,7 @@ __WHERE_IN__ СГРУППИРОВАТЬ ПО БанкПоступление.Контрагент УПОРЯДОЧИТЬ ПО + Регистратор, Сумма УБЫВ, Период УБЫВ `; @@ -502,7 +517,7 @@ const BASE_RECIPES = [ optional_filters: ["as_of_date", "counterparty", "contract", "limit"], default_limit: 64, account_scope: ["60", "76"], - account_scope_mode: "preferred" + account_scope_mode: "strict" }, { recipe_id: "address_movements_receivables_v1", @@ -512,7 +527,7 @@ const BASE_RECIPES = [ optional_filters: ["as_of_date", "counterparty", "contract", "limit"], default_limit: 64, account_scope: ["62", "76"], - account_scope_mode: "preferred" + account_scope_mode: "strict" }, { recipe_id: "address_open_contracts_candidates_v1", @@ -522,7 +537,7 @@ const BASE_RECIPES = [ optional_filters: ["as_of_date", "organization", "limit"], default_limit: 128, account_scope: ["60", "62", "76"], - account_scope_mode: "preferred" + account_scope_mode: "strict" }, { recipe_id: "address_open_items_by_party_or_contract_v1", diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 03c4f8f..57ee5e3 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -156,6 +156,86 @@ function normalizeQuestionText(value) { .replace(/\s+/g, " ") .trim(); } +function normalizeIsoDateOnly(value) { + const parsed = parseIsoDateToken(value); + if (!parsed) { + return null; + } + return toIsoDate(parsed.year, parsed.month, parsed.day); +} +function toUtcDayTimestamp(isoDate) { + const parsed = parseIsoDateToken(isoDate); + if (!parsed) { + return null; + } + return Date.UTC(parsed.year, parsed.month - 1, parsed.day); +} +function resolveReceivablesAsOfDate(options) { + const explicit = normalizeIsoDateOnly(options.asOfDate); + if (explicit) { + return explicit; + } + const periodTo = normalizeIsoDateOnly(options.periodTo); + if (periodTo) { + return periodTo; + } + const now = new Date(); + return toIsoDate(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate()); +} +function hasReceivablesDebtAgingFocus(userMessage) { + const text = normalizeQuestionText(userMessage); + if (!text) { + return false; + } + const hasDebtSignal = /(?:долг(?:и|ов|а|у)?|задолж|дебитор|не плат|неоплач|просроч|хвост)/iu.test(text); + const hasLongevitySignal = /(?:долгожив|долгожител|дольше|длительн|несколько\s+месяц|возраст|по\s+времен|с\s+момент|на\s+этот\s+момент)/iu.test(text); + const hasCounterpartySignal = /(?:заказчик|клиент|покупател|контрагент|должник|counterpart|customer|client|buyer)/iu.test(text); + return hasDebtSignal && hasLongevitySignal && hasCounterpartySignal; +} +function formatAgeYearsMonthsDays(daysRaw) { + const safeDays = Math.max(0, Math.floor(daysRaw)); + const years = Math.floor(safeDays / 365); + const remAfterYears = safeDays % 365; + const months = Math.floor(remAfterYears / 30); + const days = remAfterYears % 30; + const parts = []; + if (years > 0) { + parts.push(`${years} г.`); + } + if (months > 0) { + parts.push(`${months} мес.`); + } + if (parts.length === 0 || days > 0) { + parts.push(`${days} дн.`); + } + return parts.join(" "); +} +function extractContractDateFromToken(token) { + const source = String(token ?? "").trim(); + if (!source) { + return null; + } + const lower = source.toLowerCase(); + if (!/(?:договор|contract|дог\.)/iu.test(lower)) { + return null; + } + const isoMatch = source.match(/\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/); + if (isoMatch) { + const isoCandidate = isoMatch[0]; + return normalizeIsoDateOnly(isoCandidate); + } + const ruMatch = source.match(/\b(0[1-9]|[12]\d|3[01])[./-](0[1-9]|1[0-2])[./-]((?:19|20)\d{2})\b/); + if (!ruMatch) { + return null; + } + const day = Number(ruMatch[1]); + const month = Number(ruMatch[2]); + const year = Number(ruMatch[3]); + if (!Number.isFinite(day) || !Number.isFinite(month) || !Number.isFinite(year)) { + return null; + } + return toIsoDate(year, month, day); +} function needsVatWhyExplanation(userMessage) { const text = normalizeQuestionText(userMessage); if (!text) { @@ -270,6 +350,16 @@ function detectCounterpartyLifecycleFocus(userMessage) { } return "active_customers_period"; } +function hasCounterpartyLifecycleLongevityQuestion(userMessage) { + const text = normalizeQuestionText(userMessage); + if (!text) { + return false; + } + const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test(text); + const hasLongevityCue = /(?:долгожив|долгожител|дольше(?:\s+всех)?|сам(?:ые|ый)\s+стар(?:ые|ый)|лет\s+в\s+базе|лет\s+с\s+нами|longest|oldest)/iu.test(text); + const hasImplicitCounterpartyQuestion = /(?:кто\s+с\s+нами|кто\s+у\s+нас)/iu.test(text); + return (hasCounterpartyLexeme || hasImplicitCounterpartyQuestion) && hasLongevityCue; +} function detectMinOpsForAvgCheck(userMessage) { const text = normalizeQuestionText(userMessage); if (!text) { @@ -345,6 +435,7 @@ function extractRequestedYearFromQuestion(userMessage) { return 2000 + shortYear; } function extractCounterpartyName(row) { + const skipTokenPattern = /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu; for (const token of row.analytics) { const normalized = String(token ?? "").trim(); if (!normalized) { @@ -353,10 +444,178 @@ function extractCounterpartyName(row) { if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { continue; } + if (/^\d+(?:[./-]\d+)*$/.test(normalized)) { + continue; + } + if (!/[a-zа-я]/iu.test(normalized)) { + continue; + } + if (skipTokenPattern.test(normalized)) { + continue; + } + return normalized; + } + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (!normalized) { + continue; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { + continue; + } + if (normalized.length < 3) { + continue; + } return normalized; } return null; } +function buildCounterpartyRiskAggregate(rows) { + const byCounterparty = new Map(); + for (const row of rows) { + const name = extractCounterpartyName(row); + if (!name) { + continue; + } + const amountRaw = row.amount ?? 0; + if (!Number.isFinite(amountRaw)) { + continue; + } + const amount = Math.abs(amountRaw); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + name, + totalAmount: amount, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period + }); + continue; + } + current.totalAmount += amount; + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + } + return Array.from(byCounterparty.values()).sort((left, right) => { + if (right.totalAmount !== left.totalAmount) { + return right.totalAmount - left.totalAmount; + } + if (right.operations !== left.operations) { + return right.operations - left.operations; + } + return left.name.localeCompare(right.name); + }); +} +function pickContractStartDateFromRow(row) { + for (const token of row.analytics) { + const detected = extractContractDateFromToken(token); + if (detected) { + return detected; + } + } + const byRegistrator = extractContractDateFromToken(row.registrator); + if (byRegistrator) { + return byRegistrator; + } + return null; +} +function minIsoDate(left, right) { + if (!left) { + return right; + } + if (!right) { + return left; + } + return right < left ? right : left; +} +function maxIsoDate(left, right) { + if (!left) { + return right; + } + if (!right) { + return left; + } + return right > left ? right : left; +} +function buildCounterpartyDebtAgingAggregate(rows, asOfDate) { + const byCounterparty = new Map(); + for (const row of rows) { + const name = extractCounterpartyName(row); + if (!name) { + continue; + } + const amountRaw = row.amount ?? 0; + if (!Number.isFinite(amountRaw)) { + continue; + } + const amount = Math.abs(amountRaw); + const rowIso = normalizeIsoDateOnly(row.period); + const contractDate = pickContractStartDateFromRow(row); + const contractName = extractContractName(row); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + base: { + name, + totalAmount: amount, + operations: 1, + firstPeriod: rowIso, + lastPeriod: rowIso + }, + minContractDate: contractDate, + contracts: new Set(contractName ? [contractName] : []) + }); + continue; + } + current.base.totalAmount += amount; + current.base.operations += 1; + current.base.firstPeriod = minIsoDate(current.base.firstPeriod, rowIso); + current.base.lastPeriod = maxIsoDate(current.base.lastPeriod, rowIso); + current.minContractDate = minIsoDate(current.minContractDate, contractDate); + if (contractName) { + current.contracts.add(contractName); + } + } + const asOfTs = toUtcDayTimestamp(asOfDate); + const finalized = Array.from(byCounterparty.values()).map((entry) => { + const debtAgeStartDate = entry.minContractDate ?? entry.base.firstPeriod ?? null; + const startTs = toUtcDayTimestamp(debtAgeStartDate); + const debtAgeSource = entry.minContractDate + ? "contract_date" + : "first_movement"; + const debtAgeDays = asOfTs !== null && startTs !== null && asOfTs >= startTs + ? Math.floor((asOfTs - startTs) / (24 * 60 * 60 * 1000)) + : null; + return { + ...entry.base, + debtAgeStartDate, + debtAgeDays, + debtAgeSource, + contractDateDetected: Boolean(entry.minContractDate), + contracts: Array.from(entry.contracts.values()).slice(0, 3) + }; + }); + return finalized.sort((left, right) => { + const leftAge = left.debtAgeDays ?? -1; + const rightAge = right.debtAgeDays ?? -1; + if (rightAge !== leftAge) { + return rightAge - leftAge; + } + if (right.totalAmount !== left.totalAmount) { + return right.totalAmount - left.totalAmount; + } + if (right.operations !== left.operations) { + return right.operations - left.operations; + } + return left.name.localeCompare(right.name); + }); +} function extractContractName(row) { for (const token of row.analytics) { const normalized = String(token ?? "").trim(); @@ -744,7 +1003,37 @@ function composeFactualReply(intent, rows, options = {}) { } if (intent === "counterparty_activity_lifecycle") { const activityRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY"); + const activityYearRows = rows.filter((row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY_YEAR"); const byCounterparty = new Map(); + for (const row of activityYearRows) { + const name = extractCounterpartyName(row); + if (!name) { + continue; + } + const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); + const year = extractYearFromIso(row.period); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + years: new Set(year !== null ? [year] : []) + }); + continue; + } + current.opsCount += opsCount; + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if (year !== null) { + current.years.add(year); + } + } for (const row of activityRows) { const name = extractCounterpartyName(row); if (!name) { @@ -753,48 +1042,90 @@ function composeFactualReply(intent, rows, options = {}) { const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); const current = byCounterparty.get(name); if (!current) { - byCounterparty.set(name, { name, opsCount, lastPeriod: row.period }); + const year = extractYearFromIso(row.period); + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + years: new Set(year !== null ? [year] : []) + }); continue; } - if (opsCount > current.opsCount) { + if (activityYearRows.length === 0 && opsCount > current.opsCount) { current.opsCount = opsCount; } if ((row.period ?? "") > (current.lastPeriod ?? "")) { current.lastPeriod = row.period; } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + const year = extractYearFromIso(row.period); + if (year !== null) { + current.years.add(year); + } } - const counterparties = Array.from(byCounterparty.values()).sort((left, right) => { + const counterpartiesRaw = Array.from(byCounterparty.values()); + const focus = detectCounterpartyLifecycleFocus(options.userMessage); + const requestedYear = extractRequestedYearFromQuestion(options.userMessage); + const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage); + const rankingLimit = detectRankingLimit(options.userMessage, 10); + const counterparties = counterpartiesRaw.sort((left, right) => { + if (longevityQuestion) { + const yearsDiff = right.years.size - left.years.size; + if (yearsDiff !== 0) { + return yearsDiff; + } + } if (right.opsCount !== left.opsCount) { return right.opsCount - left.opsCount; } return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? ""); }); - const focus = detectCounterpartyLifecycleFocus(options.userMessage); - const requestedYear = extractRequestedYearFromQuestion(options.userMessage); const scopeLabel = focus === "active_customers_all_time" ? "за все время" : requestedYear ? `в ${requestedYear} году` : "в выбранном периоде"; - const lines = [ - `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, - "Собран профиль активности заказчиков (bank-doc activity aggregate).", - `Строк агрегата: ${rows.length}.` - ]; + const lines = longevityQuestion + ? [ + `Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`, + "Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.", + `Строк агрегата: ${rows.length}.` + ] + : [ + `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, + "Собран профиль активности заказчиков (bank-doc activity aggregate).", + `Строк агрегата: ${rows.length}.` + ]; if (counterparties.length === 0) { - lines.push("По выбранному окну активности заказчики не найдены."); + lines.push(longevityQuestion + ? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам." + : "По выбранному окну активности заказчики не найдены."); return { responseType: "FACTUAL_SUMMARY", text: lines.join("\n") }; } - const visible = counterparties.slice(0, 120); + const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120); + if (longevityQuestion) { + lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`); + } lines.push(...visible.map((item, index) => { + const years = Array.from(item.years).sort((a, b) => a - b); + const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : ""; + const periodSpan = item.firstPeriod && item.lastPeriod ? ` | период: ${item.firstPeriod}..${item.lastPeriod}` : ""; + if (longevityQuestion) { + return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`; + } const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : ""; - return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}`; + return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`; })); if (counterparties.length > visible.length) { - lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`); + lines.push(longevityQuestion + ? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).` + : `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`); } return { responseType: "FACTUAL_LIST", @@ -1171,6 +1502,7 @@ function composeFactualReply(intent, rows, options = {}) { } if (intent === "list_open_contracts") { const contracts = contractCandidatesFromRows(rows); + const counterparties = buildCounterpartyRiskAggregate(rows); const lines = [ "Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).", `Строк движения: ${rows.length}.`, @@ -1179,6 +1511,13 @@ function composeFactualReply(intent, rows, options = {}) { if (contracts.length > 0) { lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)); } + else if (counterparties.length > 0) { + lines.push(`Контрагентов с сигналом незакрытых хвостов: ${counterparties.length}.`); + lines.push(...counterparties + .slice(0, 8) + .map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`)); + lines.push("Договорные якоря в этом live-срезе не выделены, поэтому показан контрагентный рейтинг риска."); + } else { lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback."); lines.push(...formatTopRows(rows, 6)); @@ -1189,39 +1528,102 @@ function composeFactualReply(intent, rows, options = {}) { }; } if (intent === "list_payables_counterparties") { + const counterparties = buildCounterpartyRiskAggregate(rows); const lines = [ "Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).", `Строк в выборке: ${rows.length}.`, - ...(rows.length > 0 - ? ["Ниже примеры строк для ручной проверки."] - : ["Явных признаков системной задолженности по доступному срезу не найдено."]), - ...formatTopRows(rows, 6) + `Контрагентов с сигналом: ${counterparties.length}.` ]; + if (counterparties.length > 0) { + lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):"); + lines.push(...counterparties + .slice(0, 8) + .map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`)); + lines.push("Примеры исходных строк:"); + lines.push(...formatTopRows(rows, 4)); + } + else { + lines.push("Явных признаков системной задолженности по доступному срезу не найдено."); + lines.push(...formatTopRows(rows, 6)); + } return { responseType: "FACTUAL_LIST", text: lines.join("\n") }; } if (intent === "list_receivables_counterparties") { + const counterparties = buildCounterpartyRiskAggregate(rows); + const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage); + if (debtAgingFocus) { + const asOfDate = resolveReceivablesAsOfDate(options); + const aging = buildCounterpartyDebtAgingAggregate(rows, asOfDate); + const detectedContractDates = aging.filter((item) => item.contractDateDetected).length; + const lines = [ + "Проверил должников по сроку жизни задолженности (контур 62/76).", + `Дата среза: ${formatDateRu(asOfDate)}.`, + `Строк в выборке: ${rows.length}.`, + `Контрагентов с сигналом: ${aging.length}.` + ]; + if (aging.length > 0) { + lines.push("Приоритет ручной проверки (по возрасту долга, по убыванию):"); + lines.push(...aging.slice(0, 10).map((item, index) => { + const ageLabel = item.debtAgeDays !== null ? formatAgeYearsMonthsDays(item.debtAgeDays) : "н/д"; + const startDateLabel = item.debtAgeStartDate ? formatDateRu(item.debtAgeStartDate) : "не определена"; + const startSourceLabel = item.debtAgeSource === "contract_date" ? "дата договора" : "первое движение по договору/контрагенту"; + const contractsLabel = item.contracts.length > 0 ? item.contracts.join("; ") : "договор не выделен в срезе"; + return `${index + 1}. ${item.name} | договоры: ${contractsLabel} | возраст долга: ${ageLabel} | старт: ${startDateLabel} (${startSourceLabel}) | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}`; + })); + lines.push(detectedContractDates > 0 + ? `Дата договора выделена для ${detectedContractDates} из ${aging.length} контрагентов; для остальных использован старт первого движения.` + : "Явная дата договора в live-строках не выделена, возраст рассчитан от первого движения."); + } + else { + lines.push("Явных признаков затяжной дебиторки по доступному срезу не найдено."); + } + return { + responseType: "FACTUAL_LIST", + text: lines.join("\n") + }; + } const lines = [ "Проверил покупателей с признаками затянутой оплаты (контур 62/76).", `Строк в выборке: ${rows.length}.`, - ...(rows.length > 0 - ? ["Ниже примеры строк, которые стоит проверить в первую очередь."] - : ["Явных признаков затяжной дебиторки по доступному срезу не найдено."]), - ...formatTopRows(rows, 6) + `Контрагентов с сигналом: ${counterparties.length}.` ]; + if (counterparties.length > 0) { + lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):"); + lines.push(...counterparties + .slice(0, 8) + .map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`)); + lines.push("Примеры исходных строк:"); + lines.push(...formatTopRows(rows, 4)); + } + else { + lines.push("Явных признаков затяжной дебиторки по доступному срезу не найдено."); + lines.push(...formatTopRows(rows, 6)); + } return { responseType: "FACTUAL_LIST", text: lines.join("\n") }; } if (intent === "open_items_by_counterparty_or_contract") { + const counterparties = buildCounterpartyRiskAggregate(rows); const lines = [ - "Собраны открытые позиции по указанному фильтру (контрагент/договор).", + "Собраны открытые позиции по взаиморасчетам.", `Строк отобрано: ${rows.length}.`, - ...formatTopRows(rows, 6) + `Контрагентов с сигналом: ${counterparties.length}.` ]; + if (counterparties.length > 0) { + lines.push(...counterparties + .slice(0, 8) + .map((item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`)); + lines.push("Примеры исходных строк:"); + lines.push(...formatTopRows(rows, 4)); + } + else { + lines.push(...formatTopRows(rows, 6)); + } return { responseType: "FACTUAL_LIST", text: lines.join("\n") diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 9d45e89..cd6d3b4 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -3150,7 +3150,11 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll hasDeepAnalysisPreferenceSignal(rawMessageForGate) || hasDeepAnalysisPreferenceSignal(repairedInputMessage); const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage); - const hasClassifierSignal = modeDetection.mode === "address_query"; + const modeDetectionRaw = (0, addressQueryClassifier_1.detectAddressQuestionMode)(String(addressInputMessage ?? "")); + const hasClassifierSignal = modeDetection.mode === "address_query" || modeDetectionRaw.mode === "address_query"; + const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(repairedInputMessage || addressInputMessage); + const intentResolutionRaw = (0, addressIntentResolver_1.resolveAddressIntent)(String(addressInputMessage ?? "")); + const hasIntentSignal = intentResolution.intent !== "unknown" || intentResolutionRaw.intent !== "unknown"; const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence); const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); @@ -3181,7 +3185,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && llmContractIntent === "unknown"; - const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; + const hasAnyAddressSignal = hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) || hasDataRetrievalRequestSignal(rawMessageForGate) || hasAccountingSignal(rawMessageForGate) || @@ -3217,11 +3221,13 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll decision: "run_address_lane", reason: hasClassifierSignal ? "address_mode_classifier_detected" - : hasLlmCanonicalSignal - ? "llm_canonical_candidate_detected" - : hasLlmCanonicalDataSignal - ? "llm_canonical_data_signal_detected" - : "address_signal_detected" + : hasIntentSignal + ? "address_intent_resolver_detected" + : hasLlmCanonicalSignal + ? "llm_canonical_candidate_detected" + : hasLlmCanonicalDataSignal + ? "llm_canonical_data_signal_detected" + : "address_signal_detected" }; } if (followupContext) { @@ -3705,8 +3711,8 @@ function hasDataRetrievalRequestSignal(text) { if (hasBroadInterrogative && hasBroadBusinessObject) { return true; } - const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a)(?:$|[\s,.!?;:])/iu.test(lower); - const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446)/iu.test(lower); + const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a|\u043f\u0440\u043e\u0432\u0435\u0440\u044c|\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c)(?:$|[\s,.!?;:])/iu.test(lower); + const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433)/iu.test(lower); if (hasRussianRetrievalAction && hasRussianRetrievalObject) { return true; } diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index cac079e..798d306 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -270,6 +270,7 @@ const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [ "кто ушел", "кто ушёл", "только один раз", + "дольше всего", "дольше всех", "долгоживущие контрагенты", "регулярные поставщики", @@ -661,14 +662,27 @@ function hasCounterpartyPopulationAndRolesSignal(text: string): boolean { } function hasLifecycleSegmentationSignal(text: string): boolean { - return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test( + return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|дольше\s+всего|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test( text ); } +function hasCounterpartyDebtLongevitySignal(text: string): boolean { + const hasCounterpartyLexeme = + /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test( + text + ); + const hasDebtLexeme = /(?:долг(?:и|ов|а|у)?|задолж(?:енность|енности|енностям|ал|али)?|просроч|хвост)/iu.test(text); + const hasLongevityCue = + /(?:долгожив|долгожител|несколько\s+месяц|по\s+годам|дольше|лет|год(?:ам|а|у|ы)?|на\s+этот\s+момент|длительн)/iu.test( + text + ); + return hasCounterpartyLexeme && hasDebtLexeme && hasLongevityCue; +} + function hasCounterpartyActivityLifecycleSignal(text: string): boolean { const hasPaymentRiskLexeme = - /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|долг|задолж)/iu.test( + /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|задерж(?:ива|к)|просроч|задолж|\bдолг(?:и|ов|а|у)?\b)/iu.test( text ); if (hasPaymentRiskLexeme) { @@ -680,14 +694,14 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean { if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) { return true; } - if (/(?:сколько|скока|скок)\s+/iu.test(text)) { + if (/(?:сколько|скока|скок)\s+/iu.test(text) && !hasLifecycleSegmentationSignal(text)) { return false; } const hasCounterpartyLexeme = /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|поставщик(?:ов|а|и)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|supplier(?:s)?|vendor(?:s)?)/iu.test( text ); const hasActivityLexeme = - /(?:работал(?:и)?|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test( + /(?:работал(?:и)?|работа(?:ет|ют)|активн(?:ые|ых|а|о)?|сотрудничал(?:и)?|были\s+в\s+работе|active|использ(?:овал(?:и|ось)?|уются|ован(?:ы|о)?))/iu.test( text ); const hasTimeWindowLexeme = @@ -936,9 +950,9 @@ function hasOpenContractsListSignal(text: string): boolean { function hasSupplierTailRiskSignal(text: string): boolean { const hasSupplier = /(?:поставщик|supplier|vendor)/iu.test(text); - const hasTail = /(?:хвост|висят|незакрыт|задолж|долг|просроч)/iu.test(text); + const hasTail = /(?:хвост|висят|незакрыт|не\s+закрыв|задолж|долг|просроч|сч[её]т)/iu.test(text); const hasRisk = /(?:систематич|регулярн|проблем|тревог|не\s+разов|больше\s+похож)/iu.test(text); - const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц)/iu.test(text); + const hasPeriodCue = /(?:на\s+конец\s+(?:месяц|период)|конец\s+месяц|пару\s+месяц|несколько\s+месяц|больше\s+месяц)/iu.test(text); return hasSupplier && hasTail && (hasRisk || hasPeriodCue); } @@ -948,11 +962,30 @@ function hasReceivablesLatencyRiskSignal(text: string): boolean { const hasPayment = /(?:оплат|платеж|платёж|payment)/iu.test(text); const hasShipment = /(?:отправк|отгруз|реализ|shipment|delivery)/iu.test(text); const hasDelay = /(?:длинн|долг|просроч|задерж|висят|тревог|too\s+long|late)/iu.test(text); - const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); - const hasPeriodOrRiskCue = /(?:за\s+текущ|на\s+конец|тревог|просроч|задерж|долг|длинн)/iu.test(text); + const hasOverdueDeadlineCue = + /(?:срок(?:и|а)?(?:\s+оплат[ыы]?)?[\s\S]{0,24}(?:прош|выш|истек|истёк)|срок(?:и|а)?\s+давно\s+прошл|давно\s+пора\s+оплат|давно\s+не\s+оплач)/iu.test( + text + ); + const hasNonPayment = /(?:не\s+плат(?:ит|ят|ил|или)|не\s+оплат|не\s+оплач|без\s+оплат|оплат(?:ы|а)?\s+нет|нет\s+оплат|неоплач)/iu.test(text); + const hasPaymentShipmentImbalance = + /(?:оплач(?:ено|ен[аоы]?|ивать|ивать)?\s+меньше[\s\S]{0,36}отгруж|недоплат[\s\S]{0,36}отгруж|отгруж[\s\S]{0,36}оплач(?:ено|ено\s+меньше))/iu.test( + text + ); + const hasNegativeSaldoRisk = /(?:сальд[оа]\s+(?:уже\s+)?отрицат|минусов(?:ое|ой)\s+сальдо|сальдо\s+в\s+минус)/iu.test(text); + const hasPeriodOrRiskCue = + /(?:за\s+текущ|на\s+конец|тревог|просроч|задерж|долг|длинн|несколько\s+месяц|больше\s+месяц)/iu.test(text) || + hasOverdueDeadlineCue || + hasNegativeSaldoRisk; const hasBetweenShipmentAndPayment = /между[\s\S]{0,80}(?:отправк|отгруз|реализ)[\s\S]{0,80}(?:оплат|платеж|платёж|payment)/iu.test(text); - if (hasBuyer && hasPayment && ((hasShipment && hasDelay) || hasBetweenShipmentAndPayment)) { + if ( + hasBuyer && + hasPayment && + ((hasShipment && (hasDelay || hasOverdueDeadlineCue)) || hasBetweenShipmentAndPayment || hasPaymentShipmentImbalance) + ) { + return true; + } + if ((hasBuyer || hasCounterparty) && hasPaymentShipmentImbalance) { return true; } return (hasBuyer || hasCounterparty) && hasNonPayment && hasPeriodOrRiskCue; @@ -961,24 +994,50 @@ function hasReceivablesLatencyRiskSignal(text: string): boolean { function hasSettlementGapSignal(text: string): boolean { const hasPayment = /(?:платеж|платёж|оплат|списани|поступлен|payment)/iu.test(text); const hasDocument = /(?:док(?:и|умент|ументы|ументов)|docs?|documents?)/iu.test(text); + const hasShipment = /(?:отгруз|реализ|shipment|delivery|товар|услуг)/iu.test(text); const hasAdvance = /(?:аванс|предоплат)/iu.test(text); + const hasClosureLexeme = /(?:закрыти|взаиморасч|акт|сч[её]т(?:ов|а|ы)?)/iu.test(text); const hasNoDocumentForClosing = /(?:нет|без)\s+(?:док(?:и|умент|ументы|ументов)|закрывающ)/iu.test(text) && - /(?:закрыти|взаиморасч|акт)/iu.test(text); + hasClosureLexeme; const hasNoDocumentForClosingReversed = /(?:док(?:и|умент|ументы|ументов)|закрывающ)[\s\S]{0,48}(?:нет|без)/iu.test(text) && - /(?:закрыти|взаиморасч|акт)/iu.test(text); + hasClosureLexeme; const hasNoPayments = /(?:нет|без)\s+(?:оплат|платеж|платёж|payment)/iu.test(text) || /(?:оплат|платеж|платёж|payment)\s+нет/iu.test(text); const hasDocsWithoutPayments = hasDocument && hasNoPayments; const hasPaymentsWithoutClosingDocs = hasPayment && (hasNoDocumentForClosing || hasNoDocumentForClosingReversed); + const hasPaymentsWithoutSettlementClosure = + hasPayment && + /(?:без|нет)\s+закрыти(?:я|й)?(?:\s+взаиморасч[её]тов)?/iu.test(text) && + hasClosureLexeme; + const hasShipmentWithoutClosingDocs = + hasShipment && + (hasNoDocumentForClosing || + hasNoDocumentForClosingReversed || + /(?:без|нет)\s+док(?:и|умент(?:ов|ы|а)?)\s+(?:для\s+)?(?:их\s+)?закрыти/u.test(text)); + const hasClosingWithoutSupportingDocs = + hasClosureLexeme && + /(?:без|нет)\s+подтверждающ(?:их|его|ие)?\s+док(?:и|умент(?:ов|ы|а)?)/iu.test(text); + const hasAdvanceStuckRisk = + /(?:зависш(?:ий|ие|ая|ие\s+аванс)|давно\s+пора\s+закрыть|пора\s+закрывать|перепривяз(?:ать|к)|списыв(?:ать|ани|ан)|нереальн)/iu.test( + text + ); const hasUnclosedAdvanceGap = hasAdvance && - (/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт)/iu.test(text) || + (/(?:не\s+закрыт|незакрыт|долго\s+не\s+закрыт|давно\s+не\s+закрыт|давно\s+пора\s+закрыть)/iu.test(text) || + hasAdvanceStuckRisk || hasNoDocumentForClosing || hasNoDocumentForClosingReversed); - return hasPaymentsWithoutClosingDocs || hasDocsWithoutPayments || hasUnclosedAdvanceGap; + return ( + hasPaymentsWithoutClosingDocs || + hasPaymentsWithoutSettlementClosure || + hasDocsWithoutPayments || + hasShipmentWithoutClosingDocs || + hasClosingWithoutSupportingDocs || + hasUnclosedAdvanceGap + ); } function hasReconciliationMismatchSignal(text: string): boolean { @@ -1376,6 +1435,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti }; } + if (hasCounterpartyDebtLongevitySignal(text)) { + return { + intent: "list_receivables_counterparties", + confidence: "medium", + reasons: ["receivables_debt_lifecycle_signal_detected"] + }; + } + if (hasSupplierTailRiskSignal(text)) { return { intent: "list_payables_counterparties", @@ -1410,6 +1477,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti if ( hasAny(text, OPEN_ITEMS_HINTS) && + !hasCounterpartyDebtLongevitySignal(text) && /(?:контраг|договор|контракт|counterparty|contract|покупател|клиент|заказчик|customer|client|buyer|supplier|поставщик)/iu.test( text ) diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 77ce6f5..e1bc3d1 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -715,6 +715,103 @@ function applyIntentSpecificFilter(intent: AddressIntent, rows: NormalizedAddres return rows; } +function parseIsoDateUtcTimestamp(value: string | null | undefined): number | null { + const source = String(value ?? "").trim(); + const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + if (month < 1 || month > 12 || day < 1 || day > 31) { + return null; + } + return Date.UTC(year, month - 1, day); +} + +function isCounterpartyRiskIntent(intent: AddressIntent): boolean { + return ( + intent === "list_receivables_counterparties" || + intent === "list_payables_counterparties" || + intent === "list_open_contracts" || + intent === "open_items_by_counterparty_or_contract" + ); +} + +function resolveFutureGuardReferenceDate(analysisDate: string | null, filters: AddressFilterSet): string | null { + if (analysisDate) { + return analysisDate; + } + const asOfDate = normalizeAnalysisDateHint(filters.as_of_date); + if (asOfDate) { + return asOfDate; + } + const periodTo = normalizeAnalysisDateHint(filters.period_to); + if (periodTo) { + return periodTo; + } + return null; +} + +function isMissingSubcontoFieldError(errorText: string | null | undefined): boolean { + const normalized = String(errorText ?? "") + .toLowerCase() + .replace(/\s+/g, " "); + if (!normalized) { + return false; + } + return ( + normalized.includes("поле не найдено") && + (normalized.includes("субконтодт1") || + normalized.includes("subcontodt1") || + normalized.includes("subconto_dt1")) + ); +} + +function applyFutureDatedRowsGuard( + rows: NormalizedAddressRow[], + intent: AddressIntent, + referenceDate: string | null +): { rows: NormalizedAddressRow[]; droppedCount: number } { + if (!isCounterpartyRiskIntent(intent) || rows.length === 0) { + return { + rows, + droppedCount: 0 + }; + } + + const referenceTs = (() => { + const explicitTs = parseIsoDateUtcTimestamp(referenceDate); + if (explicitTs !== null) { + return explicitTs; + } + const now = new Date(); + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + })(); + const guardTailMs = 31 * 24 * 60 * 60 * 1000; + const latestAllowedTs = referenceTs + guardTailMs; + + const keptRows: NormalizedAddressRow[] = []; + let droppedCount = 0; + for (const row of rows) { + const rowTs = parseIsoDateUtcTimestamp(row.period); + if (rowTs !== null && rowTs > latestAllowedTs) { + droppedCount += 1; + continue; + } + keptRows.push(row); + } + + return { + rows: keptRows, + droppedCount + }; +} + function hasExplicitPeriodWindow(filters: AddressFilterSet): boolean { return ( (typeof filters.period_from === "string" && filters.period_from.trim().length > 0) || @@ -745,6 +842,8 @@ function isAnchorRecoveryIntent(intent: AddressIntent): boolean { intent === "list_contracts_by_counterparty" || intent === "list_documents_by_contract" || intent === "bank_operations_by_contract" || + intent === "list_payables_counterparties" || + intent === "list_receivables_counterparties" || intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" ); @@ -1483,10 +1582,20 @@ export class AddressQueryService { const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({ userMessage, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, - periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined + periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, + asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined }); + const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, filters.extracted_filters); let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters); - const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters); + const debtLifecycleReceivablesScenario = + intent.intent === "list_receivables_counterparties" && + Array.isArray(intent.reasons) && + intent.reasons.includes("receivables_debt_lifecycle_signal_detected"); + const recipeIntent = debtLifecycleReceivablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent; + const recipeSelection = selectAddressRecipe(recipeIntent, filters.extracted_filters); + if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) { + baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle"); + } if (intent.intent === "unknown") { return buildLimitedExecutionResult({ @@ -1508,30 +1617,6 @@ export class AddressQueryService { }); } - if ( - intent.intent === "open_items_by_counterparty_or_contract" && - !filters.extracted_filters.counterparty && - !filters.extracted_filters.contract - ) { - return buildLimitedExecutionResult({ - mode, - shape, - intent, - filters: filters.extracted_filters, - missingRequiredFilters: ["counterparty_or_contract"], - selectedRecipe: null, - anchor, - mcpCallStatus: "skipped", - rowsFetched: 0, - rowsMatched: 0, - category: "missing_anchor", - reasonText: "для open_items нужен якорь контрагента или договора", - nextStep: "укажите контрагента или номер/название договора", - limitations: ["open_items_requires_counterparty_or_contract_filter"], - reasons: baseReasons - }); - } - if (recipeSelection.selected_recipe === null) { return buildLimitedExecutionResult({ mode, @@ -1631,11 +1716,40 @@ export class AddressQueryService { } } - const plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters); - const mcp = await executeAddressMcpQuery({ + let plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters); + let mcp = await executeAddressMcpQuery({ query: plan.query, limit: plan.limit }); + if ( + mcp.error && + recipeSelection.selected_recipe.recipe_id === "address_movements_receivables_v1" && + isMissingSubcontoFieldError(mcp.error) + ) { + const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", filters.extracted_filters); + if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) { + const fallbackPlan = buildAddressRecipePlan(fallbackSelection.selected_recipe, filters.extracted_filters); + const fallbackMcp = await executeAddressMcpQuery({ + query: fallbackPlan.query, + limit: fallbackPlan.limit + }); + if (!fallbackMcp.error) { + plan = fallbackPlan; + mcp = fallbackMcp; + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items"); + } + } else { + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed"); + } + } + } else { + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable"); + } + } + } if (mcp.error) { const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); @@ -1696,7 +1810,21 @@ export class AddressQueryService { }); const anchorFilter = applyAddressFilters(normalizedRows, filtersForMatching); const filterByAnchors = anchorFilter.rows; - const filteredRows = applyIntentSpecificFilter(intent.intent, filterByAnchors); + const filteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, filterByAnchors); + const filteredRowsFutureGuard = applyFutureDatedRowsGuard( + filteredRowsBeforeFutureGuard, + intent.intent, + futureGuardReferenceDate + ); + const filteredRows = filteredRowsFutureGuard.rows; + if (filteredRowsFutureGuard.droppedCount > 0) { + if (!filters.warnings.includes("future_rows_excluded_from_response")) { + filters.warnings.push("future_rows_excluded_from_response"); + } + if (!baseReasons.includes("future_rows_excluded_from_response")) { + baseReasons.push("future_rows_excluded_from_response"); + } + } const rowDiagnostics = deriveRowStageDiagnostics(mcp.raw_rows, normalizedRows.length, normalizedRows.length); const stageStatus = deriveMcpStageStatus({ rawRowsReceived: mcp.raw_rows.length, @@ -1782,7 +1910,9 @@ export class AddressQueryService { if ( filteredRows.length === 0 && isAnchorRecoveryIntent(intent.intent) && - (stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe") + (stageStatus === "materialized_but_not_anchor_matched" || + stageStatus === "materialized_but_filtered_out_by_recipe" || + stageStatus === "raw_rows_received_but_not_materialized") ) { const currentLimit = typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit) @@ -1827,7 +1957,21 @@ export class AddressQueryService { }); const expandedAnchorFilter = applyAddressFilters(expandedNormalizedRows, expandedFiltersForMatching); const expandedRowsByAnchor = expandedAnchorFilter.rows; - const expandedFilteredRows = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor); + const expandedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, expandedRowsByAnchor); + const expandedFutureGuard = applyFutureDatedRowsGuard( + expandedFilteredRowsBeforeFutureGuard, + intent.intent, + resolveFutureGuardReferenceDate(analysisDate, expandedLimitFilters) + ); + const expandedFilteredRows = expandedFutureGuard.rows; + if (expandedFutureGuard.droppedCount > 0) { + if (!filters.warnings.includes("future_rows_excluded_from_response")) { + filters.warnings.push("future_rows_excluded_from_response"); + } + if (!baseReasons.includes("future_rows_excluded_from_response")) { + baseReasons.push("future_rows_excluded_from_response"); + } + } if (expandedFilteredRows.length > 0) { const expandedRowDiagnostics = deriveRowStageDiagnostics( expandedMcp.raw_rows, @@ -1938,7 +2082,21 @@ export class AddressQueryService { }); const broadenedAnchorFilter = applyAddressFilters(broadenedNormalizedRows, broadenedFiltersForMatching); const broadenedRowsByAnchor = broadenedAnchorFilter.rows; - const broadenedFilteredRows = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor); + const broadenedFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, broadenedRowsByAnchor); + const broadenedFutureGuard = applyFutureDatedRowsGuard( + broadenedFilteredRowsBeforeFutureGuard, + intent.intent, + resolveFutureGuardReferenceDate(analysisDate, autoBroadenedFilters) + ); + const broadenedFilteredRows = broadenedFutureGuard.rows; + if (broadenedFutureGuard.droppedCount > 0) { + if (!filters.warnings.includes("future_rows_excluded_from_response")) { + filters.warnings.push("future_rows_excluded_from_response"); + } + if (!baseReasons.includes("future_rows_excluded_from_response")) { + baseReasons.push("future_rows_excluded_from_response"); + } + } if (broadenedFilteredRows.length > 0) { const broadenedRowDiagnostics = deriveRowStageDiagnostics( broadenedMcp.raw_rows, @@ -2059,7 +2217,21 @@ export class AddressQueryService { }); const historicalAnchorFilter = applyAddressFilters(historicalNormalizedRows, historicalFiltersForMatching); const historicalRowsByAnchor = historicalAnchorFilter.rows; - const historicalFilteredRows = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor); + const historicalFilteredRowsBeforeFutureGuard = applyIntentSpecificFilter(intent.intent, historicalRowsByAnchor); + const historicalFutureGuard = applyFutureDatedRowsGuard( + historicalFilteredRowsBeforeFutureGuard, + intent.intent, + resolveFutureGuardReferenceDate(analysisDate, historicalFilters) + ); + const historicalFilteredRows = historicalFutureGuard.rows; + if (historicalFutureGuard.droppedCount > 0) { + if (!filters.warnings.includes("future_rows_excluded_from_response")) { + filters.warnings.push("future_rows_excluded_from_response"); + } + if (!baseReasons.includes("future_rows_excluded_from_response")) { + baseReasons.push("future_rows_excluded_from_response"); + } + } if (historicalFilteredRows.length > 0) { const historicalRowDiagnostics = deriveRowStageDiagnostics( historicalMcp.raw_rows, diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 131472e..303b325 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -226,6 +226,20 @@ __WHERE_OUT__ `; const COUNTERPARTY_ACTIVITY_LIFECYCLE_QUERY_TEMPLATE = ` +ВЫБРАТЬ + НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД) КАК Период, + "CP_CUSTOMER_ACTIVITY_YEAR" КАК Регистратор, + "" КАК СчетДт, + "" КАК СчетКт, + КОЛИЧЕСТВО(*) КАК Сумма, + ПРЕДСТАВЛЕНИЕ(БанкПоступление.Контрагент) КАК Контрагент +ИЗ + Документ.ПоступлениеНаРасчетныйСчет КАК БанкПоступление +__WHERE_IN__ +СГРУППИРОВАТЬ ПО + БанкПоступление.Контрагент, + НАЧАЛОПЕРИОДА(БанкПоступление.Дата, ГОД) +ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ МАКСИМУМ(БанкПоступление.Дата) КАК Период, "CP_CUSTOMER_ACTIVITY" КАК Регистратор, @@ -239,6 +253,7 @@ __WHERE_IN__ СГРУППИРОВАТЬ ПО БанкПоступление.Контрагент УПОРЯДОЧИТЬ ПО + Регистратор, Сумма УБЫВ, Период УБЫВ `; @@ -517,7 +532,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ optional_filters: ["as_of_date", "counterparty", "contract", "limit"], default_limit: 64, account_scope: ["60", "76"], - account_scope_mode: "preferred" + account_scope_mode: "strict" }, { recipe_id: "address_movements_receivables_v1", @@ -527,7 +542,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ optional_filters: ["as_of_date", "counterparty", "contract", "limit"], default_limit: 64, account_scope: ["62", "76"], - account_scope_mode: "preferred" + account_scope_mode: "strict" }, { recipe_id: "address_open_contracts_candidates_v1", @@ -537,7 +552,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ optional_filters: ["as_of_date", "organization", "limit"], default_limit: 128, account_scope: ["60", "62", "76"], - account_scope_mode: "preferred" + account_scope_mode: "strict" }, { recipe_id: "address_open_items_by_party_or_contract_v1", diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index c7751f8..16793b0 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -13,6 +13,7 @@ interface ComposeFactualReplyOptions { userMessage?: string; periodFrom?: string; periodTo?: string; + asOfDate?: string; } type PeriodProfileFocus = @@ -239,6 +240,99 @@ function normalizeQuestionText(value: string | null | undefined): string { .trim(); } +function normalizeIsoDateOnly(value: string | null | undefined): string | null { + const parsed = parseIsoDateToken(value); + if (!parsed) { + return null; + } + return toIsoDate(parsed.year, parsed.month, parsed.day); +} + +function toUtcDayTimestamp(isoDate: string | null | undefined): number | null { + const parsed = parseIsoDateToken(isoDate); + if (!parsed) { + return null; + } + return Date.UTC(parsed.year, parsed.month - 1, parsed.day); +} + +function resolveReceivablesAsOfDate(options: ComposeFactualReplyOptions): string { + const explicit = normalizeIsoDateOnly(options.asOfDate); + if (explicit) { + return explicit; + } + const periodTo = normalizeIsoDateOnly(options.periodTo); + if (periodTo) { + return periodTo; + } + const now = new Date(); + return toIsoDate(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate()); +} + +function hasReceivablesDebtAgingFocus(userMessage: string | null | undefined): boolean { + const text = normalizeQuestionText(userMessage); + if (!text) { + return false; + } + const hasDebtSignal = /(?:долг(?:и|ов|а|у)?|задолж|дебитор|не плат|неоплач|просроч|хвост)/iu.test(text); + const hasLongevitySignal = + /(?:долгожив|долгожител|дольше|длительн|несколько\s+месяц|возраст|по\s+времен|с\s+момент|на\s+этот\s+момент)/iu.test( + text + ); + const hasCounterpartySignal = /(?:заказчик|клиент|покупател|контрагент|должник|counterpart|customer|client|buyer)/iu.test( + text + ); + return hasDebtSignal && hasLongevitySignal && hasCounterpartySignal; +} + +function formatAgeYearsMonthsDays(daysRaw: number): string { + const safeDays = Math.max(0, Math.floor(daysRaw)); + const years = Math.floor(safeDays / 365); + const remAfterYears = safeDays % 365; + const months = Math.floor(remAfterYears / 30); + const days = remAfterYears % 30; + const parts: string[] = []; + if (years > 0) { + parts.push(`${years} г.`); + } + if (months > 0) { + parts.push(`${months} мес.`); + } + if (parts.length === 0 || days > 0) { + parts.push(`${days} дн.`); + } + return parts.join(" "); +} + +function extractContractDateFromToken(token: string): string | null { + const source = String(token ?? "").trim(); + if (!source) { + return null; + } + const lower = source.toLowerCase(); + if (!/(?:договор|contract|дог\.)/iu.test(lower)) { + return null; + } + + const isoMatch = source.match(/\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/); + if (isoMatch) { + const isoCandidate = isoMatch[0]; + return normalizeIsoDateOnly(isoCandidate); + } + + const ruMatch = source.match(/\b(0[1-9]|[12]\d|3[01])[./-](0[1-9]|1[0-2])[./-]((?:19|20)\d{2})\b/); + if (!ruMatch) { + return null; + } + const day = Number(ruMatch[1]); + const month = Number(ruMatch[2]); + const year = Number(ruMatch[3]); + if (!Number.isFinite(day) || !Number.isFinite(month) || !Number.isFinite(year)) { + return null; + } + return toIsoDate(year, month, day); +} + function needsVatWhyExplanation(userMessage: string | null | undefined): boolean { const text = normalizeQuestionText(userMessage); if (!text) { @@ -380,6 +474,23 @@ function detectCounterpartyLifecycleFocus(userMessage: string | null | undefined return "active_customers_period"; } +function hasCounterpartyLifecycleLongevityQuestion(userMessage: string | null | undefined): boolean { + const text = normalizeQuestionText(userMessage); + if (!text) { + return false; + } + const hasCounterpartyLexeme = + /(?:заказчик(?:ов|а|и)?|клиент(?:ов|а|ы)?|покупател(?:ей|я|и)?|контрагент(?:ов|а|ы)?|customer(?:s)?|client(?:s)?|counterpart(?:y|ies)|buyer(?:s)?)/iu.test( + text + ); + const hasLongevityCue = + /(?:долгожив|долгожител|дольше(?:\s+всех)?|сам(?:ые|ый)\s+стар(?:ые|ый)|лет\s+в\s+базе|лет\s+с\s+нами|longest|oldest)/iu.test( + text + ); + const hasImplicitCounterpartyQuestion = /(?:кто\s+с\s+нами|кто\s+у\s+нас)/iu.test(text); + return (hasCounterpartyLexeme || hasImplicitCounterpartyQuestion) && hasLongevityCue; +} + function detectMinOpsForAvgCheck(userMessage: string | null | undefined): number { const text = normalizeQuestionText(userMessage); if (!text) { @@ -461,6 +572,9 @@ function extractRequestedYearFromQuestion(userMessage: string | null | undefined } function extractCounterpartyName(row: ComposeStageRow): string | null { + const skipTokenPattern = + /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu; + for (const token of row.analytics) { const normalized = String(token ?? "").trim(); if (!normalized) { @@ -469,11 +583,216 @@ function extractCounterpartyName(row: ComposeStageRow): string | null { if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { continue; } + if (/^\d+(?:[./-]\d+)*$/.test(normalized)) { + continue; + } + if (!/[a-zа-я]/iu.test(normalized)) { + continue; + } + if (skipTokenPattern.test(normalized)) { + continue; + } + return normalized; + } + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (!normalized) { + continue; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { + continue; + } + if (normalized.length < 3) { + continue; + } return normalized; } return null; } +interface CounterpartyRiskAggregate { + name: string; + totalAmount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; +} + +function buildCounterpartyRiskAggregate(rows: ComposeStageRow[]): CounterpartyRiskAggregate[] { + const byCounterparty = new Map(); + + 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; + } + >(); + + for (const row of rows) { + const name = extractCounterpartyName(row); + if (!name) { + continue; + } + const amountRaw = row.amount ?? 0; + if (!Number.isFinite(amountRaw)) { + continue; + } + const amount = Math.abs(amountRaw); + const rowIso = normalizeIsoDateOnly(row.period); + const contractDate = pickContractStartDateFromRow(row); + const contractName = extractContractName(row); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + base: { + name, + totalAmount: amount, + operations: 1, + firstPeriod: rowIso, + lastPeriod: rowIso + }, + minContractDate: contractDate, + contracts: new Set(contractName ? [contractName] : []) + }); + continue; + } + current.base.totalAmount += amount; + current.base.operations += 1; + current.base.firstPeriod = minIsoDate(current.base.firstPeriod, rowIso); + current.base.lastPeriod = maxIsoDate(current.base.lastPeriod, rowIso); + current.minContractDate = minIsoDate(current.minContractDate, contractDate); + if (contractName) { + current.contracts.add(contractName); + } + } + + const asOfTs = toUtcDayTimestamp(asOfDate); + const finalized = Array.from(byCounterparty.values()).map((entry) => { + const debtAgeStartDate = entry.minContractDate ?? entry.base.firstPeriod ?? null; + const startTs = toUtcDayTimestamp(debtAgeStartDate); + const debtAgeSource: CounterpartyDebtAgingAggregate["debtAgeSource"] = entry.minContractDate + ? "contract_date" + : "first_movement"; + const debtAgeDays = + asOfTs !== null && startTs !== null && asOfTs >= startTs + ? Math.floor((asOfTs - startTs) / (24 * 60 * 60 * 1000)) + : null; + return { + ...entry.base, + debtAgeStartDate, + debtAgeDays, + debtAgeSource, + contractDateDetected: Boolean(entry.minContractDate), + contracts: Array.from(entry.contracts.values()).slice(0, 3) + }; + }); + + return finalized.sort((left, right) => { + const leftAge = left.debtAgeDays ?? -1; + const rightAge = right.debtAgeDays ?? -1; + if (rightAge !== leftAge) { + return rightAge - leftAge; + } + if (right.totalAmount !== left.totalAmount) { + return right.totalAmount - left.totalAmount; + } + if (right.operations !== left.operations) { + return right.operations - left.operations; + } + return left.name.localeCompare(right.name); + }); +} + function extractContractName(row: ComposeStageRow): string | null { for (const token of row.analytics) { const normalized = String(token ?? "").trim(); @@ -946,7 +1265,44 @@ export function composeFactualReply( const activityRows = rows.filter( (row) => String(row.registrator ?? "").trim().toUpperCase() === "CP_CUSTOMER_ACTIVITY" ); - const byCounterparty = new Map(); + 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 } + >(); + + for (const row of activityYearRows) { + const name = extractCounterpartyName(row); + if (!name) { + continue; + } + const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); + const year = extractYearFromIso(row.period); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + years: new Set(year !== null ? [year] : []) + }); + continue; + } + current.opsCount += opsCount; + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if (year !== null) { + current.years.add(year); + } + } + for (const row of activityRows) { const name = extractCounterpartyName(row); if (!name) { @@ -955,26 +1311,48 @@ export function composeFactualReply( const opsCount = Math.max(0, Math.trunc(row.amount ?? 0)); const current = byCounterparty.get(name); if (!current) { - byCounterparty.set(name, { name, opsCount, lastPeriod: row.period }); + const year = extractYearFromIso(row.period); + byCounterparty.set(name, { + name, + opsCount, + lastPeriod: row.period, + firstPeriod: row.period, + years: new Set(year !== null ? [year] : []) + }); continue; } - if (opsCount > current.opsCount) { + if (activityYearRows.length === 0 && opsCount > current.opsCount) { current.opsCount = opsCount; } if ((row.period ?? "") > (current.lastPeriod ?? "")) { current.lastPeriod = row.period; } + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + const year = extractYearFromIso(row.period); + if (year !== null) { + current.years.add(year); + } } - const counterparties = Array.from(byCounterparty.values()).sort((left, right) => { + const counterpartiesRaw = Array.from(byCounterparty.values()); + const focus = detectCounterpartyLifecycleFocus(options.userMessage); + const requestedYear = extractRequestedYearFromQuestion(options.userMessage); + const longevityQuestion = hasCounterpartyLifecycleLongevityQuestion(options.userMessage); + const rankingLimit = detectRankingLimit(options.userMessage, 10); + const counterparties = counterpartiesRaw.sort((left, right) => { + if (longevityQuestion) { + const yearsDiff = right.years.size - left.years.size; + if (yearsDiff !== 0) { + return yearsDiff; + } + } if (right.opsCount !== left.opsCount) { return right.opsCount - left.opsCount; } return (right.lastPeriod ?? "").localeCompare(left.lastPeriod ?? ""); }); - - const focus = detectCounterpartyLifecycleFocus(options.userMessage); - const requestedYear = extractRequestedYearFromQuestion(options.userMessage); const scopeLabel = focus === "active_customers_all_time" ? "за все время" @@ -982,29 +1360,53 @@ export function composeFactualReply( ? `в ${requestedYear} году` : "в выбранном периоде"; - const lines: string[] = [ - `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, - "Собран профиль активности заказчиков (bank-doc activity aggregate).", - `Строк агрегата: ${rows.length}.` - ]; + const lines: string[] = longevityQuestion + ? [ + `Заказчиков с самым длинным горизонтом сотрудничества (по годам): ${counterparties.length}.`, + "Собран lifecycle-профиль заказчиков: ранжирование по числу лет и частоте активности.", + `Строк агрегата: ${rows.length}.` + ] + : [ + `Активные заказчики ${scopeLabel}: ${counterparties.length}.`, + "Собран профиль активности заказчиков (bank-doc activity aggregate).", + `Строк агрегата: ${rows.length}.` + ]; if (counterparties.length === 0) { - lines.push("По выбранному окну активности заказчики не найдены."); + lines.push( + longevityQuestion + ? "По доступному окну не удалось выделить заказчиков с подтвержденной длительностью сотрудничества по годам." + : "По выбранному окну активности заказчики не найдены." + ); return { responseType: "FACTUAL_SUMMARY", text: lines.join("\n") }; } - const visible = counterparties.slice(0, 120); + const visible = counterparties.slice(0, longevityQuestion ? rankingLimit : 120); + if (longevityQuestion) { + lines.push(`Топ-${visible.length} заказчиков по охвату лет и частоте операций:`); + } lines.push( ...visible.map((item, index) => { + const years = Array.from(item.years).sort((a, b) => a - b); + const yearsLabel = years.length > 0 ? ` | лет в базе: ${years.length} | годы: ${years.join(", ")}` : ""; + const periodSpan = + item.firstPeriod && item.lastPeriod ? ` | период: ${item.firstPeriod}..${item.lastPeriod}` : ""; + if (longevityQuestion) { + return `${index + 1}. ${item.name} | операций: ${item.opsCount}${yearsLabel}${periodSpan}`; + } const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : ""; - return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}`; + return `${index + 1}. ${item.name} | операций: ${item.opsCount}${suffix}${years.length > 0 ? ` | лет в базе: ${years.length}` : ""}`; }) ); if (counterparties.length > visible.length) { - lines.push(`Показаны первые ${visible.length} из ${counterparties.length} заказчиков.`); + lines.push( + longevityQuestion + ? `Показаны первые ${visible.length} из ${counterparties.length} заказчиков (полный список можно выгрузить отдельно).` + : `Показаны первые ${visible.length} из ${counterparties.length} заказчиков.` + ); } return { @@ -1508,6 +1910,7 @@ export function composeFactualReply( if (intent === "list_open_contracts") { const contracts = contractCandidatesFromRows(rows); + const counterparties = buildCounterpartyRiskAggregate(rows); const lines = [ "Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).", `Строк движения: ${rows.length}.`, @@ -1515,6 +1918,17 @@ export function composeFactualReply( ]; if (contracts.length > 0) { lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)); + } else if (counterparties.length > 0) { + lines.push(`Контрагентов с сигналом незакрытых хвостов: ${counterparties.length}.`); + lines.push( + ...counterparties + .slice(0, 8) + .map( + (item, index) => + `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}` + ) + ); + lines.push("Договорные якоря в этом live-срезе не выделены, поэтому показан контрагентный рейтинг риска."); } else { lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback."); lines.push(...formatTopRows(rows, 6)); @@ -1526,14 +1940,28 @@ export function composeFactualReply( } if (intent === "list_payables_counterparties") { + const counterparties = buildCounterpartyRiskAggregate(rows); const lines = [ "Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).", `Строк в выборке: ${rows.length}.`, - ...(rows.length > 0 - ? ["Ниже примеры строк для ручной проверки."] - : ["Явных признаков системной задолженности по доступному срезу не найдено."]), - ...formatTopRows(rows, 6) + `Контрагентов с сигналом: ${counterparties.length}.` ]; + if (counterparties.length > 0) { + lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):"); + lines.push( + ...counterparties + .slice(0, 8) + .map( + (item, index) => + `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}` + ) + ); + lines.push("Примеры исходных строк:"); + lines.push(...formatTopRows(rows, 4)); + } else { + lines.push("Явных признаков системной задолженности по доступному срезу не найдено."); + lines.push(...formatTopRows(rows, 6)); + } return { responseType: "FACTUAL_LIST", text: lines.join("\n") @@ -1541,14 +1969,67 @@ export function composeFactualReply( } if (intent === "list_receivables_counterparties") { + const counterparties = buildCounterpartyRiskAggregate(rows); + const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage); + if (debtAgingFocus) { + const asOfDate = resolveReceivablesAsOfDate(options); + const aging = buildCounterpartyDebtAgingAggregate(rows, asOfDate); + const detectedContractDates = aging.filter((item) => item.contractDateDetected).length; + const lines: string[] = [ + "Проверил должников по сроку жизни задолженности (контур 62/76).", + `Дата среза: ${formatDateRu(asOfDate)}.`, + `Строк в выборке: ${rows.length}.`, + `Контрагентов с сигналом: ${aging.length}.` + ]; + + if (aging.length > 0) { + lines.push("Приоритет ручной проверки (по возрасту долга, по убыванию):"); + lines.push( + ...aging.slice(0, 10).map((item, index) => { + const ageLabel = item.debtAgeDays !== null ? formatAgeYearsMonthsDays(item.debtAgeDays) : "н/д"; + const startDateLabel = item.debtAgeStartDate ? formatDateRu(item.debtAgeStartDate) : "не определена"; + const startSourceLabel = + item.debtAgeSource === "contract_date" ? "дата договора" : "первое движение по договору/контрагенту"; + const contractsLabel = + item.contracts.length > 0 ? item.contracts.join("; ") : "договор не выделен в срезе"; + return `${index + 1}. ${item.name} | договоры: ${contractsLabel} | возраст долга: ${ageLabel} | старт: ${startDateLabel} (${startSourceLabel}) | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}`; + }) + ); + lines.push( + detectedContractDates > 0 + ? `Дата договора выделена для ${detectedContractDates} из ${aging.length} контрагентов; для остальных использован старт первого движения.` + : "Явная дата договора в live-строках не выделена, возраст рассчитан от первого движения." + ); + } else { + lines.push("Явных признаков затяжной дебиторки по доступному срезу не найдено."); + } + + return { + responseType: "FACTUAL_LIST", + text: lines.join("\n") + }; + } const lines = [ "Проверил покупателей с признаками затянутой оплаты (контур 62/76).", `Строк в выборке: ${rows.length}.`, - ...(rows.length > 0 - ? ["Ниже примеры строк, которые стоит проверить в первую очередь."] - : ["Явных признаков затяжной дебиторки по доступному срезу не найдено."]), - ...formatTopRows(rows, 6) + `Контрагентов с сигналом: ${counterparties.length}.` ]; + if (counterparties.length > 0) { + lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):"); + lines.push( + ...counterparties + .slice(0, 8) + .map( + (item, index) => + `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}` + ) + ); + lines.push("Примеры исходных строк:"); + lines.push(...formatTopRows(rows, 4)); + } else { + lines.push("Явных признаков затяжной дебиторки по доступному срезу не найдено."); + lines.push(...formatTopRows(rows, 6)); + } return { responseType: "FACTUAL_LIST", text: lines.join("\n") @@ -1556,11 +2037,26 @@ export function composeFactualReply( } if (intent === "open_items_by_counterparty_or_contract") { + const counterparties = buildCounterpartyRiskAggregate(rows); const lines = [ - "Собраны открытые позиции по указанному фильтру (контрагент/договор).", + "Собраны открытые позиции по взаиморасчетам.", `Строк отобрано: ${rows.length}.`, - ...formatTopRows(rows, 6) + `Контрагентов с сигналом: ${counterparties.length}.` ]; + if (counterparties.length > 0) { + lines.push( + ...counterparties + .slice(0, 8) + .map( + (item, index) => + `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}` + ) + ); + lines.push("Примеры исходных строк:"); + lines.push(...formatTopRows(rows, 4)); + } else { + lines.push(...formatTopRows(rows, 6)); + } return { responseType: "FACTUAL_LIST", text: lines.join("\n") diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index bc57aed..07c300e 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -3106,7 +3106,11 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll hasDeepAnalysisPreferenceSignal(rawMessageForGate) || hasDeepAnalysisPreferenceSignal(repairedInputMessage); const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage); - const hasClassifierSignal = modeDetection.mode === "address_query"; + const modeDetectionRaw = (0, addressQueryClassifier_1.detectAddressQuestionMode)(String(addressInputMessage ?? "")); + const hasClassifierSignal = modeDetection.mode === "address_query" || modeDetectionRaw.mode === "address_query"; + const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(repairedInputMessage || addressInputMessage); + const intentResolutionRaw = (0, addressIntentResolver_1.resolveAddressIntent)(String(addressInputMessage ?? "")); + const hasIntentSignal = intentResolution.intent !== "unknown" || intentResolutionRaw.intent !== "unknown"; const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence); const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); @@ -3137,7 +3141,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && llmContractIntent === "unknown"; - const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; + const hasAnyAddressSignal = + hasClassifierSignal || hasIntentSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) || hasDataRetrievalRequestSignal(rawMessageForGate) || hasAccountingSignal(rawMessageForGate) || @@ -3173,6 +3178,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll decision: "run_address_lane", reason: hasClassifierSignal ? "address_mode_classifier_detected" + : hasIntentSignal + ? "address_intent_resolver_detected" : hasLlmCanonicalSignal ? "llm_canonical_candidate_detected" : hasLlmCanonicalDataSignal @@ -3661,8 +3668,8 @@ function hasDataRetrievalRequestSignal(text) { if (hasBroadInterrogative && hasBroadBusinessObject) { return true; } - const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a)(?:$|[\s,.!?;:])/iu.test(lower); - const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446)/iu.test(lower); + const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a|\u043f\u0440\u043e\u0432\u0435\u0440\u044c|\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c)(?:$|[\s,.!?;:])/iu.test(lower); + const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433)/iu.test(lower); if (hasRussianRetrievalAction && hasRussianRetrievalObject) { return true; } diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index b0dcfec..8d27d19 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -925,6 +925,90 @@ describe("address compose stage utf8 headers", () => { expect(reply.text).toContain("Активные заказчики в 2020 году: 1."); }); + it("returns top-10 lifecycle ranking by years for longest-collaboration customer question", () => { + const reply = composeFactualReply( + "counterparty_activity_lifecycle", + [ + { + period: "2019-01-01T00:00:00Z", + registrator: "CP_CUSTOMER_ACTIVITY_YEAR", + account_dt: "", + account_kt: "", + amount: 5, + analytics: ["НОРТОН"] + }, + { + period: "2020-01-01T00:00:00Z", + registrator: "CP_CUSTOMER_ACTIVITY_YEAR", + account_dt: "", + account_kt: "", + amount: 7, + analytics: ["НОРТОН"] + }, + { + period: "2021-01-01T00:00:00Z", + registrator: "CP_CUSTOMER_ACTIVITY_YEAR", + account_dt: "", + account_kt: "", + amount: 4, + analytics: ["Группа"] + }, + { + period: "2022-05-12T12:00:00Z", + registrator: "CP_CUSTOMER_ACTIVITY", + account_dt: "", + account_kt: "", + amount: 12, + analytics: ["НОРТОН"] + } + ], + { + userMessage: "Какие заказчики работают с нами дольше всего?" + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text).toContain("Заказчиков с самым длинным горизонтом сотрудничества (по годам)"); + expect(reply.text).toContain("Топ-"); + expect(reply.text).toContain("лет в базе"); + expect(reply.text).toContain("НОРТОН"); + }); + + it("renders debt-aging ranking by as-of date for receivables debt-longevity question", () => { + const reply = composeFactualReply( + "list_receivables_counterparties", + [ + { + period: "2022-01-01T00:00:00Z", + registrator: "Реализация 1", + account_dt: "62.01", + account_kt: "90.01", + amount: 1000, + analytics: ["Контрагент А", "Договор №A-01 от 10.02.2020"] + }, + { + period: "2024-01-01T00:00:00Z", + registrator: "Реализация 2", + account_dt: "62.01", + account_kt: "90.01", + amount: 1500, + analytics: ["Контрагент Б", "Договор №B-01 от 15.03.2023"] + } + ], + { + userMessage: "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?", + asOfDate: "2026-04-11" + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text).toContain("Проверил должников по сроку жизни задолженности"); + expect(reply.text).toContain("Дата среза: 11.04.2026."); + expect(reply.text).toContain("Приоритет ручной проверки (по возрасту долга, по убыванию):"); + expect(reply.text).toContain("1. Контрагент А | договоры:"); + expect(reply.text).toContain("2. Контрагент Б | договоры:"); + }); + it("returns contract usage overview summary", () => { const reply = composeFactualReply("contract_usage_overview", [ { @@ -1594,6 +1678,14 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.intent).toBe("counterparty_activity_lifecycle"); }); + it("routes debt-longevity wording into receivables intent", () => { + const result = resolveAddressIntent( + "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?" + ); + expect(result.intent).toBe("list_receivables_counterparties"); + expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected"); + }); + it("resolves supplier lifecycle segmentation wording into lifecycle intent", () => { const result = resolveAddressIntent("Раздели поставщиков на регулярных и эпизодических по активности."); expect(result.intent).toBe("counterparty_activity_lifecycle"); @@ -1759,6 +1851,11 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.intent).toBe("list_receivables_counterparties"); }); + it("routes overdue unpaid buyers wording into receivables intent", () => { + const result = resolveAddressIntent("какие покупатели пока не оплатили свои товары или услуги, хотя сроки давно прошли?"); + expect(result.intent).toBe("list_receivables_counterparties"); + }); + it("routes reconciliation mismatch wording into open contracts intent", () => { const result = resolveAddressIntent( "Покажи контрагентов, по которым сальдо скорее всего не совпадет с их актом сверки. Может, стоит поторопиться и запросить сверку?" @@ -1780,6 +1877,23 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.intent).toBe("list_open_contracts"); }); + it("routes payments-without-settlement-closure wording into open contracts intent", () => { + const result = resolveAddressIntent("где у нас есть оплаты без закрытия взаиморасчетов, и это уже требует ручной проверки?"); + expect(result.intent).toBe("list_open_contracts"); + }); + + it("routes shipments-without-closing-docs wording into open contracts intent", () => { + const result = resolveAddressIntent("где у нас есть отгрузки без документов для их закрытия и это уже требует внимания?"); + expect(result.intent).toBe("list_open_contracts"); + }); + + it("routes closing-without-supporting-docs wording into open contracts intent", () => { + const result = resolveAddressIntent( + "где у нас есть закрытие счетов без подтверждающих документов и это уже требует ручной проверки?" + ); + expect(result.intent).toBe("list_open_contracts"); + }); + it("routes documents-without-payments wording into open contracts intent", () => { const result = resolveAddressIntent( "По каким контрагентам документы есть, а оплат нет. Может, стоит взять на карандаш такие ситуации чтоб не тянуть дальше?" @@ -2336,7 +2450,7 @@ describe("address filter extraction for balance drilldown", () => { }); }); -describe("address query limited taxonomy and stage diagnostics", () => { +describe("address query limited taxonomy and stage diagnostics", { timeout: 15000 }, () => { it("injects as_of_date from analysis context when user message has no explicit period", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("Покажи контрагентов с незакрытыми хвостами", { @@ -2385,6 +2499,22 @@ describe("address query limited taxonomy and stage diagnostics", () => { expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); + it("keeps strict account scope for receivables risk replies and excludes far-future leakage", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное." + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_receivables_counterparties"); + expect(result?.debug.account_scope_mode).toBe("strict"); + expect(result?.debug.account_scope_fallback_applied).toBe(false); + if (result?.response_type === "FACTUAL_LIST") { + const reply = String(result?.reply_text ?? ""); + expect(reply).not.toMatch(/68\.0?2\s*\/\s*19\.0?4/); + expect(reply).not.toContain("2030-"); + } + }); + it("routes payments-without-closing-docs wording into open contracts lane", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( @@ -2396,6 +2526,47 @@ describe("address query limited taxonomy and stage diagnostics", () => { expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); + it("routes payments-without-settlement-closure wording into open contracts lane", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("где у нас есть оплаты без закрытия взаиморасчетов, и это уже требует ручной проверки?"); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_open_contracts"); + expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1"); + expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); + expect(result?.debug.limited_reason_category).not.toBe("unsupported"); + }); + + it("routes shipments-without-closing-docs wording into open contracts lane", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("где у нас есть отгрузки без документов для их закрытия и это уже требует внимания?"); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_open_contracts"); + expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1"); + expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); + expect(result?.debug.limited_reason_category).not.toBe("unsupported"); + }); + + it("routes closing-without-supporting-docs wording into open contracts lane", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "где у нас есть закрытие счетов без подтверждающих документов и это уже требует ручной проверки?" + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_open_contracts"); + expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1"); + expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); + expect(result?.debug.limited_reason_category).not.toBe("unsupported"); + }); + + it("keeps strict account scope for open-contract scans", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("Какие незакрытые документы по договорам у нас уже давно пора проверить?"); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_open_contracts"); + expect(result?.debug.account_scope_mode).toBe("strict"); + expect(result?.debug.account_scope_fallback_applied).toBe(false); + }); + it("routes stale advances wording into open contracts lane without missing-anchor fallback", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( @@ -2419,6 +2590,18 @@ describe("address query limited taxonomy and stage diagnostics", () => { expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); + it("routes overdue unpaid buyers wording into receivables lane without missing-anchor fallback", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "какие покупатели пока не оплатили свои товары или услуги, хотя сроки давно прошли?" + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_receivables_counterparties"); + expect(result?.debug.selected_recipe).toBe("address_movements_receivables_v1"); + expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); + expect(result?.debug.limited_reason_category).not.toBe("unsupported"); + }); + it("routes documents-without-payments wording into open contracts lane", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( @@ -2629,6 +2812,38 @@ describe("address query limited taxonomy and stage diagnostics", () => { expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); }); + it("routes longest-collaboration customer wording into lifecycle aggregate recipe", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "Какие заказчики работают с нами дольше всего?" + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle"); + expect(result?.debug.selected_recipe).toBe("address_counterparty_activity_lifecycle_v1"); + expect(result?.debug.mcp_call_status).not.toBe("skipped"); + expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); + if (result?.response_type === "FACTUAL_LIST") { + expect(String(result.reply_text)).toContain("лет в базе"); + expect(String(result.reply_text)).toContain("Топ-"); + } + }); + + it("routes debt-longevity wording into receivables lane with factual reply", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?" + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_receivables_counterparties"); + expect(result?.debug.selected_recipe).toBe("address_open_items_by_party_or_contract_v1"); + expect(result?.debug.mcp_call_status).not.toBe("skipped"); + expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); + if (result?.response_type === "FACTUAL_LIST") { + expect(String(result.reply_text)).toContain("сроку жизни задолженности"); + expect(String(result.reply_text)).toContain("Дата среза"); + } + }); + it("routes stale contracts wording into contract usage overview recipe", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("Какие договоры давно не использовались?"); @@ -2649,13 +2864,13 @@ describe("address query limited taxonomy and stage diagnostics", () => { expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); }); - it("returns missing_anchor for open items without concrete counterparty/contract anchor", async () => { + it("allows broad open items scan without forcing missing_anchor", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("show open items by contract"); expect(result?.handled).toBe(true); - expect(result?.response_type).toBe("LIMITED_WITH_REASON"); - expect(result?.debug.limited_reason_category).toBe("missing_anchor"); - expect(result?.debug.mcp_call_status).toBe("skipped"); + expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract"); + expect(result?.debug.mcp_call_status).not.toBe("skipped"); + expect(result?.debug.limited_reason_category).not.toBe("missing_anchor"); }); it("does not return fallback factual rows for unmatched open-items contract anchor", async () => { diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index 9454cd2..7ecaaad 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -278,7 +278,7 @@ describe("assistant orchestration contract", () => { expect(decision.livingMode).toBe("address_data"); expect(decision.toolGateDecision).toBe("run_address_lane"); - expect(decision.toolGateReason).toBe("address_signal_detected"); + expect(["address_signal_detected", "address_intent_resolver_detected"]).toContain(String(decision.toolGateReason)); expect(decision.livingReason).toBe("address_lane_triggered"); }); diff --git a/llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts b/llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts new file mode 100644 index 0000000..891779a --- /dev/null +++ b/llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts @@ -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); + } + }); +}); diff --git a/llm_normalizer/backend/tests/assistantWave18ManualCommentsRegression.test.ts b/llm_normalizer/backend/tests/assistantWave18ManualCommentsRegression.test.ts new file mode 100644 index 0000000..82e1bc9 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantWave18ManualCommentsRegression.test.ts @@ -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(/�/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(); + + 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 + ); +}); diff --git a/llm_normalizer/data/autorun_annotations/annotations.json b/llm_normalizer/data/autorun_annotations/annotations.json index 61c22c8..ba0b2be 100644 --- a/llm_normalizer/data/autorun_annotations/annotations.json +++ b/llm_normalizer/data/autorun_annotations/annotations.json @@ -198,11 +198,11 @@ "comment": "на выбранный период можно показать не закрытые договора - очевидно что по контексту это можно упростить до домена открытых договоров", "manual_case_decision": "needs_dialog_policy_fix", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:57:36.621Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T09:13:08.915Z", - "updated_at": "2026-04-10T09:13:08.915Z", + "updated_at": "2026-04-11T19:57:36.621Z", "context": { "message_id": "msg-zs_PZOy4zu", "trace_id": "address-KYzulAwWgo", @@ -279,11 +279,11 @@ "comment": "в чем проблема чекнуть это в 1с? это же не сложно вродже?", "manual_case_decision": "needs_routing_extension", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:56:58.642Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T09:16:08.588Z", - "updated_at": "2026-04-10T09:16:08.588Z", + "updated_at": "2026-04-11T19:56:58.642Z", "context": { "message_id": "msg-3otxbY6nSu", "trace_id": "address-VpPvao4Vua", @@ -333,11 +333,11 @@ "comment": "необходим вывод открытых договоров не закрытых актими или финальными выплатами", "manual_case_decision": "needs_routing_extension", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:56:26.168Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T09:18:21.929Z", - "updated_at": "2026-04-10T09:18:21.929Z", + "updated_at": "2026-04-11T19:56:26.168Z", "context": { "message_id": "msg-Rviqs5LOve", "trace_id": "address-2YTJldRV28", @@ -414,11 +414,11 @@ "comment": "надо отрабатывать маршрут - вопрос простой и полезный", "manual_case_decision": "candidate_for_implementation", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:55:49.575Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T09:21:09.469Z", - "updated_at": "2026-04-10T09:21:09.469Z", + "updated_at": "2026-04-11T19:55:49.575Z", "context": { "message_id": "msg-h4Uw2x6woE", "trace_id": "address-mhaJ03Mjei", @@ -468,11 +468,11 @@ "comment": "почему мы вообще отвечаем так шаблонно?? зачем нам ллм? может на три уровня на старте ллм поставить?разбор контекста - декомпозиция - и если не можем отработать то ответ уже человеческий в контексте? сейчас постояннно одинаковые ответы это бесит", "manual_case_decision": "bad_test_case", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:55:10.038Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T09:25:30.011Z", - "updated_at": "2026-04-10T09:25:30.011Z", + "updated_at": "2026-04-11T19:55:10.038Z", "context": { "message_id": "msg-H7JqG6Ni0g", "trace_id": "address-fGojfD8Utf", @@ -522,11 +522,11 @@ "comment": "очень важный кейс - надо отрабатывать - причем потенциально мы должны это умееть - ут надо показать кто из заказчиков сидит с отрытыми договорами на дату рассмотрения", "manual_case_decision": "candidate_for_implementation", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:53:55.569Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T09:27:54.906Z", - "updated_at": "2026-04-10T09:27:54.906Z", + "updated_at": "2026-04-11T19:53:55.569Z", "context": { "message_id": "msg-3RW8I_1Y4f", "trace_id": "address-3FNYpnavQE", @@ -576,11 +576,11 @@ "comment": "мы модем показать договора заведенные без оплавт на период рассмотрения - тема не сложная надо отработать", "manual_case_decision": "needs_routing_extension", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:53:31.252Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T21:06:49.639Z", - "updated_at": "2026-04-10T21:06:49.639Z", + "updated_at": "2026-04-11T19:53:31.252Z", "context": { "message_id": "msg-MWxfeEbPmS", "trace_id": "address-V5-7tJrBPM", @@ -603,11 +603,11 @@ "comment": "тут надо сапоставить договора с датами и отсутствие платежей по ним или старые платежи авансовые - надо дороботать - вопрос простой и важный", "manual_case_decision": "candidate_for_implementation", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:52:52.569Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T21:08:53.728Z", - "updated_at": "2026-04-10T21:08:53.728Z", + "updated_at": "2026-04-11T19:52:52.569Z", "context": { "message_id": "msg-GwBH6jyVi_", "trace_id": "address-719KtaE1Li", @@ -630,11 +630,11 @@ "comment": "технический ответ - такого быть не должно", "manual_case_decision": "needs_dialog_policy_fix", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:52:12.080Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T21:09:36.200Z", - "updated_at": "2026-04-10T21:09:36.200Z", + "updated_at": "2026-04-11T19:52:12.080Z", "context": { "message_id": "msg-xCoMu24uIa", "trace_id": "Idd369iAGgAGpm", @@ -657,11 +657,11 @@ "comment": "ушло не в ту ветку - ответ совершенно не в кассу", "manual_case_decision": "needs_dialog_policy_fix", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:38:35.315Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-10T21:10:27.894Z", - "updated_at": "2026-04-10T21:10:27.894Z", + "updated_at": "2026-04-11T19:38:35.315Z", "context": { "message_id": "msg-_p_ppJ9bfV", "trace_id": "chat-QQxVBg9vSO", @@ -684,11 +684,11 @@ "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов", "manual_case_decision": "candidate_for_implementation", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T19:37:36.816Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-11T12:44:50.920Z", - "updated_at": "2026-04-11T12:44:50.920Z", + "updated_at": "2026-04-11T19:37:36.816Z", "context": { "message_id": "msg-tL1QVBeDxY", "trace_id": "Ylge9xWuRuJLvV", @@ -711,11 +711,11 @@ "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - однозначно к дорабюотке - анализируем заказчиков которые чаще встречаются по годам и выводим топ 10", "manual_case_decision": "needs_dialog_policy_fix", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T20:33:42.820Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-11T12:45:50.144Z", - "updated_at": "2026-04-11T12:45:50.144Z", + "updated_at": "2026-04-11T20:33:42.820Z", "context": { "message_id": "msg-rj83nhGOV7", "trace_id": "address-H9VJ13GWWC", @@ -738,11 +738,11 @@ "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - простой вопрос - показываем просто открытые договора без приходов денег", "manual_case_decision": "candidate_for_implementation", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T18:08:27.601Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-11T12:46:41.463Z", - "updated_at": "2026-04-11T12:46:41.463Z", + "updated_at": "2026-04-11T18:08:27.601Z", "context": { "message_id": "msg-2kDN4UKCbY", "trace_id": "address-L0WwEsakCe", @@ -765,11 +765,11 @@ "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - надо внедрять", "manual_case_decision": "candidate_for_implementation", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T18:32:00.081Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-11T12:47:04.120Z", - "updated_at": "2026-04-11T12:47:04.120Z", + "updated_at": "2026-04-11T18:32:00.081Z", "context": { "message_id": "msg--ZmllegVvV", "trace_id": "cG51b5sIOWKwTi", @@ -792,11 +792,11 @@ "comment": "покеаываем клиентов с открытими договорами которые висят более месяца бенз денег - выводимм том 10", "manual_case_decision": "candidate_for_implementation", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T18:07:46.388Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-11T12:48:11.847Z", - "updated_at": "2026-04-11T12:48:11.847Z", + "updated_at": "2026-04-11T18:07:46.388Z", "context": { "message_id": "msg-6GpvEMKGcQ", "trace_id": "address-lGGvDiH21w", @@ -819,11 +819,11 @@ "comment": "однозначно на расширение доменов", "manual_case_decision": "candidate_for_implementation", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T18:07:26.769Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-11T12:49:02.831Z", - "updated_at": "2026-04-11T12:49:02.831Z", + "updated_at": "2026-04-11T18:07:26.769Z", "context": { "message_id": "msg-bZZQxlpKcO", "trace_id": "address-JiGLTVe3_0", @@ -846,11 +846,11 @@ "comment": "проблема тыт шум - важно показать договора с незакрытыми доками - топ по времени висения - от самой длинной дистании до кооротной", "manual_case_decision": "needs_dialog_policy_fix", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T18:06:44.816Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-11T12:50:25.257Z", - "updated_at": "2026-04-11T12:50:25.257Z", + "updated_at": "2026-04-11T18:06:44.816Z", "context": { "message_id": "msg-3Leq0R75YH", "trace_id": "E1O-azugbPvNG9", @@ -873,11 +873,11 @@ "comment": "нужен анализ и отработка домена", "manual_case_decision": "candidate_for_implementation", "annotation_author": "manual_reviewer", - "resolved": false, - "resolved_at": null, - "resolved_by": null, + "resolved": true, + "resolved_at": "2026-04-11T18:05:57.111Z", + "resolved_by": "manual_reviewer", "created_at": "2026-04-11T12:50:48.487Z", - "updated_at": "2026-04-11T12:50:48.487Z", + "updated_at": "2026-04-11T18:05:57.111Z", "context": { "message_id": "msg-6Fqd6XrBv2", "trace_id": "address-TnBvKVAVfu",