ДОМЕНЫ - ВОПРОСЫ - Этап 4: точный маршрут confirmed payables на дату без эвристического фолбэка

This commit is contained in:
dctouch 2026-04-12 14:14:03 +03:00
parent 1b2ee93176
commit fbd156e58e
19 changed files with 1303 additions and 162 deletions

529
docs/TECH/PLAN_FIX.md Normal file
View File

@ -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**.
Прям обязательно:
* 3050 простых сценариев;
* 1020 средних;
* 510 сложных.
И для каждого:
* 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. Сделать 23 эталонные длинные цепочки, а не весь “длинный диалог”
Вот это важный баланс.
Не надо сейчас пытаться закрыть **все** длинные диалоги.
Нужно взять 23 ключевые цепочки и довести их как вертикальные срезы.
Например:
### Цепочка 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. Выбрать 57 самых важных сложных управленческих вопросов.
3. Для каждого сделать exact business route.
4. Одновременно заложить минимальный result/state/navigation layer.
5. Прогнать 23 длинные эталонные цепочки.
### Не сейчас
* не пытаться закрыть весь длинный диалог целиком;
* не делать глобальную магическую “универсальную” оркестрацию;
* не лечить сложные кейсы только prompt engineeringом.
---
## Короткий ответ на твой главный вопрос
**Да, фокус на сложных вопросах сейчас — правильный.**
Но **полностью откладывать длинные цепочки нельзя**. Их нужно не “доделывать потом”, а **заложить сейчас в виде минимальной объектной модели состояния и 23 эталонных навигационных сценариев**.
То есть:
**сначала не “сложные вопросы vs длинные диалоги”,
а “exact routes first, state skeleton in parallel, rich dialog later”.**
Это, на мой взгляд, для вас сейчас самый здоровый путь.
Могу дальше сразу разложить это в виде **конкретного roadmap-а по спринтам / этапам**, чтобы это можно было отдать в работу.

View File

@ -745,6 +745,9 @@ function requiredFiltersByIntent(intent) {
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return ["account", "as_of_date"]; return ["account", "as_of_date"];
} }
if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"];
}
if (intent === "list_documents_by_counterparty" || if (intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty") { intent === "list_contracts_by_counterparty") {
@ -756,7 +759,9 @@ function requiredFiltersByIntent(intent) {
return []; return [];
} }
function usesAsOfPrimaryWindow(intent) { 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) { function extractAddressFilters(userMessage, intent) {
const rawText = String(userMessage ?? "").trim(); const rawText = String(userMessage ?? "").trim();
@ -916,7 +921,10 @@ function extractAddressFilters(userMessage, intent) {
// - explicit as_of has priority; // - explicit as_of has priority;
// - else use period_to boundary when provided; // - else use period_to boundary when provided;
// - else default to today. // - 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) { if (filters.period_to) {
filters.as_of_date = filters.period_to; filters.as_of_date = filters.period_to;
warnings.push("as_of_date_derived_from_period_to"); warnings.push("as_of_date_derived_from_period_to");

View File

@ -1225,11 +1225,12 @@ function resolveAddressIntent(userMessage) {
} }
if (hasAny(text, PAYABLES_STRONG)) { if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"]; const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) { const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text);
if (payablesDebtLifecycleSignal) {
reasons.push("payables_debt_lifecycle_signal_detected"); reasons.push("payables_debt_lifecycle_signal_detected");
} }
return { return {
intent: "list_payables_counterparties", intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties",
confidence: "high", confidence: "high",
reasons reasons
}; };

View File

@ -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_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1"; const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000; const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000; const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000; const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const PARTY_ANCHOR_STOPWORDS = new Set([ const PARTY_ANCHOR_STOPWORDS = new Set([
@ -345,6 +346,7 @@ function shouldAttemptCounterpartyCatalogResolution(intent, filters) {
intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_counterparty" ||
intent === "open_items_by_counterparty_or_contract" || intent === "open_items_by_counterparty_or_contract" ||
intent === "list_payables_counterparties" || intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" ||
intent === "list_receivables_counterparties"); intent === "list_receivables_counterparties");
} }
async function resolveCounterpartyViaCatalog(anchorRaw) { async function resolveCounterpartyViaCatalog(anchorRaw) {
@ -628,6 +630,7 @@ function parseIsoDateUtcTimestamp(value) {
function isCounterpartyRiskIntent(intent) { function isCounterpartyRiskIntent(intent) {
return (intent === "list_receivables_counterparties" || return (intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" || intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" ||
intent === "list_open_contracts" || intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract"); intent === "open_items_by_counterparty_or_contract");
} }
@ -638,7 +641,9 @@ function isHeuristicCandidatesIntent(intent) {
intent === "open_items_by_counterparty_or_contract"); intent === "open_items_by_counterparty_or_contract");
} }
function isConfirmedBalanceIntent(intent) { 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) { function resolveAsOfDateBasis(filters) {
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date); const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
@ -702,11 +707,12 @@ function deriveAddressResultSemantics(input) {
}; };
} }
if (isConfirmedBalanceIntent(input.intent)) { if (isConfirmedBalanceIntent(input.intent)) {
const balanceConfirmed = input.responseType !== "LIMITED_WITH_REASON";
return { return {
requested_result_mode: requestedResultMode, requested_result_mode: requestedResultMode,
result_mode: "confirmed_balance", result_mode: "confirmed_balance",
evidence_strength: deriveAddressEvidenceStrength(input), evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: true, balance_confirmed: balanceConfirmed,
as_of_date_basis: asOfDateBasis ?? "period_end" as_of_date_basis: asOfDateBasis ?? "period_end"
}; };
} }
@ -717,6 +723,62 @@ function deriveAddressResultSemantics(input) {
} }
return {}; 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) { function resolveFutureGuardReferenceDate(analysisDate, filters) {
if (analysisDate) { if (analysisDate) {
return analysisDate; return analysisDate;
@ -1124,6 +1186,9 @@ function buildLimitedOffers(input) {
if (input.intent === "list_receivables_counterparties") { if (input.intent === "list_receivables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76"); offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
} }
else if (input.intent === "payables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76");
}
else if (input.intent === "list_payables_counterparties") { else if (input.intent === "list_payables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76"); offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76");
} }
@ -1165,7 +1230,8 @@ function buildLimitedIntentSignalLine(input) {
open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.", open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.",
list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.", list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов." list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату."
}; };
const byShape = { const byShape = {
AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.", AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.",
@ -1278,7 +1344,7 @@ function composeLimitedReply(input) {
if (offers.length > 0) { if (offers.length > 0) {
lines.push(`Что могу сделать сейчас: ${offers.join("; ")}.`); lines.push(`Что могу сделать сейчас: ${offers.join("; ")}.`);
} }
return lines.join("\n"); return lines.join("\n\n");
} }
function buildLimitedExecutionResult(input) { function buildLimitedExecutionResult(input) {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters); const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
@ -1289,6 +1355,12 @@ function buildLimitedExecutionResult(input) {
responseType: "LIMITED_WITH_REASON", responseType: "LIMITED_WITH_REASON",
rowsMatched: input.rowsMatched 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 { return {
handled: true, handled: true,
reply_text: composeLimitedReply({ reply_text: composeLimitedReply({
@ -1341,7 +1413,7 @@ function buildLimitedExecutionResult(input) {
response_type: "LIMITED_WITH_REASON", response_type: "LIMITED_WITH_REASON",
...resultSemantics, ...resultSemantics,
limitations: input.limitations, limitations: input.limitations,
reasons: input.reasons reasons
} }
}; };
} }
@ -1371,13 +1443,29 @@ class AddressQueryService {
baseReasons.push("as_of_date_from_analysis_context"); 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) => ({ const composeOptionsFromFilters = (filterSet) => ({
userMessage, userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined 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); let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" && const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) && Array.isArray(intent.reasons) &&
@ -1387,15 +1475,25 @@ class AddressQueryService {
(intent.reasons.includes("payables_debt_lifecycle_signal_detected") || (intent.reasons.includes("payables_debt_lifecycle_signal_detected") ||
intent.reasons.includes("supplier_tail_risk_signal_detected") || intent.reasons.includes("supplier_tail_risk_signal_detected") ||
intent.reasons.includes("payables_signal_detected")); intent.reasons.includes("payables_signal_detected"));
const recipeIntent = debtLifecycleReceivablesScenario || debtLifecyclePayablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent; const preferConfirmedBalanceForPayablesLifecycle = debtLifecyclePayablesScenario && requestedResultMode === "confirmed_balance";
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, filters.extracted_filters); const recipeIntent = debtLifecycleReceivablesScenario
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters); ? "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) { if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle"); baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle");
} }
if (debtLifecyclePayablesScenario && recipeIntent !== intent.intent) { if (debtLifecyclePayablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_payables_debt_lifecycle"); 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" && if (requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" && recipeIntent === "open_items_by_counterparty_or_contract" &&
!baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) { !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)({ let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: plan.query, query: plan.query,
limit: plan.limit limit: plan.limit
}); });
const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date";
if (mcp.error && if (mcp.error &&
recipeSelection.selected_recipe.recipe_id === "address_movements_receivables_v1" && (plan.recipe.recipe_id === "address_movements_receivables_v1" ||
isMissingSubcontoFieldError(mcp.error)) { plan.recipe.recipe_id === "address_movements_payables_v1") &&
const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", filters.extracted_filters); 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) { 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)({ const fallbackMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: fallbackPlan.query, query: fallbackPlan.query,
limit: fallbackPlan.limit limit: fallbackPlan.limit
@ -1531,9 +1633,16 @@ class AddressQueryService {
if (!fallbackMcp.error) { if (!fallbackMcp.error) {
plan = fallbackPlan; plan = fallbackPlan;
mcp = fallbackMcp; 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")) { if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) {
baseReasons.push("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 { else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { 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) { if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
@ -1555,7 +1672,7 @@ class AddressQueryService {
intent, intent,
filters: filters.extracted_filters, filters: filters.extracted_filters,
missingRequiredFilters: [], missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id, selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode, accountScopeMode: plan.account_scope_mode,
anchor, anchor,
mcpCallStatus: deriveMcpStageStatus({ mcpCallStatus: deriveMcpStageStatus({
@ -1590,10 +1707,10 @@ class AddressQueryService {
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows; const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
anchor = (0, resolveStage_1.refineAnchorFromRows)(anchor, normalizedRows); anchor = (0, resolveStage_1.refineAnchorFromRows)(anchor, normalizedRows);
const filtersForMatching = anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved 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 : anchor.anchor_type === "contract" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, contract: anchor.anchor_value_resolved } ? { ...executionFilters, contract: anchor.anchor_value_resolved }
: filters.extracted_filters; : executionFilters;
const accountScopeAudit = buildAccountScopeAudit({ const accountScopeAudit = buildAccountScopeAudit({
intent: intent.intent, intent: intent.intent,
filters: filtersForMatching, filters: filtersForMatching,
@ -1635,7 +1752,7 @@ class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors); const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors; const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) { 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 const recoveryReason = recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback" ? "contract_docs_recovered_via_bank_fallback"
: "contract_docs_recovered_via_anchor_rows"; : "contract_docs_recovered_via_anchor_rows";
@ -1656,7 +1773,7 @@ class AddressQueryService {
detected_intent_confidence: intent.confidence, detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters, extracted_filters: filters.extracted_filters,
missing_required_filters: [], missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id, selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus("matched_non_empty"), mcp_call_status_legacy: toLegacyMcpStatus("matched_non_empty"),
account_scope_mode: plan.account_scope_mode, account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied, account_scope_fallback_applied: accountScopeFallbackApplied,
@ -1684,15 +1801,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: factual.responseType, response_type: factual.responseType,
...deriveAddressResultSemantics({ ...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent, intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id, selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters, filters: filters.extracted_filters,
responseType: factual.responseType, responseType: factual.responseType,
rowsMatched: recoveredRows.length rowsMatched: recoveredRows.length
}), }), factual.semantics),
limitations: [...filters.warnings, recoveryReason], 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_not_anchor_matched" ||
stageStatus === "materialized_but_filtered_out_by_recipe" || stageStatus === "materialized_but_filtered_out_by_recipe" ||
stageStatus === "raw_rows_received_but_not_materialized")) { stageStatus === "raw_rows_received_but_not_materialized")) {
const currentLimit = typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit) const currentLimit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
? Math.max(1, Math.trunc(filters.extracted_filters.limit)) ? Math.max(1, Math.trunc(executionFilters.limit))
: plan.limit; : plan.limit;
if (currentLimit < ADDRESS_ANCHOR_RECOVERY_LIMIT) { if (currentLimit < ADDRESS_ANCHOR_RECOVERY_LIMIT) {
const expandedLimitFilters = { const expandedLimitFilters = {
...filters.extracted_filters, ...executionFilters,
limit: ADDRESS_ANCHOR_RECOVERY_LIMIT limit: ADDRESS_ANCHOR_RECOVERY_LIMIT
}; };
const expandedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, expandedLimitFilters); const expandedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, expandedLimitFilters);
@ -1807,15 +1924,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: expandedFactual.responseType, response_type: expandedFactual.responseType,
...deriveAddressResultSemantics({ ...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent, intent: intent.intent,
selectedRecipe: expandedSelection.selected_recipe.recipe_id, selectedRecipe: expandedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters, filters: filters.extracted_filters,
responseType: expandedFactual.responseType, responseType: expandedFactual.responseType,
rowsMatched: expandedFilteredRows.length rowsMatched: expandedFilteredRows.length
}), }), expandedFactual.semantics),
limitations: expandedLimitations, limitations: expandedLimitations,
reasons: expandedReasons reasons: withConfirmedBalanceFallbackReason(expandedReasons, requestedResultMode, expandedFactual.semantics)
} }
}; };
} }
@ -1925,15 +2042,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: broadenedFactual.responseType, response_type: broadenedFactual.responseType,
...deriveAddressResultSemantics({ ...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent, intent: intent.intent,
selectedRecipe: broadenedSelection.selected_recipe.recipe_id, selectedRecipe: broadenedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters, filters: filters.extracted_filters,
responseType: broadenedFactual.responseType, responseType: broadenedFactual.responseType,
rowsMatched: broadenedFilteredRows.length rowsMatched: broadenedFilteredRows.length
}), }), broadenedFactual.semantics),
limitations: broadenedLimitations, limitations: broadenedLimitations,
reasons: broadenedReasons reasons: withConfirmedBalanceFallbackReason(broadenedReasons, requestedResultMode, broadenedFactual.semantics)
} }
}; };
} }
@ -2051,15 +2168,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: historicalFactual.responseType, response_type: historicalFactual.responseType,
...deriveAddressResultSemantics({ ...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent, intent: intent.intent,
selectedRecipe: historicalSelection.selected_recipe.recipe_id, selectedRecipe: historicalSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters, filters: filters.extracted_filters,
responseType: historicalFactual.responseType, responseType: historicalFactual.responseType,
rowsMatched: historicalFilteredRows.length rowsMatched: historicalFilteredRows.length
}), }), historicalFactual.semantics),
limitations: historicalLimitations, 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")) { (stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")) {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows); const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) { 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 fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion = intent.intent === "list_documents_by_counterparty" const fallbackSuggestion = intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи." ? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
@ -2094,7 +2211,7 @@ class AddressQueryService {
detected_intent_confidence: intent.confidence, detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters, extracted_filters: filters.extracted_filters,
missing_required_filters: [], missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id, selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: "matched_non_empty", mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: plan.account_scope_mode, account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied, account_scope_fallback_applied: accountScopeFallbackApplied,
@ -2122,15 +2239,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: fallbackFactual.responseType, response_type: fallbackFactual.responseType,
...deriveAddressResultSemantics({ ...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent, intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id, selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters, filters: filters.extracted_filters,
responseType: fallbackFactual.responseType, responseType: fallbackFactual.responseType,
rowsMatched: documentBankFallbackRows.length rowsMatched: documentBankFallbackRows.length
}), }), fallbackFactual.semantics),
limitations: fallbackLimitations, limitations: fallbackLimitations,
reasons: fallbackReasons reasons: withConfirmedBalanceFallbackReason(fallbackReasons, requestedResultMode, fallbackFactual.semantics)
} }
}; };
} }
@ -2219,7 +2336,7 @@ class AddressQueryService {
intent, intent,
filters: filters.extracted_filters, filters: filters.extracted_filters,
missingRequiredFilters: [], missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id, selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode, accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied, accountScopeFallbackApplied,
accountScopeAudit, accountScopeAudit,
@ -2242,7 +2359,44 @@ class AddressQueryService {
reasons: baseReasons 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 { return {
handled: true, handled: true,
reply_text: factual.text, reply_text: factual.text,
@ -2257,7 +2411,7 @@ class AddressQueryService {
detected_intent_confidence: intent.confidence, detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters, extracted_filters: filters.extracted_filters,
missing_required_filters: [], missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id, selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus), mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
account_scope_mode: plan.account_scope_mode, account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied, account_scope_fallback_applied: accountScopeFallbackApplied,
@ -2285,15 +2439,9 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null, limited_reason_category: null,
response_type: factual.responseType, response_type: factual.responseType,
...deriveAddressResultSemantics({ ...factualResultSemantics,
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: filteredRows.length
}),
limitations: filters.warnings, limitations: filters.warnings,
reasons: baseReasons reasons: withConfirmedBalanceFallbackReason(baseReasons, requestedResultMode, factual.semantics, factualResultSemantics.result_mode)
} }
}; };
} }

View File

@ -9,7 +9,13 @@ const MOVEMENTS_QUERY_TEMPLATE = `
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор, ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт, ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт, ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
Движения.Сумма КАК Сумма Движения.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
ИЗ ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__ __WHERE_CLAUSE__
@ -519,6 +525,16 @@ const BASE_RECIPES = [
account_scope: ["60", "76"], account_scope: ["60", "76"],
account_scope_mode: "strict" 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", recipe_id: "address_movements_receivables_v1",
intent: "list_receivables_counterparties", intent: "list_receivables_counterparties",

View File

@ -508,11 +508,11 @@ function classifyPayablesLiabilityCategory(row, counterparty) {
scores.supplier_or_contractor += 1; scores.supplier_or_contractor += 1;
reasons.add("участие счета 76"); reasons.add("участие счета 76");
} }
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|loan|overdraft)/iu.test(text)) { if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit)/iu.test(text)) {
scores.bank_or_credit += 3; scores.bank_or_credit += 3;
reasons.add("банк/кредит в аналитике"); reasons.add("банк/кредит в аналитике");
} }
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос)/iu.test(text)) { if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал)/iu.test(text)) {
scores.tax_or_state += 3; scores.tax_or_state += 3;
reasons.add("налог/госорган в аналитике"); reasons.add("налог/госорган в аналитике");
} }
@ -525,6 +525,42 @@ function classifyPayablesLiabilityCategory(row, counterparty) {
reasons: Array.from(reasons) 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) { function buildPayablesCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map(); const byCounterparty = new Map();
for (const row of rows) { for (const row of rows) {
@ -574,26 +610,10 @@ function buildPayablesCounterpartyRiskAggregate(rows) {
current.reasons.add(reason); 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()) return Array.from(byCounterparty.values())
.map((item) => ({ .map((item) => ({
...item.base, ...item.base,
category: toCategory(item.categoryScores), category: resolvePayablesLiabilityCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2) categoryReasons: Array.from(item.reasons).slice(0, 2)
})) }))
.sort((left, right) => { .sort((left, right) => {
@ -606,6 +626,88 @@ function buildPayablesCounterpartyRiskAggregate(rows) {
return left.name.localeCompare(right.name); 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) { function buildCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map(); const byCounterparty = new Map();
for (const row of rows) { for (const row of rows) {
@ -1663,61 +1765,206 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n") text: lines.join("\n")
}; };
} }
if (intent === "list_payables_counterparties") { if (intent === "payables_confirmed_as_of_date") {
const counterparties = buildPayablesCounterpartyRiskAggregate(rows); const payablesAsOfDate = resolvePayablesAsOfDate(options);
const scopeLine = (() => { const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate);
const asOfDate = normalizeIsoDateOnly(options.asOfDate); const asOfDate = normalizeIsoDateOnly(options.asOfDate);
if (asOfDate) { const periodFrom = normalizeIsoDateOnly(options.periodFrom);
return `Дата среза: ${formatDateRu(asOfDate)}.`; const periodTo = normalizeIsoDateOnly(options.periodTo);
} const scopeLine = asOfDate
const periodFrom = normalizeIsoDateOnly(options.periodFrom); ? `- Дата среза: ${formatDateRu(asOfDate)}.`
const periodTo = normalizeIsoDateOnly(options.periodTo); : periodFrom || periodTo
if (periodFrom || periodTo) { ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
return `Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`; : null;
} const carryoverLine = asOfDate || periodFrom || periodTo
return null; ? "- В срез могут входить обязательства, возникшие до периода, если они оставались открытыми на дату среза."
})(); : 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 = [ const lines = [
"Коротко: собран shortlist кандидатов на ручную проверку по потенциально незакрытым обязательствам (контур 60/76).", "Блок 1. Статус результата",
"", "- Режим результата: подтвержденный срез обязательств к оплате (exact route).",
"Что это значит:", "- Эвристический shortlist в этом режиме не используется."
"- Режим результата: эвристический скоринг по движениям.",
"- Это не финальный подтвержденный остаток к оплате.",
...(scopeLine ? ["", scopeLine] : []),
"",
`Строк в выборке: ${rows.length}.`,
`Контрагентов-кандидатов: ${counterparties.length}.`
]; ];
if (counterparties.length > 0) { lines.push("");
const categoryCounts = counterparties.reduce((acc, item) => { lines.push("Блок 2. Что учтено");
acc[item.category] += 1; lines.push(`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`);
return acc; if (scopeLine) {
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }); lines.push(scopeLine);
lines.push(""); }
lines.push("Категории обязательств:"); lines.push("- Контур: обязательства по счетам 60/76.");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`); if (carryoverLine) {
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`); lines.push(carryoverLine);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`); }
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`); lines.push("");
lines.push(""); lines.push("Блок 3. Сводка");
lines.push("Приоритет ручной проверки (по сумме/частоте сигналов):"); lines.push(`- Строк в выборке: ${rows.length}.`);
lines.push(...counterparties lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${confirmedBalances.length}.`);
.slice(0, 8) lines.push("");
.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("Блок 4. Категории обязательств");
lines.push(""); lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push("Примеры исходных строк:"); lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(...formatTopRows(rows, 4)); 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 { else {
lines.push(""); lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено.");
lines.push("Явных кандидатов на незакрытые обязательства по текущему срезу не найдено.");
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 6));
} }
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 { return {
responseType: "FACTUAL_LIST", 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") { if (intent === "list_receivables_counterparties") {

View File

@ -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 inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract); const currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract = !currentContract || const shouldInheritContract = !currentContract ||
@ -370,6 +372,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
merged.counterparty = inheritedCounterparty; merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context"); 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 (allTimeRequested) {
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) { if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
@ -424,6 +433,7 @@ function resolveMissingRequiredFilters(intent, filters) {
const requiredByIntent = { const requiredByIntent = {
account_balance_snapshot: ["account", "as_of_date"], account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
list_documents_by_counterparty: ["counterparty"], list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"],

View File

@ -91,7 +91,9 @@ function inferAggregationProfile(intent, shape) {
intent === "vat_payable_forecast") { intent === "vat_payable_forecast") {
return "management_profile"; 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"; return "balance_snapshot";
} }
if (intent === "open_items_by_counterparty_or_contract" || if (intent === "open_items_by_counterparty_or_contract" ||

View File

@ -3695,6 +3695,7 @@ function hasOpenContractsAddressSignal(text) {
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_open_contracts", "list_open_contracts",
"open_items_by_counterparty_or_contract", "open_items_by_counterparty_or_contract",
"payables_confirmed_as_of_date",
"list_documents_by_contract", "list_documents_by_contract",
"bank_operations_by_contract", "bank_operations_by_contract",
"list_documents_by_counterparty", "list_documents_by_counterparty",

View File

@ -837,6 +837,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") { if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return ["account", "as_of_date"]; return ["account", "as_of_date"];
} }
if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"];
}
if ( if (
intent === "list_documents_by_counterparty" || intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_counterparty" ||
@ -851,7 +854,11 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
} }
function usesAsOfPrimaryWindow(intent: AddressIntent): boolean { function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
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"
);
} }
export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction { export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction {
@ -1035,7 +1042,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
// - explicit as_of has priority; // - explicit as_of has priority;
// - else use period_to boundary when provided; // - else use period_to boundary when provided;
// - else default to today. // - 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) { if (filters.period_to) {
filters.as_of_date = filters.period_to; filters.as_of_date = filters.period_to;
warnings.push("as_of_date_derived_from_period_to"); warnings.push("as_of_date_derived_from_period_to");

View File

@ -1432,11 +1432,12 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (hasAny(text, PAYABLES_STRONG)) { if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"]; const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) { const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text);
if (payablesDebtLifecycleSignal) {
reasons.push("payables_debt_lifecycle_signal_detected"); reasons.push("payables_debt_lifecycle_signal_detected");
} }
return { return {
intent: "list_payables_counterparties", intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties",
confidence: "high", confidence: "high",
reasons reasons
}; };

View File

@ -419,6 +419,7 @@ function shouldAttemptCounterpartyCatalogResolution(intent: AddressIntent, filte
intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_counterparty" ||
intent === "open_items_by_counterparty_or_contract" || intent === "open_items_by_counterparty_or_contract" ||
intent === "list_payables_counterparties" || intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" ||
intent === "list_receivables_counterparties" intent === "list_receivables_counterparties"
); );
} }
@ -745,6 +746,7 @@ function isCounterpartyRiskIntent(intent: AddressIntent): boolean {
return ( return (
intent === "list_receivables_counterparties" || intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" || intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" ||
intent === "list_open_contracts" || intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract" intent === "open_items_by_counterparty_or_contract"
); );
@ -760,7 +762,11 @@ function isHeuristicCandidatesIntent(intent: AddressIntent): boolean {
} }
function isConfirmedBalanceIntent(intent: AddressIntent): boolean { function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
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: AddressFilterSet): AddressAsOfDateBasis | null { function resolveAsOfDateBasis(filters: AddressFilterSet): AddressAsOfDateBasis | null {
@ -845,11 +851,12 @@ function deriveAddressResultSemantics(input: {
}; };
} }
if (isConfirmedBalanceIntent(input.intent)) { if (isConfirmedBalanceIntent(input.intent)) {
const balanceConfirmed = input.responseType !== "LIMITED_WITH_REASON";
return { return {
requested_result_mode: requestedResultMode, requested_result_mode: requestedResultMode,
result_mode: "confirmed_balance", result_mode: "confirmed_balance",
evidence_strength: deriveAddressEvidenceStrength(input), evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: true, balance_confirmed: balanceConfirmed,
as_of_date_basis: asOfDateBasis ?? "period_end" as_of_date_basis: asOfDateBasis ?? "period_end"
}; };
} }
@ -1463,6 +1470,8 @@ function buildLimitedOffers(input: {
if (input.intent === "list_receivables_counterparties") { if (input.intent === "list_receivables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76"); offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
} else if (input.intent === "payables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76");
} else if (input.intent === "list_payables_counterparties") { } else if (input.intent === "list_payables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76"); offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76");
} else if (input.intent === "open_items_by_counterparty_or_contract" || input.intent === "list_open_contracts") { } else if (input.intent === "open_items_by_counterparty_or_contract" || input.intent === "list_open_contracts") {
@ -1512,7 +1521,8 @@ function buildLimitedIntentSignalLine(input: {
open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.", open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.",
list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.", list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов." list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату."
}; };
const byShape: Partial<Record<AddressQueryShapeDetection["shape"], string>> = { const byShape: Partial<Record<AddressQueryShapeDetection["shape"], string>> = {
@ -1655,7 +1665,7 @@ function composeLimitedReply(input: {
lines.push(`Что могу сделать сейчас: ${offers.join("; ")}.`); lines.push(`Что могу сделать сейчас: ${offers.join("; ")}.`);
} }
return lines.join("\n"); return lines.join("\n\n");
} }
function buildLimitedExecutionResult(input: { function buildLimitedExecutionResult(input: {
@ -1701,12 +1711,17 @@ function buildLimitedExecutionResult(input: {
rowsMatched: input.rowsMatched rowsMatched: input.rowsMatched
}); });
const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters); const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters);
const reasons = withConfirmedBalanceFallbackReason( const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(
input.reasons, input.reasons,
requestedResultMode, requestedResultMode,
undefined, undefined,
resultSemantics.result_mode 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 { return {
handled: true, handled: true,
reply_text: composeLimitedReply({ reply_text: composeLimitedReply({
@ -1795,7 +1810,8 @@ export class AddressQueryService {
} }
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters); const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters);
const payablesConfirmedExecution = 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) ? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate)
: null; : null;
const executionFilters = payablesConfirmedExecution?.executionFilters ?? filters.extracted_filters; 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")) { if (preferConfirmedBalanceForPayablesLifecycle && !baseReasons.includes("confirmed_balance_attempt_for_payables_debt_lifecycle")) {
baseReasons.push("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 ( if (
requestedResultMode === "confirmed_balance" && requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" && recipeIntent === "open_items_by_counterparty_or_contract" &&
@ -1982,11 +2001,13 @@ export class AddressQueryService {
query: plan.query, query: plan.query,
limit: plan.limit limit: plan.limit
}); });
const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date";
if ( if (
mcp.error && mcp.error &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" || (plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_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); const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", executionFilters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) { if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
@ -2019,11 +2040,21 @@ export class AddressQueryService {
} }
} }
} else { } else {
if (!baseReasons.includes("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"); 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) { if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
@ -2846,6 +2877,36 @@ export class AddressQueryService {
}), }),
factual.semantics 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 { return {
handled: true, handled: true,
reply_text: factual.text, reply_text: factual.text,

View File

@ -540,6 +540,16 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope: ["60", "76"], account_scope: ["60", "76"],
account_scope_mode: "strict" 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", recipe_id: "address_movements_receivables_v1",
intent: "list_receivables_counterparties", intent: "list_receivables_counterparties",

View File

@ -858,7 +858,7 @@ function buildPayablesConfirmedBalanceAggregate(
continue; continue;
} }
const amount = row.amount; const amount = row.amount;
if (!Number.isFinite(amount)) { if (typeof amount !== "number" || !Number.isFinite(amount)) {
continue; continue;
} }
const absAmount = Math.abs(amount); 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<Record<PayablesLiabilityCategory, number>>(
(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") { if (intent === "list_payables_counterparties") {
const counterparties = buildPayablesCounterpartyRiskAggregate(rows); const counterparties = buildPayablesCounterpartyRiskAggregate(rows);
const payablesAsOfDate = resolvePayablesAsOfDate(options); const payablesAsOfDate = resolvePayablesAsOfDate(options);

View File

@ -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 inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract); const currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract = const shouldInheritContract =
@ -462,6 +466,13 @@ function mergeFollowupFilters(
merged.counterparty = inheritedCounterparty; merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context"); 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 (allTimeRequested) {
@ -525,6 +536,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = { const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
account_balance_snapshot: ["account", "as_of_date"], account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
list_documents_by_counterparty: ["counterparty"], list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"],

View File

@ -189,7 +189,11 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
return "management_profile"; 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"; return "balance_snapshot";
} }

View File

@ -3652,6 +3652,7 @@ function hasOpenContractsAddressSignal(text) {
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_open_contracts", "list_open_contracts",
"open_items_by_counterparty_or_contract", "open_items_by_counterparty_or_contract",
"payables_confirmed_as_of_date",
"list_documents_by_contract", "list_documents_by_contract",
"bank_operations_by_contract", "bank_operations_by_contract",
"list_documents_by_counterparty", "list_documents_by_counterparty",

View File

@ -13,6 +13,7 @@ export type AddressIntent =
| "list_contracts_by_counterparty" | "list_contracts_by_counterparty"
| "list_open_contracts" | "list_open_contracts"
| "list_payables_counterparties" | "list_payables_counterparties"
| "payables_confirmed_as_of_date"
| "list_receivables_counterparties" | "list_receivables_counterparties"
| "account_balance_snapshot" | "account_balance_snapshot"
| "open_items_by_counterparty_or_contract" | "open_items_by_counterparty_or_contract"

View File

@ -1832,7 +1832,7 @@ describe("address intent resolver expansion (M2.3a)", () => {
it("marks 'кому мы должны заплатить' as payables debt lifecycle intent", () => { it("marks 'кому мы должны заплатить' as payables debt lifecycle intent", () => {
const result = resolveAddressIntent("каму мы должны заплатить за май 2020"); 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"); 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"); 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 service = new AddressQueryService();
const result = await service.tryHandle("каму мы должны заплатить за май 2020"); const result = await service.tryHandle("каму мы должны заплатить за май 2020");
expect(result?.handled).toBe(true); 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.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(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 ?? ""); const reply = String(result?.reply_text ?? "");
if (result?.debug.result_mode === "confirmed_balance") { if (result?.response_type === "LIMITED_WITH_REASON") {
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");
expect(result?.debug.balance_confirmed).toBe(false); expect(result?.debug.balance_confirmed).toBe(false);
expect(result?.debug.reasons).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates"); expect(result?.debug.reasons).toContain("exact_payables_mode_limited_response");
expect(reply).toContain("эвристический скоринг"); expect(reply.toLowerCase()).not.toContain("эвристич");
expect(reply).toContain("Контрагентов-кандидатов:"); } else {
expect(result?.debug.balance_confirmed).toBe(true);
expect(reply).toContain("Блок 1. Статус результата"); expect(reply).toContain("Блок 1. Статус результата");
expect(reply).toContain("\n\nБлок 2. Как читать результат"); expect(reply).toContain("\n\nБлок 2. Что учтено");
expect(reply).toContain("\n\nБлок 3. Сводка выборки"); expect(reply).toContain("\n\nБлок 3. Сводка");
expect(reply).not.toContain("Кому нужно заплатить в первую очередь"); expect(reply).toContain("\n\nБлок 4. Категории обязательств");
expect(reply).toContain("\n\nБлок 5. Подтвержденные позиции к оплате");
expect(reply).not.toContain("эвристический");
} }
}); });