From fbd156e58eb912d5e60d17aa5b7c5c70a60947b3 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 12 Apr 2026 14:14:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=9E=D0=9C=D0=95=D0=9D=D0=AB=20-=20?= =?UTF-8?q?=D0=92=D0=9E=D0=9F=D0=A0=D0=9E=D0=A1=D0=AB=20-=20=D0=AD=D1=82?= =?UTF-8?q?=D0=B0=D0=BF=204:=20=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=20confirmed=20payabl?= =?UTF-8?q?es=20=D0=BD=D0=B0=20=D0=B4=D0=B0=D1=82=D1=83=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=8D=D0=B2=D1=80=D0=B8=D1=81=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=B3=D0=BE=20=D1=84=D0=BE=D0=BB=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TECH/PLAN_FIX.md | 529 ++++++++++++++++++ .../dist/services/addressFilterExtractor.js | 12 +- .../dist/services/addressIntentResolver.js | 5 +- .../dist/services/addressQueryService.js | 256 +++++++-- .../dist/services/addressRecipeCatalog.js | 18 +- .../services/address_runtime/composeStage.js | 381 ++++++++++--- .../address_runtime/decomposeStage.js | 12 +- .../address_runtime/predecomposeContract.js | 4 +- .../backend/dist/services/assistantService.js | 1 + .../src/services/addressFilterExtractor.ts | 16 +- .../src/services/addressIntentResolver.ts | 5 +- .../src/services/addressQueryService.ts | 81 ++- .../src/services/addressRecipeCatalog.ts | 10 + .../services/address_runtime/composeStage.ts | 78 ++- .../address_runtime/decomposeStage.ts | 14 +- .../address_runtime/predecomposeContract.ts | 6 +- .../backend/src/services/assistantService.ts | 1 + .../backend/src/types/addressQuery.ts | 1 + .../tests/addressQueryRuntimeM23.test.ts | 35 +- 19 files changed, 1303 insertions(+), 162 deletions(-) create mode 100644 docs/TECH/PLAN_FIX.md diff --git a/docs/TECH/PLAN_FIX.md b/docs/TECH/PLAN_FIX.md new file mode 100644 index 0000000..b4bf39a --- /dev/null +++ b/docs/TECH/PLAN_FIX.md @@ -0,0 +1,529 @@ +Да, так двигаться **можно**, но не в виде «сейчас добьём все сложные вопросы, а потом когда-нибудь займёмся длинным диалогом». +Правильнее так: + +**сейчас добивать сложные вычислительные вопросы — да, +но уже сейчас заложить минимальный каркас длинного диалога, чтобы потом не пришлось ломать всё заново.** + +То есть ответ не «сначала одно, потом другое», а: + +**основной фокус — на сложных доказуемых вопросах, +но с обязательной тонкой прослойкой state/navigation уже сейчас.** + +--- + +## Почему так + +У тебя сейчас по сути есть **три разных задачи**, и их нельзя смешивать: + +### 1. Доказательный вычислительный контур + +Это вопросы типа: + +* кому должны; +* кто должен нам; +* остатки; +* задолженность; +* обязательства на дату; +* НДС; +* обороты; +* сверки; +* причины расхождения. + +Тут нужен не “умный ассистент”, а **строгий финансовый движок ответа**. +LLM тут только: + +* понимает запрос, +* канонизирует, +* упаковывает ответ. + +### 2. Диалоговая навигация по результатам + +Это уже история: + +* покажи должников; +* открой первого; +* покажи документы по нему; +* открой вот этот документ; +* вернись к списку; +* теперь покажи только крупных; +* а теперь снова общий список. + +Это вообще другая задача. Это не про бухгалтерский расчёт, а про **сессионное состояние, ссылки на объекты и навигацию**. + +### 3. Разговорный слой + +Это: + +* как красиво формулируется ответ; +* как держится контекст; +* как не врёт; +* как не теряет тему; +* как уточняет. + +Если не разделить эти три задачи, начнётся каша: + +* вычислительные баги будете лечить промптом; +* диалоговые провалы — данными; +* архитектурные проблемы — текстом ответа. + +--- + +## Что у вас уже сделано правильно + +Судя по описанию, вы уже сделали важную правильную вещь: +вы ушли от «говно-MVP на if-ах» к более нормальной оркестрации и явным контрактам. + +Это правильно. + +Потому что без этого дальше было бы бессмысленно развивать сложные вопросы. + +--- + +## Где сейчас опасность + +Опасность у вас не в том, что вы пошли в сложные вопросы. +Опасность в другом: + +**вы можете начать чинить сложные кейсы так, что они размоют рабочие простые кейсы.** + +Это самая частая проблема. + +То есть: + +* новый route ради сложного кейса начинает перехватывать старые запросы; +* новая нормализация ломает короткие вопросы; +* новый orchestration layer делает хуже там, где раньше всё было стабильно; +* deep/fallback начинает срабатывать там, где раньше был простой exact answer. + +Поэтому тебе сейчас нельзя работать по принципу: + +> “ну сейчас доделаем тяжёлые вопросы, а там посмотрим” + +Нужен другой режим: + +> **сложные кейсы добавляются как отдельный слой, не ломая работающий baseline.** + +--- + +## Самый правильный способ двигаться сейчас + +### Главный принцип: + +**не делать “общую умность”, а делать “изолированные доказательные capability-маршруты”.** + +То есть не надо пытаться “в целом сделать ассистент умнее”. +Надо делать конкретные capability-блоки: + +* confirmed_payables_as_of_date +* confirmed_receivables_as_of_date +* vat_obligation_breakdown +* account_balance_as_of_date +* document_chain_explainer +* debtor_detail_drilldown +* settlement_reconciliation_trace + +И каждый из них: + +* имеет входной контракт, +* имеет data-route, +* имеет свой evidence model, +* имеет свои acceptance tests. + +Это намного стабильнее, чем пытаться “допилить общий интеллект”. + +--- + +## Можно ли сейчас фокусироваться на сложных вопросах, а длинные цепочки делать потом + +**Да, но только частично.** + +Правильный ответ такой: + +### Что можно отложить + +Можно отложить: + +* полноценную богатую conversational UX-историю; +* красивые возвращения по веткам; +* умные эллипсисы типа “тот же документ, что мы обсуждали раньше”; +* сложные нелинейные прыжки по истории. + +### Что нельзя откладывать + +Нельзя откладывать: + +* **модель состояния результата**; +* **идентификаторы result set’ов**; +* **идентификаторы выбранных объектов**; +* **контекст фокуса**; +* **операции drilldown / back / reopen / refine**. + +Потому что если этого не заложить сейчас, потом всё придётся перепахивать. + +--- + +## Очень важная мысль + +Длинный диалог в вашем кейсе — это **не “LLM держит контекст”**. + +Это вообще не об этом. + +Это должно быть не: + +* “модель помнит, о чём говорили”, + +а: + +* “система хранит конкретный объектный state разговора”. + +Например: + +### После запроса “покажи должников” + +создаётся объект: + +* `result_set_id = debtors_2020_05_v1` +* тип: `receivables_list` +* дата среза +* фильтры +* сортировка +* список entity_id + +### Потом “открой второго” + +создаётся: + +* `focus_object = counterparty:gamma_mebel` +* `parent_result_set = debtors_2020_05_v1` + +### Потом “покажи документы по нему” + +создаётся: + +* `result_set_id = docs_for_gamma_mebel_2020_05_v1` +* тип: `document_list` +* parent focus: `counterparty:gamma_mebel` + +### Потом “вернись к списку должников” + +это не магия модели, а явное: + +* restore `result_set_id = debtors_2020_05_v1` + +Вот тогда это работает как система. +А не как “LLM вроде бы держит мысль”. + +--- + +## Поэтому правильная стратегия у вас должна быть двухконтурной + +## Контур А — основной: сложные доказательные вопросы + +Это ваш главный приоритет прямо сейчас. + +Потому что если ассистент не умеет честно отвечать на: + +* кому должны, +* кто должен нам, +* сколько НДС, +* какие обязательства открыты, +* почему расхождение, + +то всё остальное рано или поздно бессмысленно. + +Здесь надо добивать **exact analytical routes**. + +## Контур Б — минимальный state/navigation framework + +Не весь длинный диалог целиком, а минимальный каркас. + +То есть прямо сейчас нужно ввести хотя бы: + +* `conversation_state` +* `current_focus` +* `result_sets` +* `drilldown_history` +* `resolvable references` + типа “этот контрагент”, “первый”, “тот документ”, “верни прошлый список” + +Без этого потом многоуровневые кейсы будут собираться заново криво. + +--- + +## Как бы я выстроил разработку по этапам + +## Этап 1. Заморозить baseline + +Перед любыми тяжёлыми доработками нужно зафиксировать текущий рабочий контур: + +* список простых запросов, которые уже работают; +* их эталонные ответы; +* их route expectations; +* их допустимые форматы; +* их accuracy baseline. + +Иначе вы не заметите, как сложные доработки сожрут простые кейсы. + +То есть нужен **golden baseline suite**. + +Прям обязательно: + +* 30–50 простых сценариев; +* 10–20 средних; +* 5–10 сложных. + +И для каждого: + +* intent; +* expected route; +* required evidence; +* must_not_happen; +* acceptance criteria. + +--- + +## Этап 2. Выделить отдельный слой exact analytical capabilities + +Не один “универсальный роут”, а capability registry. + +Например: + +* `confirmed_payables_as_of_date` +* `confirmed_receivables_as_of_date` +* `account_balance_as_of_date` +* `tax_obligation_snapshot` +* `document_trace_chain` +* `counterparty_reconciliation_detail` + +И каждый capability должен быть: + +* изолирован, +* версионирован, +* под rollout flag, +* с собственным eval suite. + +Это защитит простые кейсы. + +--- + +## Этап 3. Для сложных вопросов делать не prompt-tuning, а data-route design + +Вот тут очень важно. + +На текущем этапе вам не нужно “допилить LLM, чтобы она лучше поняла вопрос”. +Вам нужно: + +* описать доказательный объект ответа; +* понять, какие сущности для него нужны; +* понять, где эти поля лежат; +* понять, как строится формула; +* понять, как выглядит traceability. + +То есть сложный вопрос нужно разбирать не как “сложный prompt”, а как: + +**какой exact business computation за ним стоит?** + +Например, вопрос: + +> кому должны на май 2020 + +разбирается как: + +* дата среза, +* открытые обязательства, +* группировка по контрагенту, +* классификация обязательств, +* исключение закрытых, +* доказательная цепочка. + +Вот это правильный уровень. + +--- + +## Этап 4. Уже сейчас ввести минимальный state model для диалога + +Не надо пока делать “идеальный длинный разговор”. +Но нужно завести базовые сущности: + +### `session_context` + +* active_result_set_id +* active_focus_object +* last_confirmed_route +* date_scope +* organization_scope + +### `result_set` + +* id +* type +* route_id +* filters +* sort +* entity_refs[] +* source_refs[] +* created_from_turn + +### `focus_object` + +* object_type +* object_id +* label +* provenance_result_set_id + +### `navigation_event` + +* action: open / refine / back / compare / reset +* source_result_set_id +* target_object_id +* derived_result_set_id + +Тогда потом длинный диалог нарастает нормально. + +--- + +## Этап 5. Сделать 2–3 эталонные длинные цепочки, а не весь “длинный диалог” + +Вот это важный баланс. + +Не надо сейчас пытаться закрыть **все** длинные диалоги. +Нужно взять 2–3 ключевые цепочки и довести их как вертикальные срезы. + +Например: + +### Цепочка 1 + +* покажи должников на дату +* открой крупнейшего +* покажи документы по нему +* открой документ +* покажи, чем закрывался +* вернись к списку + +### Цепочка 2 + +* кому должны на дату +* оставь только поставщиков +* покажи по конкретному контрагенту +* покажи основание долга +* покажи связанные оплаты +* верни сводку + +### Цепочка 3 + +* покажи НДС за период +* объясни расхождение +* открой документы-источники +* покажи корректировки +* вернись к агрегату + +Этого уже хватит, чтобы не строить всё вслепую. + +--- + +## На что я бы не тратил время сейчас + +Сейчас я бы **не** делал ставку на: + +* “сделаем модель ещё умнее, и она сама вытянет”; +* бесконечную шлифовку prose-ответов; +* тонкую косметику fallback-логики; +* попытку одной общей оркестрацией покрыть всё; +* богатую свободную болталку. + +Это всё вторично по сравнению с: + +* exact routes, +* state model, +* regression harness, +* evidence model. + +--- + +## Как не сломать простые кейсы + +Вот тут прям практический совет. + +### 1. Все новые маршруты — только за флагами + +Новый exact-route не должен сразу заменять старый baseline. + +Сначала: + +* old route remains default +* new route runs in shadow mode +* сравниваете ответы +* смотрите deltas +* только потом переводите на prod path + +### 2. Для каждого кейса фиксируйте expected route + +Например: + +* “остаток по счету” не должен внезапно уйти в deep-analysis; +* “кому должны” должен идти только в confirmed payables route; +* “покажи документы по этому контрагенту” — только в drilldown route. + +### 3. Отдельно меряйте регрессии + +Не только answer correctness, но и: + +* route correctness; +* evidence completeness; +* fallback frequency; +* overconfident wrong answers; +* broken referential follow-ups. + +--- + +## Самый правильный способ мыслить про ваш продукт сейчас + +Не как про “LLM-ассистента по бухгалтерии”, а как про: + +**доказательный финансовый query engine + conversational navigator поверх него** + +Вот это, по-моему, самая точная рамка. + +Потому что если мыслить “ассистентом”, вас всё время будет тянуть лечить вычислительные проблемы через LLM. +А если мыслить “доказательным query engine”, тогда всё встаёт на место: + +* LLM — вход и объяснение; +* engine — расчёт; +* state layer — навигация; +* evidence layer — доверие. + +--- + +## Мой прямой совет по порядку работ + +Я бы делал так: + +### Сейчас + +1. Зафиксировать baseline простых рабочих кейсов. +2. Выбрать 5–7 самых важных сложных управленческих вопросов. +3. Для каждого сделать exact business route. +4. Одновременно заложить минимальный result/state/navigation layer. +5. Прогнать 2–3 длинные эталонные цепочки. + +### Не сейчас + +* не пытаться закрыть весь длинный диалог целиком; +* не делать глобальную магическую “универсальную” оркестрацию; +* не лечить сложные кейсы только prompt engineering’ом. + +--- + +## Короткий ответ на твой главный вопрос + +**Да, фокус на сложных вопросах сейчас — правильный.** +Но **полностью откладывать длинные цепочки нельзя**. Их нужно не “доделывать потом”, а **заложить сейчас в виде минимальной объектной модели состояния и 2–3 эталонных навигационных сценариев**. + +То есть: + +**сначала не “сложные вопросы vs длинные диалоги”, +а “exact routes first, state skeleton in parallel, rich dialog later”.** + +Это, на мой взгляд, для вас сейчас самый здоровый путь. + +Могу дальше сразу разложить это в виде **конкретного roadmap-а по спринтам / этапам**, чтобы это можно было отдать в работу. diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 7e93dc1..b34cbec 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -745,6 +745,9 @@ function requiredFiltersByIntent(intent) { if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { return ["account", "as_of_date"]; } + if (intent === "payables_confirmed_as_of_date") { + return ["as_of_date"]; + } if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || intent === "list_contracts_by_counterparty") { @@ -756,7 +759,9 @@ function requiredFiltersByIntent(intent) { return []; } function usesAsOfPrimaryWindow(intent) { - return intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts"; + return (intent === "open_items_by_counterparty_or_contract" || + intent === "list_open_contracts" || + intent === "payables_confirmed_as_of_date"); } function extractAddressFilters(userMessage, intent) { const rawText = String(userMessage ?? "").trim(); @@ -916,7 +921,10 @@ function extractAddressFilters(userMessage, intent) { // - explicit as_of has priority; // - else use period_to boundary when provided; // - else default to today. - if ((intent === "account_balance_snapshot" || intent === "documents_forming_balance") && !filters.as_of_date) { + if ((intent === "account_balance_snapshot" || + intent === "documents_forming_balance" || + intent === "payables_confirmed_as_of_date") && + !filters.as_of_date) { if (filters.period_to) { filters.as_of_date = filters.period_to; warnings.push("as_of_date_derived_from_period_to"); diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 061ea57..b96eea3 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1225,11 +1225,12 @@ function resolveAddressIntent(userMessage) { } if (hasAny(text, PAYABLES_STRONG)) { const reasons = ["payables_signal_detected"]; - if (hasPayablesDebtLifecycleSignal(text)) { + const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text); + if (payablesDebtLifecycleSignal) { reasons.push("payables_debt_lifecycle_signal_detected"); } return { - intent: "list_payables_counterparties", + intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties", confidence: "high", reasons }; diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 5772b4a..fd5ba43 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -10,6 +10,7 @@ const composeStage_1 = require("./address_runtime/composeStage"); const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"]; const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1"; const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000; +const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200; const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000; const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000; const PARTY_ANCHOR_STOPWORDS = new Set([ @@ -345,6 +346,7 @@ function shouldAttemptCounterpartyCatalogResolution(intent, filters) { intent === "bank_operations_by_counterparty" || intent === "open_items_by_counterparty_or_contract" || intent === "list_payables_counterparties" || + intent === "payables_confirmed_as_of_date" || intent === "list_receivables_counterparties"); } async function resolveCounterpartyViaCatalog(anchorRaw) { @@ -628,6 +630,7 @@ function parseIsoDateUtcTimestamp(value) { function isCounterpartyRiskIntent(intent) { return (intent === "list_receivables_counterparties" || intent === "list_payables_counterparties" || + intent === "payables_confirmed_as_of_date" || intent === "list_open_contracts" || intent === "open_items_by_counterparty_or_contract"); } @@ -638,7 +641,9 @@ function isHeuristicCandidatesIntent(intent) { intent === "open_items_by_counterparty_or_contract"); } function isConfirmedBalanceIntent(intent) { - return intent === "account_balance_snapshot" || intent === "documents_forming_balance"; + return (intent === "account_balance_snapshot" || + intent === "documents_forming_balance" || + intent === "payables_confirmed_as_of_date"); } function resolveAsOfDateBasis(filters) { const asOfDate = normalizeAnalysisDateHint(filters.as_of_date); @@ -702,11 +707,12 @@ function deriveAddressResultSemantics(input) { }; } if (isConfirmedBalanceIntent(input.intent)) { + const balanceConfirmed = input.responseType !== "LIMITED_WITH_REASON"; return { requested_result_mode: requestedResultMode, result_mode: "confirmed_balance", evidence_strength: deriveAddressEvidenceStrength(input), - balance_confirmed: true, + balance_confirmed: balanceConfirmed, as_of_date_basis: asOfDateBasis ?? "period_end" }; } @@ -717,6 +723,62 @@ function deriveAddressResultSemantics(input) { } return {}; } +function mergeAddressResultSemantics(base, override) { + if (!override) { + return base; + } + return { + ...base, + ...(override.result_mode ? { result_mode: override.result_mode } : {}), + ...(override.evidence_strength ? { evidence_strength: override.evidence_strength } : {}), + ...(typeof override.balance_confirmed === "boolean" ? { balance_confirmed: override.balance_confirmed } : {}) + }; +} +function withConfirmedBalanceFallbackReason(reasons, requestedResultMode, semantics, baseResultMode) { + if (requestedResultMode !== "confirmed_balance") { + return reasons; + } + const effectiveResultMode = semantics?.result_mode ?? baseResultMode; + if (effectiveResultMode !== "heuristic_candidates") { + return reasons; + } + if (reasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) { + return reasons; + } + return [...reasons, "confirmed_balance_unavailable_fallback_to_heuristic_candidates"]; +} +function enforceStrictAccountScopeForIntent(plan, intent) { + if (intent !== "list_receivables_counterparties" || plan.account_scope_mode === "strict") { + return plan; + } + return { + ...plan, + account_scope_mode: "strict" + }; +} +function resolveExecutionFiltersForPayablesConfirmedBalance(filters, analysisDate) { + const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date); + const periodTo = normalizeAnalysisDateHint(filters.period_to); + const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null; + const executionFilters = { + ...filters + }; + if (derivedAsOf) { + executionFilters.as_of_date = derivedAsOf; + } + delete executionFilters.period_from; + delete executionFilters.period_to; + const limit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit) + ? Math.max(1, Math.trunc(executionFilters.limit)) + : null; + if (limit === null || limit < ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT) { + executionFilters.limit = Math.max(ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT, limit ?? 0); + } + return { + executionFilters, + asOfDerived: derivedAsOf + }; +} function resolveFutureGuardReferenceDate(analysisDate, filters) { if (analysisDate) { return analysisDate; @@ -1124,6 +1186,9 @@ function buildLimitedOffers(input) { if (input.intent === "list_receivables_counterparties") { offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76"); } + else if (input.intent === "payables_confirmed_as_of_date") { + offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76"); + } else if (input.intent === "list_payables_counterparties") { offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76"); } @@ -1165,7 +1230,8 @@ function buildLimitedIntentSignalLine(input) { open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.", list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", - list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов." + list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", + payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату." }; const byShape = { AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.", @@ -1278,7 +1344,7 @@ function composeLimitedReply(input) { if (offers.length > 0) { lines.push(`Что могу сделать сейчас: ${offers.join("; ")}.`); } - return lines.join("\n"); + return lines.join("\n\n"); } function buildLimitedExecutionResult(input) { const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters); @@ -1289,6 +1355,12 @@ function buildLimitedExecutionResult(input) { responseType: "LIMITED_WITH_REASON", rowsMatched: input.rowsMatched }); + const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters); + const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(input.reasons, requestedResultMode, undefined, resultSemantics.result_mode); + const reasons = input.intent.intent === "payables_confirmed_as_of_date" && + !reasonsWithConfirmedFallback.includes("exact_payables_mode_limited_response") + ? [...reasonsWithConfirmedFallback, "exact_payables_mode_limited_response"] + : reasonsWithConfirmedFallback; return { handled: true, reply_text: composeLimitedReply({ @@ -1341,7 +1413,7 @@ function buildLimitedExecutionResult(input) { response_type: "LIMITED_WITH_REASON", ...resultSemantics, limitations: input.limitations, - reasons: input.reasons + reasons } }; } @@ -1371,13 +1443,29 @@ class AddressQueryService { baseReasons.push("as_of_date_from_analysis_context"); } } + const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters); + const payablesConfirmedExecution = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") && + requestedResultMode === "confirmed_balance" + ? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) + : null; + const executionFilters = payablesConfirmedExecution?.executionFilters ?? filters.extracted_filters; + if (payablesConfirmedExecution?.asOfDerived && + !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) { + if (!filters.warnings.includes("as_of_date_derived_for_confirmed_payables")) { + filters.warnings.push("as_of_date_derived_for_confirmed_payables"); + } + if (!baseReasons.includes("as_of_date_derived_for_confirmed_payables")) { + baseReasons.push("as_of_date_derived_for_confirmed_payables"); + } + } const composeOptionsFromFilters = (filterSet) => ({ userMessage, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, - asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined + asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, + requestedResultMode }); - const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, filters.extracted_filters); + const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters); let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters); const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" && Array.isArray(intent.reasons) && @@ -1387,15 +1475,25 @@ class AddressQueryService { (intent.reasons.includes("payables_debt_lifecycle_signal_detected") || intent.reasons.includes("supplier_tail_risk_signal_detected") || intent.reasons.includes("payables_signal_detected")); - const recipeIntent = debtLifecycleReceivablesScenario || debtLifecyclePayablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent; - const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, filters.extracted_filters); - const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters); + const preferConfirmedBalanceForPayablesLifecycle = debtLifecyclePayablesScenario && requestedResultMode === "confirmed_balance"; + const recipeIntent = debtLifecycleReceivablesScenario + ? "open_items_by_counterparty_or_contract" + : debtLifecyclePayablesScenario && !preferConfirmedBalanceForPayablesLifecycle + ? "open_items_by_counterparty_or_contract" + : intent.intent; + const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, executionFilters); if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) { baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle"); } if (debtLifecyclePayablesScenario && recipeIntent !== intent.intent) { baseReasons.push("recipe_override_to_open_items_for_payables_debt_lifecycle"); } + if (preferConfirmedBalanceForPayablesLifecycle && !baseReasons.includes("confirmed_balance_attempt_for_payables_debt_lifecycle")) { + baseReasons.push("confirmed_balance_attempt_for_payables_debt_lifecycle"); + } + if (intent.intent === "payables_confirmed_as_of_date" && !baseReasons.includes("confirmed_balance_exact_payables_intent")) { + baseReasons.push("confirmed_balance_exact_payables_intent"); + } if (requestedResultMode === "confirmed_balance" && recipeIntent === "open_items_by_counterparty_or_contract" && !baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) { @@ -1513,17 +1611,21 @@ class AddressQueryService { } } } - let plan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters); + let effectiveRecipeId = recipeSelection.selected_recipe.recipe_id; + let plan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, executionFilters), intent.intent); let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ query: plan.query, limit: plan.limit }); + const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date"; 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); + (plan.recipe.recipe_id === "address_movements_receivables_v1" || + plan.recipe.recipe_id === "address_movements_payables_v1") && + isMissingSubcontoFieldError(mcp.error) && + allowOpenItemsFallbackForMissingSubconto) { + const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", executionFilters); if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) { - const fallbackPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, filters.extracted_filters); + const fallbackPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, executionFilters), intent.intent); const fallbackMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({ query: fallbackPlan.query, limit: fallbackPlan.limit @@ -1531,9 +1633,16 @@ class AddressQueryService { if (!fallbackMcp.error) { plan = fallbackPlan; mcp = fallbackMcp; + if (intent.intent === "list_payables_counterparties") { + effectiveRecipeId = fallbackSelection.selected_recipe.recipe_id; + } if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) { baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items"); } + if (intent.intent === "list_payables_counterparties" && + !baseReasons.includes("fallback_recipe_switched_to_open_items")) { + baseReasons.push("fallback_recipe_switched_to_open_items"); + } } else { if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { @@ -1547,6 +1656,14 @@ class AddressQueryService { } } } + if (mcp.error && + (plan.recipe.recipe_id === "address_movements_receivables_v1" || + plan.recipe.recipe_id === "address_movements_payables_v1") && + isMissingSubcontoFieldError(mcp.error) && + !allowOpenItemsFallbackForMissingSubconto && + !baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback")) { + baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback"); + } if (mcp.error) { const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); return buildLimitedExecutionResult({ @@ -1555,7 +1672,7 @@ class AddressQueryService { intent, filters: filters.extracted_filters, missingRequiredFilters: [], - selectedRecipe: recipeSelection.selected_recipe.recipe_id, + selectedRecipe: effectiveRecipeId, accountScopeMode: plan.account_scope_mode, anchor, mcpCallStatus: deriveMcpStageStatus({ @@ -1590,10 +1707,10 @@ class AddressQueryService { const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows; anchor = (0, resolveStage_1.refineAnchorFromRows)(anchor, normalizedRows); const filtersForMatching = anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved - ? { ...filters.extracted_filters, counterparty: anchor.anchor_value_resolved } + ? { ...executionFilters, counterparty: anchor.anchor_value_resolved } : anchor.anchor_type === "contract" && anchor.anchor_value_resolved - ? { ...filters.extracted_filters, contract: anchor.anchor_value_resolved } - : filters.extracted_filters; + ? { ...executionFilters, contract: anchor.anchor_value_resolved } + : executionFilters; const accountScopeAudit = buildAccountScopeAudit({ intent: intent.intent, filters: filtersForMatching, @@ -1635,7 +1752,7 @@ class AddressQueryService { const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors); const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors; if (recoveredRows.length > 0) { - const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters)); + const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows, composeOptionsFromFilters(executionFilters)); const recoveryReason = recoveredBankRows.length > 0 ? "contract_docs_recovered_via_bank_fallback" : "contract_docs_recovered_via_anchor_rows"; @@ -1656,7 +1773,7 @@ class AddressQueryService { detected_intent_confidence: intent.confidence, extracted_filters: filters.extracted_filters, missing_required_filters: [], - selected_recipe: recipeSelection.selected_recipe.recipe_id, + selected_recipe: effectiveRecipeId, mcp_call_status_legacy: toLegacyMcpStatus("matched_non_empty"), account_scope_mode: plan.account_scope_mode, account_scope_fallback_applied: accountScopeFallbackApplied, @@ -1684,15 +1801,15 @@ class AddressQueryService { runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", limited_reason_category: null, response_type: factual.responseType, - ...deriveAddressResultSemantics({ + ...mergeAddressResultSemantics(deriveAddressResultSemantics({ intent: intent.intent, - selectedRecipe: recipeSelection.selected_recipe.recipe_id, + selectedRecipe: effectiveRecipeId, filters: filters.extracted_filters, responseType: factual.responseType, rowsMatched: recoveredRows.length - }), + }), factual.semantics), limitations: [...filters.warnings, recoveryReason], - reasons: [...baseReasons, recoveryReason] + reasons: withConfirmedBalanceFallbackReason([...baseReasons, recoveryReason], requestedResultMode, factual.semantics) } }; } @@ -1702,12 +1819,12 @@ class AddressQueryService { (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)) + const currentLimit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit) + ? Math.max(1, Math.trunc(executionFilters.limit)) : plan.limit; if (currentLimit < ADDRESS_ANCHOR_RECOVERY_LIMIT) { const expandedLimitFilters = { - ...filters.extracted_filters, + ...executionFilters, limit: ADDRESS_ANCHOR_RECOVERY_LIMIT }; const expandedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, expandedLimitFilters); @@ -1807,15 +1924,15 @@ class AddressQueryService { runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", limited_reason_category: null, response_type: expandedFactual.responseType, - ...deriveAddressResultSemantics({ + ...mergeAddressResultSemantics(deriveAddressResultSemantics({ intent: intent.intent, selectedRecipe: expandedSelection.selected_recipe.recipe_id, filters: filters.extracted_filters, responseType: expandedFactual.responseType, rowsMatched: expandedFilteredRows.length - }), + }), expandedFactual.semantics), limitations: expandedLimitations, - reasons: expandedReasons + reasons: withConfirmedBalanceFallbackReason(expandedReasons, requestedResultMode, expandedFactual.semantics) } }; } @@ -1925,15 +2042,15 @@ class AddressQueryService { runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", limited_reason_category: null, response_type: broadenedFactual.responseType, - ...deriveAddressResultSemantics({ + ...mergeAddressResultSemantics(deriveAddressResultSemantics({ intent: intent.intent, selectedRecipe: broadenedSelection.selected_recipe.recipe_id, filters: filters.extracted_filters, responseType: broadenedFactual.responseType, rowsMatched: broadenedFilteredRows.length - }), + }), broadenedFactual.semantics), limitations: broadenedLimitations, - reasons: broadenedReasons + reasons: withConfirmedBalanceFallbackReason(broadenedReasons, requestedResultMode, broadenedFactual.semantics) } }; } @@ -2051,15 +2168,15 @@ class AddressQueryService { runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", limited_reason_category: null, response_type: historicalFactual.responseType, - ...deriveAddressResultSemantics({ + ...mergeAddressResultSemantics(deriveAddressResultSemantics({ intent: intent.intent, selectedRecipe: historicalSelection.selected_recipe.recipe_id, filters: filters.extracted_filters, responseType: historicalFactual.responseType, rowsMatched: historicalFilteredRows.length - }), + }), historicalFactual.semantics), limitations: historicalLimitations, - reasons: historicalReasons + reasons: withConfirmedBalanceFallbackReason(historicalReasons, requestedResultMode, historicalFactual.semantics) } }; } @@ -2073,7 +2190,7 @@ class AddressQueryService { (stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")) { const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows); if (documentBankFallbackRows.length > 0) { - const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, composeOptionsFromFilters(filters.extracted_filters)); + const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, composeOptionsFromFilters(executionFilters)); const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы."; const fallbackSuggestion = intent.intent === "list_documents_by_counterparty" ? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи." @@ -2094,7 +2211,7 @@ class AddressQueryService { detected_intent_confidence: intent.confidence, extracted_filters: filters.extracted_filters, missing_required_filters: [], - selected_recipe: recipeSelection.selected_recipe.recipe_id, + selected_recipe: effectiveRecipeId, mcp_call_status_legacy: "matched_non_empty", account_scope_mode: plan.account_scope_mode, account_scope_fallback_applied: accountScopeFallbackApplied, @@ -2122,15 +2239,15 @@ class AddressQueryService { runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", limited_reason_category: null, response_type: fallbackFactual.responseType, - ...deriveAddressResultSemantics({ + ...mergeAddressResultSemantics(deriveAddressResultSemantics({ intent: intent.intent, - selectedRecipe: recipeSelection.selected_recipe.recipe_id, + selectedRecipe: effectiveRecipeId, filters: filters.extracted_filters, responseType: fallbackFactual.responseType, rowsMatched: documentBankFallbackRows.length - }), + }), fallbackFactual.semantics), limitations: fallbackLimitations, - reasons: fallbackReasons + reasons: withConfirmedBalanceFallbackReason(fallbackReasons, requestedResultMode, fallbackFactual.semantics) } }; } @@ -2219,7 +2336,7 @@ class AddressQueryService { intent, filters: filters.extracted_filters, missingRequiredFilters: [], - selectedRecipe: recipeSelection.selected_recipe.recipe_id, + selectedRecipe: effectiveRecipeId, accountScopeMode: plan.account_scope_mode, accountScopeFallbackApplied, accountScopeAudit, @@ -2242,7 +2359,44 @@ class AddressQueryService { reasons: baseReasons }); } - const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, composeOptionsFromFilters(filters.extracted_filters)); + const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, composeOptionsFromFilters(executionFilters)); + const factualResultSemantics = mergeAddressResultSemantics(deriveAddressResultSemantics({ + intent: intent.intent, + selectedRecipe: effectiveRecipeId, + filters: filters.extracted_filters, + responseType: factual.responseType, + rowsMatched: filteredRows.length + }), factual.semantics); + if (intent.intent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) { + return buildLimitedExecutionResult({ + mode, + shape, + intent, + filters: filters.extracted_filters, + missingRequiredFilters: [], + selectedRecipe: effectiveRecipeId, + accountScopeMode: plan.account_scope_mode, + accountScopeFallbackApplied, + accountScopeAudit, + anchor, + matchFailureStage, + matchFailureReason, + mcpCallStatus: stageStatus, + rowsFetched: mcp.fetched_rows, + rawRowsReceived: mcp.raw_rows.length, + rowsAfterAccountScope: normalizedRows.length, + rowsAfterRecipeFilter: filterByAnchors.length, + rowsMaterialized: normalizedRows.length, + rowsMatched: filteredRows.length, + rawRowKeysSample: rowDiagnostics.rawRowKeysSample, + materializationDropReason: rowDiagnostics.materializationDropReason, + category: "recipe_visibility_gap", + reasonText: "exact payables mode: confirmed balance was not proven for the requested as-of slice", + nextStep: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", + limitations: ["exact_payables_mode_unconfirmed_output_blocked"], + reasons: [...baseReasons, "exact_payables_mode_unconfirmed_output_blocked"] + }); + } return { handled: true, reply_text: factual.text, @@ -2257,7 +2411,7 @@ class AddressQueryService { detected_intent_confidence: intent.confidence, extracted_filters: filters.extracted_filters, missing_required_filters: [], - selected_recipe: recipeSelection.selected_recipe.recipe_id, + selected_recipe: effectiveRecipeId, mcp_call_status_legacy: toLegacyMcpStatus(stageStatus), account_scope_mode: plan.account_scope_mode, account_scope_fallback_applied: accountScopeFallbackApplied, @@ -2285,15 +2439,9 @@ class AddressQueryService { runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", limited_reason_category: null, response_type: factual.responseType, - ...deriveAddressResultSemantics({ - intent: intent.intent, - selectedRecipe: recipeSelection.selected_recipe.recipe_id, - filters: filters.extracted_filters, - responseType: factual.responseType, - rowsMatched: filteredRows.length - }), + ...factualResultSemantics, limitations: filters.warnings, - reasons: baseReasons + reasons: withConfirmedBalanceFallbackReason(baseReasons, requestedResultMode, factual.semantics, factualResultSemantics.result_mode) } }; } diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index f1e6643..f3dfd0f 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -9,7 +9,13 @@ const MOVEMENTS_QUERY_TEMPLATE = ` ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор, ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт, ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт, - Движения.Сумма КАК Сумма + Движения.Сумма КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1, + ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2, + ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3 ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения __WHERE_CLAUSE__ @@ -519,6 +525,16 @@ const BASE_RECIPES = [ account_scope: ["60", "76"], account_scope_mode: "strict" }, + { + recipe_id: "address_payables_confirmed_as_of_date_v1", + intent: "payables_confirmed_as_of_date", + purpose: "Build confirmed payables snapshot as-of date from movements on accounts 60/76", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], + default_limit: 200, + account_scope: ["60", "76"], + account_scope_mode: "strict" + }, { recipe_id: "address_movements_receivables_v1", intent: "list_receivables_counterparties", diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 41e01fd..f599429 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -508,11 +508,11 @@ function classifyPayablesLiabilityCategory(row, counterparty) { scores.supplier_or_contractor += 1; reasons.add("участие счета 76"); } - if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|loan|overdraft)/iu.test(text)) { + if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit)/iu.test(text)) { scores.bank_or_credit += 3; reasons.add("банк/кредит в аналитике"); } - if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос)/iu.test(text)) { + if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал)/iu.test(text)) { scores.tax_or_state += 3; reasons.add("налог/госорган в аналитике"); } @@ -525,6 +525,42 @@ function classifyPayablesLiabilityCategory(row, counterparty) { reasons: Array.from(reasons) }; } +const PAYABLES_CATEGORY_KEYS = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"]; +function resolvePayablesLiabilityCategory(scores) { + let winner = "other"; + let best = Number.NEGATIVE_INFINITY; + for (const key of PAYABLES_CATEGORY_KEYS) { + const score = scores[key]; + if (score > best) { + best = score; + winner = key; + } + } + if (best <= 0) { + return "other"; + } + return winner; +} +function hasPayablesSectionPrefix(account) { + const section = extractAccountSectionCode(account); + return section === "60" || section === "76"; +} +function resolvePayablesAsOfDate(options) { + const explicit = normalizeIsoDateOnly(options.asOfDate); + if (explicit) { + return explicit; + } + const periodTo = normalizeIsoDateOnly(options.periodTo); + if (periodTo) { + return periodTo; + } + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + if (periodFrom) { + return periodFrom; + } + const now = new Date(); + return toIsoDate(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate()); +} function buildPayablesCounterpartyRiskAggregate(rows) { const byCounterparty = new Map(); for (const row of rows) { @@ -574,26 +610,10 @@ function buildPayablesCounterpartyRiskAggregate(rows) { current.reasons.add(reason); } } - const scoreKeys = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"]; - const toCategory = (scores) => { - let winner = "other"; - let best = Number.NEGATIVE_INFINITY; - for (const key of scoreKeys) { - const score = scores[key]; - if (score > best) { - best = score; - winner = key; - } - } - if (best <= 0) { - return "other"; - } - return winner; - }; return Array.from(byCounterparty.values()) .map((item) => ({ ...item.base, - category: toCategory(item.categoryScores), + category: resolvePayablesLiabilityCategory(item.categoryScores), categoryReasons: Array.from(item.reasons).slice(0, 2) })) .sort((left, right) => { @@ -606,6 +626,88 @@ function buildPayablesCounterpartyRiskAggregate(rows) { return left.name.localeCompare(right.name); }); } +function buildPayablesConfirmedBalanceAggregate(rows, asOfDate) { + const byCounterparty = new Map(); + const asOfTimestamp = toUtcDayTimestamp(asOfDate); + for (const row of rows) { + const name = extractCounterpartyName(row); + if (!name) { + continue; + } + const rowTimestamp = toUtcDayTimestamp(row.period); + if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) { + continue; + } + const amount = row.amount; + if (typeof amount !== "number" || !Number.isFinite(amount)) { + continue; + } + const absAmount = Math.abs(amount); + let delta = 0; + if (hasPayablesSectionPrefix(row.account_kt)) { + delta += absAmount; + } + if (hasPayablesSectionPrefix(row.account_dt)) { + delta -= absAmount; + } + if (Math.abs(delta) <= 0.0000001) { + continue; + } + const classified = classifyPayablesLiabilityCategory(row, name); + const current = byCounterparty.get(name); + if (!current) { + byCounterparty.set(name, { + outstandingAmount: delta, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period, + categoryScores: { + supplier_or_contractor: classified.scores.supplier_or_contractor, + bank_or_credit: classified.scores.bank_or_credit, + tax_or_state: classified.scores.tax_or_state, + other: classified.scores.other + }, + reasons: new Set(classified.reasons) + }); + continue; + } + current.outstandingAmount += delta; + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + current.categoryScores.supplier_or_contractor += classified.scores.supplier_or_contractor; + current.categoryScores.bank_or_credit += classified.scores.bank_or_credit; + current.categoryScores.tax_or_state += classified.scores.tax_or_state; + current.categoryScores.other += classified.scores.other; + for (const reason of classified.reasons) { + current.reasons.add(reason); + } + } + return Array.from(byCounterparty.entries()) + .map(([name, item]) => ({ + name, + outstandingAmount: item.outstandingAmount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + category: resolvePayablesLiabilityCategory(item.categoryScores), + categoryReasons: Array.from(item.reasons).slice(0, 2) + })) + .filter((item) => item.outstandingAmount > 0.005) + .sort((left, right) => { + if (right.outstandingAmount !== left.outstandingAmount) { + return right.outstandingAmount - left.outstandingAmount; + } + if (right.operations !== left.operations) { + return right.operations - left.operations; + } + return left.name.localeCompare(right.name); + }); +} function buildCounterpartyRiskAggregate(rows) { const byCounterparty = new Map(); for (const row of rows) { @@ -1663,61 +1765,206 @@ function composeFactualReply(intent, rows, options = {}) { text: lines.join("\n") }; } - if (intent === "list_payables_counterparties") { - const counterparties = buildPayablesCounterpartyRiskAggregate(rows); - const scopeLine = (() => { - const asOfDate = normalizeIsoDateOnly(options.asOfDate); - if (asOfDate) { - return `Дата среза: ${formatDateRu(asOfDate)}.`; - } - const periodFrom = normalizeIsoDateOnly(options.periodFrom); - const periodTo = normalizeIsoDateOnly(options.periodTo); - if (periodFrom || periodTo) { - return `Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`; - } - return null; - })(); + if (intent === "payables_confirmed_as_of_date") { + const payablesAsOfDate = resolvePayablesAsOfDate(options); + const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate); + const asOfDate = normalizeIsoDateOnly(options.asOfDate); + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + const periodTo = normalizeIsoDateOnly(options.periodTo); + const scopeLine = asOfDate + ? `- Дата среза: ${formatDateRu(asOfDate)}.` + : periodFrom || periodTo + ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` + : null; + const carryoverLine = asOfDate || periodFrom || periodTo + ? "- В срез могут входить обязательства, возникшие до периода, если они оставались открытыми на дату среза." + : null; + const categoryCounts = confirmedBalances.reduce((acc, item) => { + acc[item.category] += 1; + return acc; + }, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }); const lines = [ - "Коротко: собран shortlist кандидатов на ручную проверку по потенциально незакрытым обязательствам (контур 60/76).", - "", - "Что это значит:", - "- Режим результата: эвристический скоринг по движениям.", - "- Это не финальный подтвержденный остаток к оплате.", - ...(scopeLine ? ["", scopeLine] : []), - "", - `Строк в выборке: ${rows.length}.`, - `Контрагентов-кандидатов: ${counterparties.length}.` + "Блок 1. Статус результата", + "- Режим результата: подтвержденный срез обязательств к оплате (exact route).", + "- Эвристический shortlist в этом режиме не используется." ]; - if (counterparties.length > 0) { - const categoryCounts = counterparties.reduce((acc, item) => { - acc[item.category] += 1; - return acc; - }, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }); - lines.push(""); - lines.push("Категории обязательств:"); - lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`); - lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`); - lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`); - lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`); - lines.push(""); - lines.push("Приоритет ручной проверки (по сумме/частоте сигналов):"); - lines.push(...counterparties - .slice(0, 8) - .map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""} | статус: требует ручной проверки${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`)); - lines.push(""); - lines.push("Примеры исходных строк:"); - lines.push(...formatTopRows(rows, 4)); + lines.push(""); + lines.push("Блок 2. Что учтено"); + lines.push(`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`); + if (scopeLine) { + lines.push(scopeLine); + } + lines.push("- Контур: обязательства по счетам 60/76."); + if (carryoverLine) { + lines.push(carryoverLine); + } + lines.push(""); + lines.push("Блок 3. Сводка"); + lines.push(`- Строк в выборке: ${rows.length}.`); + lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${confirmedBalances.length}.`); + lines.push(""); + lines.push("Блок 4. Категории обязательств"); + lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`); + lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`); + lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`); + lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`); + lines.push(""); + lines.push("Блок 5. Подтвержденные позиции к оплате"); + if (confirmedBalances.length > 0) { + lines.push(...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`)); } else { - lines.push(""); - lines.push("Явных кандидатов на незакрытые обязательства по текущему срезу не найдено."); - lines.push(""); - lines.push("Примеры исходных строк:"); - lines.push(...formatTopRows(rows, 6)); + lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено."); } + return { + responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: lines.join("\n"), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", + balance_confirmed: true + } + }; + } + if (intent === "list_payables_counterparties") { + const counterparties = buildPayablesCounterpartyRiskAggregate(rows); + const payablesAsOfDate = resolvePayablesAsOfDate(options); + const asOfDate = normalizeIsoDateOnly(options.asOfDate); + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + const periodTo = normalizeIsoDateOnly(options.periodTo); + const scopeLine = asOfDate + ? `- Дата среза: ${formatDateRu(asOfDate)}.` + : periodFrom || periodTo + ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` + : null; + const carryoverLine = asOfDate || periodFrom || periodTo + ? "- В список могут попадать обязательства, возникшие раньше выбранного периода, если они потенциально оставались открытыми на дату среза." + : null; + const formatHeuristicItem = (item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`; + const pushCategorySlice = (lines, title, items, limit) => { + if (items.length === 0) { + return; + } + lines.push(""); + lines.push(title); + lines.push(...items.slice(0, limit).map(formatHeuristicItem)); + }; + const buildHeuristicLines = (forcedFallbackFromConfirmed) => { + const lines = [ + "Блок 1. Статус результата", + forcedFallbackFromConfirmed + ? "- Режим результата: эвристический скоринг в рамках fallback, потому что подтвержденный срез обязательств к оплате недоступен." + : "- Режим результата: эвристический скоринг (shortlist кандидатов по признакам незакрытых обязательств в контуре 60/76).", + "- Тип результата: кандидаты для ручной проверки, а не финальный платежный реестр.", + "", + "Блок 2. Как читать результат", + "- Это shortlist кандидатов: нужна ручная проверка бухгалтером.", + "- Это не подтвержденный остаток к оплате и не готовое платежное поручение.", + ...(scopeLine ? [scopeLine] : []), + ...(carryoverLine ? [carryoverLine] : []), + "", + "Блок 3. Сводка выборки", + `- Строк в выборке: ${rows.length}.`, + `- Контрагентов-кандидатов: ${counterparties.length}.` + ]; + if (counterparties.length > 0) { + const categoryCounts = counterparties.reduce((acc, item) => { + acc[item.category] += 1; + return acc; + }, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }); + const suppliers = counterparties.filter((item) => item.category === "supplier_or_contractor"); + const banks = counterparties.filter((item) => item.category === "bank_or_credit"); + const taxOrState = counterparties.filter((item) => item.category === "tax_or_state"); + const other = counterparties.filter((item) => item.category === "other"); + lines.push(""); + lines.push("Блок 4. Категории обязательств"); + lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`); + lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`); + lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`); + lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`); + lines.push(""); + lines.push("Блок 5. Кандидаты на проверку в первую очередь"); + pushCategorySlice(lines, "5.1 Поставщики/подрядчики:", suppliers, 6); + pushCategorySlice(lines, "5.2 Банки/кредиты:", banks, 4); + pushCategorySlice(lines, "5.3 Налоги/госорганы:", taxOrState, 4); + pushCategorySlice(lines, "5.4 Прочие:", other, 4); + lines.push(""); + lines.push("Блок 6. Примеры исходных строк"); + lines.push(...formatTopRows(rows, 4)); + } + else { + lines.push(""); + lines.push("Блок 4. Категории обязательств"); + lines.push("- Явных кандидатов на незакрытые обязательства по доступному срезу не найдено."); + lines.push(""); + lines.push("Блок 5. Примеры исходных строк"); + lines.push(...formatTopRows(rows, 6)); + } + return lines; + }; + if (options.requestedResultMode === "confirmed_balance") { + const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate); + if (confirmedBalances.length > 0) { + const categoryCounts = confirmedBalances.reduce((acc, item) => { + acc[item.category] += 1; + return acc; + }, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }); + const lines = [ + "Блок 1. Статус результата", + "- Режим результата: подтвержденный срез обязательств к оплате по состоянию на дату среза в контуре 60/76.", + "- Тип результата: подтвержденные остатки к оплате.", + "", + "Блок 2. Что учтено", + `- Дата среза: ${formatDateRu(payablesAsOfDate)}.`, + ...(periodFrom || periodTo + ? [`- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`] + : []), + "- Основание: движения обязательств и оплат в пределах доступного live-среза.", + ...(carryoverLine ? [carryoverLine] : []), + "", + "Блок 3. Сводка выборки", + `- Строк в выборке: ${rows.length}.`, + `- Контрагентов с подтвержденным остатком: ${confirmedBalances.length}.`, + "", + "Блок 4. Категории обязательств", + `- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`, + `- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`, + `- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`, + `- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`, + "", + "Блок 5. Кому нужно заплатить в первую очередь (по сумме остатка):", + ...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`) + ]; + return { + responseType: "FACTUAL_LIST", + text: lines.join("\n"), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: "strong", + balance_confirmed: true + } + }; + } + const fallbackLines = buildHeuristicLines(true); + return { + responseType: "FACTUAL_LIST", + text: fallbackLines.join("\n"), + semantics: { + result_mode: "heuristic_candidates", + evidence_strength: counterparties.length > 0 ? "medium" : "weak", + balance_confirmed: false + } + }; + } + const lines = buildHeuristicLines(false); return { responseType: "FACTUAL_LIST", - text: lines.join("\n") + text: lines.join("\n"), + semantics: { + result_mode: "heuristic_candidates", + evidence_strength: counterparties.length > 0 ? "medium" : "weak", + balance_confirmed: false + } }; } if (intent === "list_receivables_counterparties") { diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 4af7477..efddc46 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -350,7 +350,9 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { } } } - if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") { + if (intent === "open_items_by_counterparty_or_contract" || + intent === "list_open_contracts" || + intent === "payables_confirmed_as_of_date") { const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const currentContract = toNonEmptyString(merged.contract); const shouldInheritContract = !currentContract || @@ -370,6 +372,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { merged.counterparty = inheritedCounterparty; reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context"); } + if (sameDateRequested) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } + } } if (allTimeRequested) { if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) { @@ -424,6 +433,7 @@ function resolveMissingRequiredFilters(intent, filters) { const requiredByIntent = { account_balance_snapshot: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"], + payables_confirmed_as_of_date: ["as_of_date"], list_documents_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"], diff --git a/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js b/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js index 6a33a7c..fe4fb4f 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js +++ b/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js @@ -91,7 +91,9 @@ function inferAggregationProfile(intent, shape) { intent === "vat_payable_forecast") { return "management_profile"; } - if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { + if (intent === "account_balance_snapshot" || + intent === "documents_forming_balance" || + intent === "payables_confirmed_as_of_date") { return "balance_snapshot"; } if (intent === "open_items_by_counterparty_or_contract" || diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 5662e07..de8365f 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -3695,6 +3695,7 @@ function hasOpenContractsAddressSignal(text) { const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "list_open_contracts", "open_items_by_counterparty_or_contract", + "payables_confirmed_as_of_date", "list_documents_by_contract", "bank_operations_by_contract", "list_documents_by_counterparty", diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 4bde8f9..729d7ba 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -837,6 +837,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array> = { @@ -1655,7 +1665,7 @@ function composeLimitedReply(input: { lines.push(`Что могу сделать сейчас: ${offers.join("; ")}.`); } - return lines.join("\n"); + return lines.join("\n\n"); } function buildLimitedExecutionResult(input: { @@ -1701,12 +1711,17 @@ function buildLimitedExecutionResult(input: { rowsMatched: input.rowsMatched }); const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters); - const reasons = withConfirmedBalanceFallbackReason( + const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason( input.reasons, requestedResultMode, undefined, resultSemantics.result_mode ); + const reasons = + input.intent.intent === "payables_confirmed_as_of_date" && + !reasonsWithConfirmedFallback.includes("exact_payables_mode_limited_response") + ? [...reasonsWithConfirmedFallback, "exact_payables_mode_limited_response"] + : reasonsWithConfirmedFallback; return { handled: true, reply_text: composeLimitedReply({ @@ -1795,7 +1810,8 @@ export class AddressQueryService { } const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters); const payablesConfirmedExecution = - intent.intent === "list_payables_counterparties" && requestedResultMode === "confirmed_balance" + (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") && + requestedResultMode === "confirmed_balance" ? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) : null; const executionFilters = payablesConfirmedExecution?.executionFilters ?? filters.extracted_filters; @@ -1846,6 +1862,9 @@ export class AddressQueryService { if (preferConfirmedBalanceForPayablesLifecycle && !baseReasons.includes("confirmed_balance_attempt_for_payables_debt_lifecycle")) { baseReasons.push("confirmed_balance_attempt_for_payables_debt_lifecycle"); } + if (intent.intent === "payables_confirmed_as_of_date" && !baseReasons.includes("confirmed_balance_exact_payables_intent")) { + baseReasons.push("confirmed_balance_exact_payables_intent"); + } if ( requestedResultMode === "confirmed_balance" && recipeIntent === "open_items_by_counterparty_or_contract" && @@ -1982,11 +2001,13 @@ export class AddressQueryService { query: plan.query, limit: plan.limit }); + const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date"; if ( mcp.error && (plan.recipe.recipe_id === "address_movements_receivables_v1" || plan.recipe.recipe_id === "address_movements_payables_v1") && - isMissingSubcontoFieldError(mcp.error) + isMissingSubcontoFieldError(mcp.error) && + allowOpenItemsFallbackForMissingSubconto ) { const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", executionFilters); if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) { @@ -2019,11 +2040,21 @@ export class AddressQueryService { } } } else { - if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) { - baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable"); - } + if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) { + baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable"); + } } } + if ( + mcp.error && + (plan.recipe.recipe_id === "address_movements_receivables_v1" || + plan.recipe.recipe_id === "address_movements_payables_v1") && + isMissingSubcontoFieldError(mcp.error) && + !allowOpenItemsFallbackForMissingSubconto && + !baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback") + ) { + baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback"); + } if (mcp.error) { const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); @@ -2846,6 +2877,36 @@ export class AddressQueryService { }), factual.semantics ); + if (intent.intent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) { + return buildLimitedExecutionResult({ + mode, + shape, + intent, + filters: filters.extracted_filters, + missingRequiredFilters: [], + selectedRecipe: effectiveRecipeId, + accountScopeMode: plan.account_scope_mode, + accountScopeFallbackApplied, + accountScopeAudit, + anchor, + matchFailureStage, + matchFailureReason, + mcpCallStatus: stageStatus, + rowsFetched: mcp.fetched_rows, + rawRowsReceived: mcp.raw_rows.length, + rowsAfterAccountScope: normalizedRows.length, + rowsAfterRecipeFilter: filterByAnchors.length, + rowsMaterialized: normalizedRows.length, + rowsMatched: filteredRows.length, + rawRowKeysSample: rowDiagnostics.rawRowKeysSample, + materializationDropReason: rowDiagnostics.materializationDropReason, + category: "recipe_visibility_gap", + reasonText: "exact payables mode: confirmed balance was not proven for the requested as-of slice", + nextStep: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", + limitations: ["exact_payables_mode_unconfirmed_output_blocked"], + reasons: [...baseReasons, "exact_payables_mode_unconfirmed_output_blocked"] + }); + } return { handled: true, reply_text: factual.text, diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 5989ae8..5fce409 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -540,6 +540,16 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope: ["60", "76"], account_scope_mode: "strict" }, + { + recipe_id: "address_payables_confirmed_as_of_date_v1", + intent: "payables_confirmed_as_of_date", + purpose: "Build confirmed payables snapshot as-of date from movements on accounts 60/76", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], + default_limit: 200, + account_scope: ["60", "76"], + account_scope_mode: "strict" + }, { recipe_id: "address_movements_receivables_v1", intent: "list_receivables_counterparties", diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index a4e80bc..f119748 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -858,7 +858,7 @@ function buildPayablesConfirmedBalanceAggregate( continue; } const amount = row.amount; - if (!Number.isFinite(amount)) { + if (typeof amount !== "number" || !Number.isFinite(amount)) { continue; } const absAmount = Math.abs(amount); @@ -2252,6 +2252,82 @@ export function composeFactualReply( }; } + if (intent === "payables_confirmed_as_of_date") { + const payablesAsOfDate = resolvePayablesAsOfDate(options); + const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate); + const asOfDate = normalizeIsoDateOnly(options.asOfDate); + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + const periodTo = normalizeIsoDateOnly(options.periodTo); + const scopeLine = asOfDate + ? `- Дата среза: ${formatDateRu(asOfDate)}.` + : periodFrom || periodTo + ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` + : null; + const carryoverLine = + asOfDate || periodFrom || periodTo + ? "- В срез могут входить обязательства, возникшие до периода, если они оставались открытыми на дату среза." + : null; + const categoryCounts = confirmedBalances.reduce>( + (acc, item) => { + acc[item.category] += 1; + return acc; + }, + { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 } + ); + + const lines: string[] = [ + "Блок 1. Статус результата", + "- Режим результата: подтвержденный срез обязательств к оплате (exact route).", + "- Эвристический shortlist в этом режиме не используется." + ]; + + lines.push(""); + lines.push("Блок 2. Что учтено"); + lines.push(`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`); + if (scopeLine) { + lines.push(scopeLine); + } + lines.push("- Контур: обязательства по счетам 60/76."); + if (carryoverLine) { + lines.push(carryoverLine); + } + + lines.push(""); + lines.push("Блок 3. Сводка"); + lines.push(`- Строк в выборке: ${rows.length}.`); + lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${confirmedBalances.length}.`); + + lines.push(""); + lines.push("Блок 4. Категории обязательств"); + lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`); + lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`); + lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`); + lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`); + + lines.push(""); + lines.push("Блок 5. Подтвержденные позиции к оплате"); + if (confirmedBalances.length > 0) { + lines.push( + ...confirmedBalances.slice(0, 10).map( + (item, index) => + `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}` + ) + ); + } else { + lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено."); + } + + return { + responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: lines.join("\n"), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", + balance_confirmed: true + } + }; + } + if (intent === "list_payables_counterparties") { const counterparties = buildPayablesCounterpartyRiskAggregate(rows); const payablesAsOfDate = resolvePayablesAsOfDate(options); diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 827c1e0..34ae88e 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -439,7 +439,11 @@ function mergeFollowupFilters( } } - if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") { + if ( + intent === "open_items_by_counterparty_or_contract" || + intent === "list_open_contracts" || + intent === "payables_confirmed_as_of_date" + ) { const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const currentContract = toNonEmptyString(merged.contract); const shouldInheritContract = @@ -462,6 +466,13 @@ function mergeFollowupFilters( merged.counterparty = inheritedCounterparty; reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context"); } + if (sameDateRequested) { + const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; + if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) { + merged.as_of_date = inheritedAsOfDate; + reasons.push("as_of_date_from_followup_context"); + } + } } if (allTimeRequested) { @@ -525,6 +536,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi const requiredByIntent: Record> = { account_balance_snapshot: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"], + payables_confirmed_as_of_date: ["as_of_date"], list_documents_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"], diff --git a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts index f43d50f..046353c 100644 --- a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts +++ b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts @@ -189,7 +189,11 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape return "management_profile"; } - if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { + if ( + intent === "account_balance_snapshot" || + intent === "documents_forming_balance" || + intent === "payables_confirmed_as_of_date" + ) { return "balance_snapshot"; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 7636de7..98ecb9f 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -3652,6 +3652,7 @@ function hasOpenContractsAddressSignal(text) { const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "list_open_contracts", "open_items_by_counterparty_or_contract", + "payables_confirmed_as_of_date", "list_documents_by_contract", "bank_operations_by_contract", "list_documents_by_counterparty", diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index a589fbb..2d36b01 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -13,6 +13,7 @@ export type AddressIntent = | "list_contracts_by_counterparty" | "list_open_contracts" | "list_payables_counterparties" + | "payables_confirmed_as_of_date" | "list_receivables_counterparties" | "account_balance_snapshot" | "open_items_by_counterparty_or_contract" diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 10ac9ea..b984dd9 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -1832,7 +1832,7 @@ describe("address intent resolver expansion (M2.3a)", () => { it("marks 'кому мы должны заплатить' as payables debt lifecycle intent", () => { const result = resolveAddressIntent("каму мы должны заплатить за май 2020"); - expect(result.intent).toBe("list_payables_counterparties"); + expect(result.intent).toBe("payables_confirmed_as_of_date"); expect(result.reasons).toContain("payables_debt_lifecycle_signal_detected"); }); @@ -2494,30 +2494,31 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); - it("routes 'каму мы должны заплатить за май 2020' into confirmed payables flow with explicit fallback contract", async () => { + it("routes 'каму мы должны заплатить за май 2020' into exact confirmed payables flow without heuristic fallback", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("каму мы должны заплатить за май 2020"); expect(result?.handled).toBe(true); - expect(result?.debug.detected_intent).toBe("list_payables_counterparties"); + expect(result?.debug.detected_intent).toBe("payables_confirmed_as_of_date"); expect(result?.debug.requested_result_mode).toBe("confirmed_balance"); - expect(result?.debug.as_of_date_basis).toBe("period_range"); + expect(result?.debug.result_mode).toBe("confirmed_balance"); + expect(result?.debug.as_of_date_basis).toBe("explicit_as_of_date"); + expect(result?.debug.selected_recipe).toBe("address_payables_confirmed_as_of_date_v1"); expect(Array.isArray(result?.debug.reasons)).toBe(true); + expect(result?.debug.reasons).not.toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates"); + expect(["FACTUAL_LIST", "FACTUAL_SUMMARY", "LIMITED_WITH_REASON"]).toContain(result?.response_type); const reply = String(result?.reply_text ?? ""); - if (result?.debug.result_mode === "confirmed_balance") { - expect(result?.debug.selected_recipe).toBe("address_movements_payables_v1"); - expect(result?.debug.balance_confirmed).toBe(true); - expect(reply).toContain("подтвержденный срез обязательств к оплате"); - expect(reply).toContain("Кому нужно заплатить в первую очередь"); - } else { - expect(result?.debug.result_mode).toBe("heuristic_candidates"); + if (result?.response_type === "LIMITED_WITH_REASON") { expect(result?.debug.balance_confirmed).toBe(false); - expect(result?.debug.reasons).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates"); - expect(reply).toContain("эвристический скоринг"); - expect(reply).toContain("Контрагентов-кандидатов:"); + expect(result?.debug.reasons).toContain("exact_payables_mode_limited_response"); + expect(reply.toLowerCase()).not.toContain("эвристич"); + } else { + expect(result?.debug.balance_confirmed).toBe(true); expect(reply).toContain("Блок 1. Статус результата"); - expect(reply).toContain("\n\nБлок 2. Как читать результат"); - expect(reply).toContain("\n\nБлок 3. Сводка выборки"); - expect(reply).not.toContain("Кому нужно заплатить в первую очередь"); + expect(reply).toContain("\n\nБлок 2. Что учтено"); + expect(reply).toContain("\n\nБлок 3. Сводка"); + expect(reply).toContain("\n\nБлок 4. Категории обязательств"); + expect(reply).toContain("\n\nБлок 5. Подтвержденные позиции к оплате"); + expect(reply).not.toContain("эвристический"); } });