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