From 8056bdfaf21cc1137e87ace809adcd1fb4d60fee Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 15 Apr 2026 20:34:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A7=20-=20=D0=A3=D1=81=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8C=20root-frame=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=82,=20memory=20recap=20=D0=B8=20colloquia?= =?UTF-8?q?l=20provenance=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...wup_context_root_pivot_audit_2026-04-15.md | 206 +++++++++ .../project_status_update_2026-04-15.md | 78 ++++ .../step5_increment_update_2026-04-15.md | 132 ++++++ ...rrent_assistant_architecture_2026-04-15.md | 428 ++++++++++++++++++ ...ent_assistant_hardening_plan_2026-04-15.md | 247 ++++++++++ .../backend/dist/services/assistantService.js | 82 +++- .../src/services/addressFilterExtractor.ts | 5 + .../address_runtime/decomposeStage.ts | 39 +- .../assistantLivingChatRuntimeAdapter.ts | 108 +++++ .../backend/src/services/assistantService.ts | 204 ++++++++- .../services/inventoryLifecycleCueHelpers.ts | 5 +- ...ddressInventoryRootFrameRegression.test.ts | 65 +++ .../addressInventoryWarehouseAnchor.test.ts | 13 + .../assistantAddressFollowupContext.test.ts | 318 +++++++++++++ .../assistantLivingChatRuntimeAdapter.test.ts | 31 ++ .../tests/assistantLivingRouter.test.ts | 92 ++++ 16 files changed, 2032 insertions(+), 21 deletions(-) create mode 100644 docs/ADDRESS/address_query/followup_context_root_pivot_audit_2026-04-15.md create mode 100644 docs/ADDRESS/address_query/project_status_update_2026-04-15.md create mode 100644 docs/ADDRESS/address_query/step5_increment_update_2026-04-15.md create mode 100644 docs/ARCH/10 - current_assistant_architecture_2026-04-15.md create mode 100644 docs/ARCH/10A - current_assistant_hardening_plan_2026-04-15.md create mode 100644 llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts diff --git a/docs/ADDRESS/address_query/followup_context_root_pivot_audit_2026-04-15.md b/docs/ADDRESS/address_query/followup_context_root_pivot_audit_2026-04-15.md new file mode 100644 index 0000000..9c5f920 --- /dev/null +++ b/docs/ADDRESS/address_query/followup_context_root_pivot_audit_2026-04-15.md @@ -0,0 +1,206 @@ +# Follow-up Context + Root Pivot Audit (2026-04-15) + +Дата: 2026-04-15 +Статус: `active audit note` +Источник прогона: `C:\Users\DCTOUCH\Desktop\test.txt` + +## 1. Зачем этот документ + +Этот note фиксирует, что именно подтвердил живой прогон с appended audit, что в исходном разборе нужно скорректировать, и как это соотносится с уже внедренным bounded fix: + +- `root_context_only` carryover при pivot из inventory drilldown в налоговый/смежный учетный домен; +- сохранение baseline inventory selected-object маршрутов; +- отсутствие архитектурного конфликта с текущими rails. + +Цель документа не переписать общий план, а зафиксировать фактический срез по одному реальному диалогу, чтобы следующие правки не лечили неверную причину. + +## 2. Что подтверждено прогоном + +### 2.1. VAT root-query работает как capability + +Стартовый запрос про прогноз НДС на март 2020 отработал корректно: + +- `detected_intent = vat_payable_forecast` +- `selected_recipe = address_vat_payable_forecast_v1` +- ответ построен в address lane, а не через chat/deep fallback + +Это важно, потому что базовый VAT route в этом прогоне не деградировал. + +### 2.2. Meta follow-up по VAT сломан по answer policy + +Запрос `это много или мало?` не получил новый evaluative answer shape. Вместо этого ассистент повторил почти весь предыдущий factual VAT answer. + +Подтвержденные симптомы: + +- `followup_context_applied = true` +- `previous_intent = vat_payable_forecast` +- `target_intent = unknown` +- route при этом остался в address lane + +Вывод: + +- поломка не в data retrieval; +- поломка в policy для meta follow-up поверх успешного финансового ответа. + +### 2.3. Есть leakage VAT frame в новый inventory root-query + +На запрос `остаток на складе за май 2020` система все равно несет хвост VAT-сценария: + +- `previous_intent = vat_payable_forecast` +- `target_intent = inventory_on_hand_as_of_date` +- `intent_selection_mode = carry_previous_intent` + +При этом итоговый inventory intent все же распознается правильно, но сам факт такого carryover является архитектурно неправильным. Новый root-query не должен стартовать с хвостом чужого домена. + +### 2.4. Multiple-organization clarification в этом прогоне работает правильно + +После вопроса `какая база подключена ?` система ушла в `assistant_data_scope_query_detected`, показала 3 организации и не выбрала активную организацию самовольно. + +На первом inventory root-query без организации система корректно вернула clarification: + +- `organization_clarification_required` +- `multiple_known_organizations_detected` + +После ответа `альтернатива` активная организация была зафиксирована как: + +- `organization_grounded_from_scope_candidates` + +Это важная корректировка исходного разбора: в данном прогоне проблема не в преждевременном выборе организации из наблюдаемых строк. + +### 2.5. Selected-object sale trace маршрутизируется, но может честно вернуться empty + +Запрос по выбранной позиции `кому продали` был правильно классифицирован: + +- `detected_intent = inventory_sale_trace_for_item` +- `selected_recipe = address_inventory_sale_trace_for_item_v1` + +Но retrieval вернул: + +- `limited_reason_category = empty_match` +- `mcp_call_status = no_raw_rows` + +Это означает: + +- classification и follow-up routing для этого шага в прогоне живы; +- ответ не найден в доступном execution contour; +- данный шаг сам по себе не доказывает, что orchestration сломан. + +### 2.6. Short follow-up после limited sale step теряет selected-object continuity + +Следующий короткий вопрос `а купили у кого` уже не продолжил selected-object inventory chain. Он ушел в living chat: + +- `tool_gate_decision = skip_address_lane` +- `tool_gate_reason = non_domain_query_indexed` +- `followup_context_detected = false` + +Это и есть реальный continuity incident в конце цепочки. + +## 3. Что в исходном разборе нужно поправить + +### 3.1. Нельзя утверждать, что этот прогон сломан из-за авто-grounding организации из observed rows + +В `test.txt` не подтверждается `organization_grounded_from_observed_rows`. По этому диалогу организация была: + +1. сначала явно не выбрана; +2. затем корректно запрошена через clarification; +3. затем выбрана пользователем; +4. затем grounded из scope candidates. + +Следовательно, в данном прогоне root cause не в org policy. + +### 3.2. Пустой sale trace не равен провалу intent-routing + +`empty_match` по sale trace нельзя автоматически считать провалом follow-up architecture. В этом кейсе route выбран корректно; проблема либо в данных, либо в доступном документном/проводочном контуре. + +### 3.3. Главный continuity bug здесь не на шаге `кому продали`, а на шаге `а купили у кого` + +Пока selected-object sale trace хотя бы доходит до exact capability, короткий follow-up после ограниченного результата уже не доходит даже до address lane. Именно это место нужно держать как следующий incident. + +## 4. Соотношение с уже внедренным bounded fix + +### 4.1. Что уже закрыто чисто + +В runtime уже внедрен узкий guard: + +- при pivot из inventory drilldown в foreign accounting domain не тащить selected item; +- сохранять только root context (`organization`, `warehouse`, `as_of_date`, `period_from`, `period_to`); +- маркировать carryover как `root_context_only`. + +Этот fix архитектурно совместим с текущими rails, потому что: + +- не переписывает state model; +- не ломает inventory selected-object цепочки внутри inventory domain; +- не меняет execution semantics exact routes; +- ограничивает только cross-domain contamination. + +### 4.2. Что этот прогон показывает поверх уже закрытого + +Прогон из `test.txt` показывает еще три отдельные задачи, которые не конфликтуют с `root_context_only` fix и должны решаться отдельно: + +1. `meta_followup_answer_policy` + VAT/налоговые meta follow-up типа `это много или мало?` должны выдавать evaluative short answer, а не replay предыдущего factual summary. + +2. `root_domain_pivot_resets_previous_intent` + Новый inventory root-query после VAT/налогового домена не должен нести `previous_intent = vat_payable_forecast`. + +3. `selected_object_continuity_after_limited_result` + Short follow-up после limited selected-object answer не должен выпадать в `non_domain_query_indexed`, если последний успешный/ограниченный шаг был inventory drilldown по выбранной позиции. + +4. `invalid_entity_extraction_guard` + Временной кусок `за май` не должен попадать в `warehouse`. + +## 5. Что нельзя делать по итогам этого прогона + +1. Нельзя откатывать или ослаблять `root_context_only` fix. + Этот прогон не опровергает его; он показывает другой класс дефектов. + +2. Нельзя лечить root leakage полным отключением follow-up carryover. + Это сломает рабочие selected-object цепочки в inventory. + +3. Нельзя интерпретировать любой `empty_match` как classifier failure. + Нужно различать: + - route selected correctly; + - retrieval found no rows; + - follow-up context lost before route selection. + +4. Нельзя лечить `warehouse = "за май"` через новые хардкодные словари по конкретным месяцам. + Нужен entity admissibility guard для warehouse anchor. + +## 6. Приоритетный backlog после аудита + +### P0 + +1. Запретить replay полного factual ответа на evaluative/meta follow-up поверх VAT/tax summary. +2. Ввести reset previous intent на root-domain pivot из VAT/tax в inventory root query. +3. Вернуть address-lane continuity для short purchase follow-up после limited selected-object inventory step. + +### P1 + +1. Отбрасывать временные фрагменты как invalid warehouse anchor. +2. Развести в acceptance: + - `route matched but empty` + - `route not matched` + - `follow-up continuity lost` + +## 7. Обязательные regression checks + +1. `VAT factual -> это много или мало?` + Ожидание: короткая evaluative реплика, без replay исходного factual summary. + +2. `VAT root -> inventory root` + Ожидание: новый root query не наследует `previous_intent` из чужого домена. + +3. `inventory selected item -> sale empty_match -> а купили у кого` + Ожидание: address lane не теряется, selected object продолжает жить. + +4. `остаток на складе за май 2020` + Ожидание: `warehouse` не заполняется значением `за май`. + +## 8. Итог + +По этому прогону архитектурная картина такая: + +- узкий fix `root_context_only` был правильным и не противоречит системе; +- текущий диалог подтверждает еще несколько независимых дефектов вокруг meta follow-up, root pivot reset и short follow-up continuity; +- org clarification policy в этом кейсе, наоборот, отработала лучше, чем предполагал исходный аудит; +- следующий слой исправлений должен быть точечным и не должен ломать действующие inventory selected-object rails. diff --git a/docs/ADDRESS/address_query/project_status_update_2026-04-15.md b/docs/ADDRESS/address_query/project_status_update_2026-04-15.md new file mode 100644 index 0000000..56a5f2d --- /dev/null +++ b/docs/ADDRESS/address_query/project_status_update_2026-04-15.md @@ -0,0 +1,78 @@ +# Project Status Update (2026-04-15) + +Дата: 2026-04-15 +Статус: `incremental update` +Базовые статусные документы: + +- `project_status_rails_graph_2026-04-08.md` +- `step5_architecture_ux_quality_plan_v1_2026-04-08.md` + +## 1. Новое подтвержденное улучшение + +В address/orchestration runtime внедрен bounded guard: + +- inventory drilldown -> tax/VAT/adjacent accounting pivot +- не тащит selected item в новый домен +- сохраняет только root context + +Это снижает риск cross-domain contamination без отката follow-up памяти внутри inventory domain. + +## 2. Что этот апдейт не должен означать + +Этот инкремент не означает, что: + +1. все follow-up проблемы уже решены; +2. inventory root-queries полностью изолированы от предыдущих VAT frame; +3. любой selected-object chain теперь устойчив при limited answers; +4. answer-shape policy для meta follow-up уже приведена в порядок. + +То есть статус должен читаться как: + +- один архитектурный риск закрыт; +- несколько соседних policy-рельс остаются открытыми. + +## 3. Свежий live snapshot по `test.txt` + +### Подтверждено + +1. VAT exact route жив. +2. Data-scope selection по организациям жив. +3. Clarification по множественным организациям в этом сценарии корректный. +4. Sale trace по выбранной позиции запускается как exact capability. + +### Открытые инциденты + +1. Meta follow-up после VAT summary повторяет старый factual answer. +2. Inventory root-query после VAT все еще может нести чужой `previous_intent`. +3. Short purchase follow-up после limited sale step может выпадать в `non_domain_query_indexed`. +4. Entity extraction все еще допускает мусорный warehouse anchor из temporal phrase. + +## 4. Риск неправильной интерпретации + +По этому состоянию нельзя говорить: + +- "архитектура диалога развалилась полностью" +- "inventory sale trace не работает вообще" +- "организация выбирается хаотично из observed rows" + +Более точная формулировка: + +- exact routes частично живы; +- bounded carryover hardening движется в правильную сторону; +- основной оставшийся риск — несогласованность соседних follow-up policies. + +## 5. Что держим как ближайший operational focus + +1. Meta follow-up answer-shape hardening. +2. Root-domain pivot reset для новых inventory root-queries. +3. Selected-object continuity after limited result. +4. Invalid entity admissibility guard for `warehouse`. + +## 6. Acceptance note + +Следующие исправления должны идти как отдельные bounded changes с targeted regressions. +Нельзя закрывать статус формулировкой "follow-up problem solved globally", пока: + +- `это много или мало?` после VAT не дает короткий evaluative answer; +- `а купили у кого` после limited inventory step не держит selected-object continuity; +- `за май` может попасть в `warehouse`. diff --git a/docs/ADDRESS/address_query/step5_increment_update_2026-04-15.md b/docs/ADDRESS/address_query/step5_increment_update_2026-04-15.md new file mode 100644 index 0000000..5f36917 --- /dev/null +++ b/docs/ADDRESS/address_query/step5_increment_update_2026-04-15.md @@ -0,0 +1,132 @@ +# Step-5 Increment Update (2026-04-15) + +Дата: 2026-04-15 +Статус: `active` +Связанные документы: + +- `step5_architecture_ux_quality_plan_v1_2026-04-08.md` +- `project_status_rails_graph_2026-04-08.md` +- `followup_context_root_pivot_audit_2026-04-15.md` + +## 1. Что именно обновлено в runtime + +В текущем инкременте зафиксирован bounded fix для cross-domain carryover: + +1. Если пользователь находится внутри inventory drilldown по выбранной позиции, +2. и следующим коротким сообщением делает pivot в чужой учетный домен, +3. runtime больше не тянет selected item в новый route, +4. а сохраняет только root context. + +Практически это выражается так: + +- `followupSelectionMode = carry_root_context` +- `followupContext.root_context_only = true` + +Сохраняются только root-level поля: + +- `organization` +- `warehouse` +- `as_of_date` +- `period_from` +- `period_to` + +Не сохраняются: + +- `item` +- object-level `previous_intent` +- object-level anchor + +## 2. Почему это решение считается чистым + +Это решение не спорит с текущей архитектурой rails, потому что: + +1. не меняет execution semantics existing exact capabilities; +2. не отключает inventory selected-object carryover внутри inventory domain; +3. не подменяет root frame object frame-ом и наоборот; +4. не лечит UX-проблемы через query-level костыли. + +То есть это не "новая архитектура", а аккуратный guard на границе между: + +- follow-up orchestration, +- selected-object navigation, +- domain pivot policy. + +## 3. Что показал свежий live audit + +Разбор прогона из `C:\Users\DCTOUCH\Desktop\test.txt` подтверждает: + +### Уже нормально + +1. VAT root route сам по себе работает. +2. Data-scope вопрос по базе/организации работает. +3. Multiple-organization clarification в этом сценарии работает корректно. +4. Inventory selected-object sale trace доходит до exact capability. + +### Еще не закрыто + +1. Meta follow-up `это много или мало?` все еще replays предыдущий factual VAT answer. +2. Новый inventory root-query после VAT все еще несет `previous_intent = vat_payable_forecast`. +3. После limited selected-object sale step короткое `а купили у кого` может выпасть в `non_domain_query_indexed`. +4. В extraction все еще возможен мусорный `warehouse = "за май"`. + +## 4. Как интерпретировать эти дефекты + +Важно не смешивать разные классы проблем: + +### A. Route/intent problem + +Когда нужный intent вообще не распознан или address lane не запускается. + +### B. Retrieval/execution problem + +Когда route выбран корректно, но retrieval возвращает `empty_match` / `no_raw_rows`. + +### C. Answer-shape problem + +Когда данные/intent корректны, но форма ответа не соответствует пользовательскому вопросу. + +### D. Follow-up continuity problem + +Когда новый короткий вопрос не унаследовал допустимый контекст из предыдущего шага. + +Свежий прогон содержит все четыре типа, но в разных местах. Это важно для планирования, чтобы не чинить retrieval-пустоту как orchestration-баг и наоборот. + +## 5. Следующий bounded backlog + +### P0 + +1. `meta_followup_answer_policy` + Для evaluative short follow-up поверх VAT/tax factual summary запретить replay полного ответа. + +2. `root_domain_pivot_resets_previous_intent` + Для нового root inventory query после VAT/tax не тянуть `previous_intent` из чужого домена. + +3. `selected_object_continuity_after_limited_result` + После limited inventory drilldown answer short follow-up должен оставаться в address lane. + +### P1 + +1. `invalid_warehouse_anchor_guard` + Темпоральные куски вроде `за май` не должны заполнять `warehouse`. + +2. `acceptance_split_for_empty_match` + В acceptance нужно отдельно учитывать: + - `route matched but empty` + - `route not matched` + - `follow-up continuity lost` + +## 6. Обязательные regression chains + +1. `прогноз НДС -> это много или мало?` +2. `прогноз НДС -> остаток на складе за май 2020` +3. `inventory selected item -> кому продали -> а купили у кого` +4. `остаток на складе за май 2020` с проверкой admissibility для `warehouse` + +## 7. Итог + +Состояние на 2026-04-15 выглядит так: + +- bounded root-context-only fix — правильный; +- inventory rails не надо откатывать; +- текущие open issues лежат рядом с ним, а не внутри него; +- дальнейшая работа должна идти через изолированные policy/acceptance fixes, а не через очередной общий rewrite follow-up логики. diff --git a/docs/ARCH/10 - current_assistant_architecture_2026-04-15.md b/docs/ARCH/10 - current_assistant_architecture_2026-04-15.md new file mode 100644 index 0000000..4c466fa --- /dev/null +++ b/docs/ARCH/10 - current_assistant_architecture_2026-04-15.md @@ -0,0 +1,428 @@ +# 10 - Текущая архитектура ассистента на 2026-04-15 + +## 1. Назначение документа + +Этот документ фиксирует не историческую эволюцию, а текущий рабочий архитектурный срез ассистента в проекте `NDC_1C`. + +Цель документа: +- зафиксировать, какие архитектурные блоки реально участвуют в runtime сейчас; +- отделить текущий работающий контур от ранних проектных отчетов и аудитов; +- дать опорную карту для дальнейшего bounded hardening без разрушения baseline. + +Документ не заменяет ранние отчеты в `docs/ARCH`, а накладывается на них как актуальный operational snapshot. + +## 2. Как читать `docs/ARCH` после этого обновления + +Исторические документы сохраняют свою роль: +- `1-4` — bootstrap, ранний pipeline, MVP и GUI/manual слой; +- `5` — ранний отчет по assistant mode; +- `6` — интегральный статус проекта и Stage 4 на конец марта; +- `7-9` — архитектурные аудиты, gap-регистры, source-to-proof и relation разведка. + +Текущий документ нужен для другого: +- не объяснить, как проект развивался; +- а зафиксировать, как именно устроен ассистент сейчас на уровне runtime, state, orchestration и capability routing. + +## 3. Executive Summary + +На текущем этапе ассистент представляет собой не один monolithic pipeline, а связку из пяти рабочих слоев: + +1. `Assistant / living router` — решает, идти ли в address/data lane, в data-scope lane или в обычный chat. +2. `Address orchestration runtime` — собирает predecompose, carryover, continuation contract и orchestration decision. +3. `Address exact execution lane` — выполняет exact-capability routes через `AddressQueryService`. +4. `Session memory + navigation state` — хранит `investigation_state`, `address_navigation_state`, `result_sets`, `focus_object`, `organization/date scope`. +5. `Answer/debug contract layer` — формирует ответ, debug payload, continuation contract и данные для GUI/debug drawer. + +Ключевая особенность текущей архитектуры: +- разговорный слой уже не является единственным носителем контекста; +- контекст все больше держится в структурированном session/navigation state; +- exact-capability routes уже существуют как отдельный runtime-контур, а не как побочный продукт общего chat-flow. + +## 4. Graphify-срез по кодовой базе + +По `graphify-out/GRAPH_REPORT.md` на `2026-04-15`: +- корпус: `473 files`; +- граф: `4865 nodes`, `10622 edges`, `132 communities`; +- среди god nodes находятся: + - `resolveAddressIntent()`; + - `composeFactualReply()`; + - `resolveAssistantOrchestrationDecision()`. + +Архитектурно самые важные community: +- `Community 2` — orchestration и `AssistantService`; +- `Community 6` — exact capability/runtime execution, включая `AddressQueryService`; +- `Community 14` — domain cards, domain purity, source gating; +- `Community 17` — navigation state, `focus_object`, `result_sets`, session carryover; +- `Community 20` — address orchestration runtime adapter и follow-up message policy. + +Это подтверждает, что текущая система уже раскладывается на отдельные runtime-блоки, а не живет в одном промптовом центре принятия решений. + +## 5. Текущая карта runtime + +### 5.1 Верхний уровень + +Текущий поток запроса выглядит так: + +`GUI/API -> AssistantService -> orchestration decision -> address runtime adapter -> AddressQueryService -> compose/debug/session persistence` + +На этом уровне уже зафиксированы три разных режима: +- `address_data`; +- `assistant_data_scope`; +- `chat`. + +Ключевая точка маршрутизации — `resolveAssistantOrchestrationDecision()` в `llm_normalizer/backend/src/services/assistantService.ts`. + +### 5.2 Session memory + +Сессионная память не ограничивается простым хранением истории сообщений. + +Текущий session store: +- `AssistantSessionStore`; +- `investigation_state`; +- `address_navigation_state`. + +Это означает, что архитектурно ассистент уже работает не только на transcript memory, но и на явном структурированном state. + +## 6. Orchestration слой + +### 6.1 Главный orchestration узел + +`AssistantService` является текущим orchestration-ядром ассистента. + +Он отвечает за: +- выбор living mode; +- resolution follow-up context; +- построение continuation contracts; +- связывание session memory с runtime execution; +- routing между address lane, data-scope и chat. + +На текущем этапе это уже не просто “controller”, а фактический координатор между state, semantics и runtime. + +### 6.2 Address orchestration runtime + +Для address/data контура вынесен отдельный adapter: +- `assistantAddressOrchestrationRuntimeAdapter.ts` + +Он выполняет: +- LLM predecompose или fallback predecompose; +- нормализацию `effectiveMessage`; +- resolution carryover context через `resolveAddressFollowupCarryoverContext()`; +- защиту от неудачных канонизаций через `shouldPreferRawFollowupMessage()`; +- построение `dialogContinuationContract`; +- формирование `addressRuntimeMeta`. + +Это важное отличие текущей архитектуры от ранних отчетов: +- predecompose; +- carryover; +- continuation contract; +- orchestration decision + +теперь собраны в отдельный runtime-блок, а не размазаны по нескольким ad hoc helper-ам. + +## 7. Session state и navigation state + +### 7.1 Что реально хранится + +`addressNavigationState.ts` фиксирует текущую модель навигационного состояния. + +Текущий `session_context` включает: +- `active_result_set_id`; +- `active_focus_object`; +- `last_confirmed_route`; +- `date_scope`: + - `as_of_date`; + - `period_from`; + - `period_to`; +- `organization_scope`. + +Отдельно хранятся: +- `result_sets[]`; +- `navigation_history[]`. + +### 7.2 Что это значит архитектурно + +Текущий диалоговый контекст больше не должен держаться только “в голове модели”. + +У системы уже есть структурированные сущности: +- результат ответа как `result_set`; +- выбранный объект как `focus_object`; +- подтвержденный маршрут как `last_confirmed_route`; +- текущий root scope как `organization/date scope`. + +Это база для устойчивых drilldown-сценариев. + +### 7.3 Как state эволюционирует + +`evolveAddressNavigationStateWithAssistantItem()` делает следующее: +- вынимает из assistant reply `detected_intent`, `selected_recipe`, `extracted_filters`; +- строит `result_set_id`; +- при наличии — строит `focus_object`; +- пишет navigation event; +- обновляет active result/focus/route/date/org scope. + +Важно: +- `active_focus_object` не затирается автоматически при отсутствии нового focus; +- `organization_scope` и `date_scope` также тянутся как долговременный session context. + +## 8. Follow-up и continuation semantics + +### 8.1 Carryover контекст + +`resolveAddressFollowupCarryoverContext()` сейчас является центральным узлом follow-up логики. + +Он собирает carryover из: +- предыдущего address reply; +- navigation state; +- organization clarification state; +- implicit continuation signal; +- selected object / displayed entities; +- inventory root frame. + +### 8.2 Root frame и drilldown frame + +В текущей реализации уже присутствует различие между: +- `inventory_root`; +- `inventory_drilldown`; +- `generic`. + +То есть архитектура уже ушла от полностью плоского carryover. + +Фактически в runtime уже живут два уровня: +- root context: + - организация; + - дата/период; + - root intent; +- object/drilldown context: + - item/counterparty/contract/focus object; + - drilldown intent. + +### 8.3 Root-only carryover + +Отдельно введен режим `carry_root_context` / `root_context_only`. + +Он используется в тех местах, где нужно: +- сохранить организацию и temporal scope; +- но не тянуть старый object-level intent в новый доменный вопрос. + +Архитектурно это важный шаг: система уже умеет не только продолжать предыдущий вопрос, но и ограничивать глубину carryover. + +### 8.4 Continuation contract + +`buildAddressDialogContinuationContractV2()` сейчас формирует отдельный machine-readable контракт продолжения. + +Он хранит: +- `decision`; +- `decision_reasons`; +- `previous_intent`; +- `target_intent`; +- `intent_selection_mode`; +- `anchor_type`; +- `anchor_value`; +- `implicit_continuation_signal`. + +Это уже полноценный runtime artifact, а не побочный debug-комментарий. + +## 9. Exact capability runtime + +### 9.1 Role of `AddressQueryService` + +`AddressQueryService` — текущий exact execution engine для address/data маршрутов. + +Основной поток внутри `tryHandle()`: +- `runAddressDecomposeStage()`; +- pre-execution grounding; +- organization clarification / scope resolution; +- requested result mode resolution; +- capability route decision; +- recipe selection; +- exact MCP query execution; +- materialization; +- scoped filtering; +- future/temporal guards; +- limited/factual reply building. + +### 9.2 Что изменилось относительно ранних отчетов + +Текущий address runtime уже не является “одним generic execute_query на удачу”. + +Сейчас в нем есть: +- capability route guard; +- route expectation audit; +- organization clarification path; +- intent-specific filter layer; +- lifecycle detachment semantics; +- historical/broadened retry layers; +- exact/limited result policy. + +То есть ранняя картина из `ARCH/5` уже устарела: exact-capability execution есть и он занимает отдельный крупный слой runtime. + +### 9.3 Capability policy + +`addressCapabilityPolicy.ts` фиксирует точные capability routes. + +На текущем этапе в policy явно присутствуют, в частности: +- inventory: + - `inventory_on_hand_as_of_date`; + - `inventory_purchase_provenance_for_item`; + - `inventory_purchase_documents_for_item`; + - `inventory_supplier_stock_overlap_as_of_date`; + - `inventory_sale_trace_for_item`; + - `inventory_purchase_to_sale_chain`; + - `inventory_aging_by_purchase_date`; +- VAT: + - `vat_payable_confirmed_as_of_date`; + - `vat_liability_confirmed_for_tax_period`. + +Это важно зафиксировать отдельно: +- capability layer уже живет как explicit policy map; +- она не должна снова расползаться в “общую умность”. + +## 10. Data/domain слой + +### 10.1 Domain purity + +`assistantDataLayer.ts` продолжает играть роль доменного ограничителя и source gate. + +В нем присутствуют: +- `domain_scope`; +- `forbidden_cross_domain_leakage`; +- source gating; +- graph traversal profiles; +- live MCP call planning; +- domain card based filtering. + +### 10.2 Почему это важно для текущей архитектуры + +Даже если текущая задача решается в address exact lane, система уже опирается на более широкий доменный слой: +- domain cards; +- source purity; +- graph-aware retrieval planning; +- forbidden cross-domain leakage rules. + +Следовательно, любые новые hardening-решения нельзя строить как локальные regex-патчи в inventory/VAT ветках, если они конфликтуют с domain purity моделями. + +## 11. Data source модель + +Текущий runtime работает как минимум с двумя типами источников: +- live MCP / `execute_query` и каталожные резолверы; +- snapshot/canonical/risk материалы, участвующие в более широком assistant/data контуре. + +Для address exact lane центральным остается live execution, но: +- orchestration уже знает про data-scope и domain routes; +- в проекте сохраняется split между lightweight live path и более широкими доказательными/аналитическими слоями. + +## 12. Debug, contracts и observability + +Текущая архитектура сильно опирается на machine-readable debug artifacts. + +На runtime-уровне зафиксированы: +- `technical_debug_payload_json`; +- `addressRuntimeMeta`; +- `orchestrationContract`; +- `dialogContinuationContract`; +- `route_expectation_*`; +- `capability_id`; +- `selected_recipe`; +- `limited_reason_category`. + +Для проекта это уже не “вспомогательная телеметрия”, а часть архитектуры: +- через эти контракты GUI, аудит и domain loop понимают, что именно решила система; +- без них bounded hardening быстро превращается в непрозрачную серию случайных патчей. + +## 13. Feature-flag слой + +В текущем runtime существенная часть поведения завязана на feature flags. + +Ключевые flags, влияющие на архитектуру: +- `FEATURE_ASSISTANT_ADDRESS_QUERY_V1`; +- `FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1`; +- `FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1`; +- `FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1`; +- `FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1`; +- `FEATURE_ASSISTANT_INVESTIGATION_STATE_V1`; +- `FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1`; +- `FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1`; +- `FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1`; +- `FEATURE_ASSISTANT_CONTRACTS_V11`; +- `FEATURE_ASSISTANT_ANSWER_POLICY_V11`. + +Архитектурно это значит: +- текущая система уже modularized; +- но operational profile зависит от конфигурации flags, а не только от кода. + +## 14. Что устарело в ранних архитектурных документах + +### 14.1 Что больше нельзя считать точным описанием текущего состояния + +Ранние документы `ARCH/5` и частично `ARCH/6_global_report` полезны как история, но не как точный snapshot текущего runtime. + +Устаревшими в них являются, в частности, представления о том, что: +- assistant mode в основном сводится к answer composer + session memory; +- retrieval plan еще в основном stubbed; +- routing и follow-up существуют как легкая надстройка поверх ответа. + +На текущем этапе это уже не так: +- continuation contracts существуют; +- navigation state существует; +- exact capability runtime существует; +- route expectation / capability guard / organization clarification существуют; +- root/drilldown carryover уже встроен в orchestrator. + +### 14.2 Как использовать старые документы теперь + +Старые отчеты полезны: +- как описание этапов развития; +- как baseline для исторического сравнения; +- как объяснение происхождения guardrails и audit criteria. + +Но для описания того, “как работает ассистент сейчас”, опорным должен считаться уже этот документ. + +## 15. Текущие архитектурные инварианты + +На момент `2026-04-15` безопасно считать архитектурными инвариантами следующее: + +1. Exact business routes должны жить как explicit capability/runtime paths, а не как heuristic chat imitation. +2. Follow-up continuity должна опираться не только на transcript, но и на structured state. +3. `focus_object`, `result_set`, `organization_scope`, `date_scope` являются частью архитектуры, а не UX-деталью. +4. Root context и drilldown context нельзя больше считать одной плоской переменной “контекст разговора”. +5. Machine-readable debug/contracts обязательны для объяснимости и bounded hardening. +6. Domain purity и forbidden cross-domain leakage уже являются встроенными guardrails и должны учитываться при любом расширении. + +## 16. Текущие ограничения + +Несмотря на значительный сдвиг архитектуры, текущая система еще не должна описываться как полностью закрытая production architecture. + +Остаются ограничения: +- часть follow-up semantics еще зависит от LLM predecompose и quality canonicalization; +- часть object-level continuity еще удерживается комбинацией transcript + navigation state, а не единым frame engine; +- не все capability routes одинаково зрелые по depth и proof closure; +- hardening по meta-follow-up, pivot semantics и field admissibility еще остается отдельным слоем работы. + +## 17. Практический итог + +Текущий ассистент уже нельзя описывать как “чат над 1С”. + +Более точная формулировка: + +`структурированный orchestration runtime + session/navigation state + exact address capability engine + domain/data guardrails` + +Именно в этой рамке должны приниматься следующие решения: +- не через новый общий rewrite; +- не через промптовую переумность; +- а через bounded усиление уже существующих архитектурных блоков. + +## 18. Связанные документы + +Исторический baseline: +- `docs/ARCH/5 - assistant_mode_architecture_report_2026-03-24.md` +- `docs/ARCH/6 - project_and_stage4_full_report_2026-03-28.md` +- `docs/ARCH/6_global_report/Assistant_Mode_GLOBAL_STATUS_2026-03-24.md` + +Аудит и gap-анализ: +- `docs/ARCH/7 - аудит архитектуры на 4 этапе/7 - assistant_runtime_ground_truth_audit_2026-03-28.md` +- `docs/ARCH/8_audit_artifacts/8 - аудит source_to_proof_по_3_контрольным_вопросам_2026-03-29.md` +- `docs/ARCH/9_audit_artifacts/9 - разведка_структуры_1с_и_связей_через_mcp_по_3_контрольным_вопросам_2026-03-29.md` +- `docs/ARCH/9_audit_artifacts/9F - current_runtime_vs_required_runtime.md` + +Следующий operational документ: +- `docs/ARCH/10A - current_assistant_hardening_plan_2026-04-15.md` diff --git a/docs/ARCH/10A - current_assistant_hardening_plan_2026-04-15.md b/docs/ARCH/10A - current_assistant_hardening_plan_2026-04-15.md new file mode 100644 index 0000000..4f11630 --- /dev/null +++ b/docs/ARCH/10A - current_assistant_hardening_plan_2026-04-15.md @@ -0,0 +1,247 @@ +# 10A - План bounded hardening текущей архитектуры на 2026-04-15 + +## 1. Назначение документа + +Этот документ фиксирует не общий wishlist и не разовый разбор отдельного прогона, а bounded hardening plan для текущей архитектуры ассистента. + +Цель: +- усилить существующие блоки; +- не снести рабочий baseline; +- не ввести решения, противоречащие уже внедренным architectural rails. + +Документ опирается на текущий runtime, зафиксированный в: +- `docs/ARCH/10 - current_assistant_architecture_2026-04-15.md` +- `graphify-out/GRAPH_REPORT.md` + +## 2. Принципы выполнения + +Все дальнейшие изменения должны проходить по следующим правилам: + +1. Не переписывать runtime foundations. +2. Не растворять exact capability routes обратно в “общую умность”. +3. Не ломать working baseline ради одного edge-case. +4. Укреплять сначала orchestration/state/policy слой, а не маскировать проблемы answer wording-ом. +5. Любой новый hardening должен быть выражен либо как: + - новый guard; + - новая policy; + - новый state invariant; + - новый regression test. + +## 3. Что уже считается baseline и не должно деградировать + +На текущем этапе надо считать защищаемым baseline следующее: +- living router между `address_data`, `assistant_data_scope` и `chat`; +- `AddressQueryService` как отдельный exact execution lane; +- `addressNavigationState` с `result_set`, `focus_object`, `organization_scope`, `date_scope`; +- `dialogContinuationContractV2`; +- `capability route guard` и `route expectation audit`; +- inventory selected-object follow-up как отдельный сценарный класс; +- organization clarification path; +- limited mode вместо ложного “подтвержденного” ответа при незакрытом proof path. + +Любой фикс, который делает систему “умнее”, но при этом размывает эти элементы, считается архитектурно спорным. + +## 4. Главные линии bounded hardening + +### 4.1 Явное различение root frame и object frame + +Что уже есть: +- `organization_scope`; +- `date_scope`; +- `active_focus_object`; +- `current_frame_kind`; +- `root_context_only`. + +Что надо усилить: +- перестать трактовать carryover как один общий пакет фильтров; +- закрепить root-level и object-level continuation как разные режимы; +- не позволять object-intent автоматически переживать любой domain pivot. + +Практический смысл: +- `root context` должен переживать больше типов follow-up; +- `object context` должен переноситься только в совместимых сценариях. + +### 4.2 Жесткий policy-слой для domain pivot + +Текущая архитектура уже содержит: +- `domain_scope`; +- `forbidden_cross_domain_leakage`; +- `assistant_data_scope_query_detected`; +- `non_domain_query_indexed`. + +Надо закрепить общий policy: +- если новый вопрос совместим с текущим drilldown domain — можно использовать object carryover; +- если новый вопрос уходит в соседний, но поддержанный домен — переносим только root context; +- если новый вопрос не собрался, нельзя автоматически продолжать предыдущий object route. + +Это должен быть orchestration-level guard, а не частный патч в inventory или VAT. + +### 4.3 Meta-follow-up как отдельный класс вопросов + +В текущем контуре мета-вопросы типа: +- `это много или мало?` +- `это критично?` +- `это нормально?` +- `что из этого важнее?` + +не должны притворяться повторным exact business query. + +Надо закрепить отдельную политику: +- meta-follow-up не должен слепо replay-ить предыдущий ответ; +- он должен опираться на предыдущий answer object/result, а не запускать старый route без надобности; +- meta-layer должен быть отделен от exact data route. + +### 4.4 Admissibility и truthfulness guard для entity/field extraction + +Текущий runtime уже умеет: +- отбрасывать часть низкокачественных anchor-ов; +- использовать clarification; +- сохранять limited mode. + +Следующий bounded шаг: +- ввести общий admissibility слой для extracted entity values; +- не позволять деградации полного anchor-а до обрубка; +- не позволять generic semantic hint перетирать уже подтвержденный selected object; +- не позволять неподтвержденному полю мимикрировать под бизнес-роль. + +Это особенно важно для: +- item/counterparty/contract anchors; +- selected-object lifecycle follow-up; +- purchase/sale provenance chains. + +### 4.5 Stabilization слоя selected-object continuity + +Selected-object continuity уже является частью реальной архитектуры, а не UX-дополнением. + +Нужно закрепить инварианты: +- выбранный объект не должен теряться из-за слабого rewrite; +- короткий follow-up не должен автоматически сбрасывать focus; +- новый явно выбранный объект должен приоритетно заменять старый; +- `focus_object` и `previous_anchor` не должны конфликтовать. + +Это должно проверяться не на одном кейсе, а на классе сценариев: +- canonical wording; +- colloquial wording; +- UI selected-object wording; +- короткий follow-up; +- follow-up после limited answer. + +### 4.6 Оркестрационный разрыв между supported route и unsupported carryover + +Одна из основных зон риска — когда: +- route поддержан; +- но carryover прилетает в неправильной форме; +- и система либо уходит в wrong-route, либо в generic partial. + +Надо усиливать границу: +- carryover policy и route policy должны быть согласованы; +- если carryover сомнителен, exact route не должен получать мусорный anchor; +- если route валиден, generic conversational fallback не должен подменять его без явной причины. + +## 5. Что нельзя делать в рамках этого плана + +Чтобы не войти в архитектурный конфликт с текущей системой, в bounded hardening нельзя: + +1. Вводить новый “универсальный супер-классификатор”, который обходил бы `AssistantService` и `AddressQueryService`. +2. Дублировать domain purity правила в локальных regex-патчах конкретных capability. +3. Переносить session/navigation state обратно в transcript-only memory. +4. Сливать `root context` и `focus object` обратно в один плоский набор фильтров. +5. Чинить системные проблемы через answer wording без policy/runtime изменения. +6. Маскировать `empty_match`, `missing_anchor`, `recipe_visibility_gap`, `execution_error` под “подтвержденный ответ”. + +## 6. Приоритетный порядок внедрения + +### P0. Сохранение архитектурной формы + +Сначала должны усиливаться те места, которые защищают уже сложившуюся форму runtime: +- root vs object carryover policy; +- selected-object continuity; +- domain pivot guard; +- admissible entity carryover. + +### P1. Meta и reasoning поверх результата + +После этого: +- meta-follow-up policy; +- reuse answer object/result object; +- bounded comparative/explanatory follow-up. + +### P2. Глубина доказательного маршрута + +После стабилизации orchestration/state слоя: +- расширение object-level proof paths; +- новые exact capability routes; +- deeper provenance/sale/purchase chains; +- richer document relation recovery. + +## 7. Обязательные acceptance-invariants + +Любое изменение по этому плану должно проверяться по invariants, а не только по “кажется, ответ стал лучше”. + +Минимальный набор: + +1. `direct_answer_first` +2. `selected_object_memory_survives_short_followup` +3. `new_explicit_selected_object_overrides_old_focus` +4. `root_context_survives_domain_pivot_without_object_leak` +5. `unsupported_new_domain_does_not_replay_previous_exact_answer` +6. `full_anchor_not_degraded_by_canonical_rewrite` +7. `limited_mode_remains_truthful` +8. `exact_supported_route_not_shadowed_by_generic_chat_fallback` + +## 8. Требуемые классы автотестов + +### 8.1 Orchestration/state tests + +Нужны отдельные регрессии на: +- `carry_root_context`; +- `root_context_only`; +- `dialogContinuationContractV2`; +- explicit object switch; +- follow-up after limited result. + +### 8.2 Address runtime tests + +Нужны тесты на: +- full item anchor preservation; +- selected-object sale/purchase routes; +- organization clarification continuity; +- empty-match honesty; +- route expectation consistency. + +### 8.3 Router boundary tests + +Нужны тесты на переходы: +- `address_data -> assistant_data_scope`; +- `address_data -> chat`; +- `inventory drilldown -> tax/meta pivot`; +- `meta-followup -> no route replay`. + +## 9. Документальный результат, который должен остаться после внедрения + +Каждый крупный bounded hardening должен оставлять после себя: +- короткий architecture note в `docs/ARCH` или `docs/ADDRESS`; +- regression tests; +- при необходимости — audit addendum; +- обновленный graphify snapshot. + +То есть hardening должен завершаться не только кодом, но и явной фиксацией архитектурного состояния. + +## 10. Итоговая формула + +Правильный способ решать следующие проблемы в проекте: + +`не rewrite системы` + +а + +`bounded hardening существующих блоков` + +через: +- orchestration policy; +- structured state; +- domain compatibility; +- admissible carryover; +- exact capability discipline. + +Именно эта рамка считается безопасной для текущей архитектуры проекта. diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index a21edcb..6435ae5 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2778,6 +2778,48 @@ function hasShortInventoryObjectFollowupSignal(userMessage) { (0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) || (0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(sample)); } +function hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage = null) { + const samples = [ + compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()), + compactWhitespace(String(userMessage ?? "").toLowerCase()), + compactWhitespace(repairAddressMojibake(String(alternateMessage ?? "")).toLowerCase()), + compactWhitespace(String(alternateMessage ?? "").toLowerCase()) + ].filter((item) => item.length > 0); + if (samples.length === 0) { + return false; + } + return samples.some((sample) => /(?:ндс|vat|налог(?:и|ов|ом|у|ами|ах)?|налогов(?:ый|ого)?|tax(?:es)?|сч[её]т[\s-]?фактур|книга\s+покупок|книга\s+продаж|вычет)/iu.test(sample) || + /(?:амортиз|основн(?:ые|ых|ым)?\s+средств|fixed\s*asset|depreciat|\bос\b)/iu.test(sample) || + /(?:закрыти|месяц|затрат|рбп|period\s*close|month\s*close|allocation|residual|cost)/iu.test(sample) || + /(?:оплат|плат(?:е|ё)ж|аванс|зач(?:е|ё)т|выписк|statement|wire|settlement|payment|\b51(?:\.\d{1,2})?\b|\b60(?:\.\d{1,2})?\b|\b62(?:\.\d{1,2})?\b)/iu.test(sample)); +} +function buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame) { + const candidateFilters = inventoryRootFrame?.filters && typeof inventoryRootFrame.filters === "object" + ? inventoryRootFrame.filters + : previousFilters; + const nextFilters = {}; + const organization = toNonEmptyString(candidateFilters?.organization) ?? toNonEmptyString(previousFilters?.organization); + const warehouse = toNonEmptyString(candidateFilters?.warehouse) ?? toNonEmptyString(previousFilters?.warehouse); + const asOfDate = toNonEmptyString(candidateFilters?.as_of_date) ?? toNonEmptyString(previousFilters?.as_of_date); + const periodFrom = toNonEmptyString(candidateFilters?.period_from) ?? toNonEmptyString(previousFilters?.period_from); + const periodTo = toNonEmptyString(candidateFilters?.period_to) ?? toNonEmptyString(previousFilters?.period_to); + if (organization) { + nextFilters.organization = organization; + } + if (warehouse) { + nextFilters.warehouse = warehouse; + } + if (asOfDate) { + nextFilters.as_of_date = asOfDate; + } + if (periodFrom) { + nextFilters.period_from = periodFrom; + } + if (periodTo) { + nextFilters.period_to = periodTo; + } + return nextFilters; +} function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); if (!normalized || countTokens(normalized) > 10) { @@ -2916,7 +2958,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes } }; } - const currentFrameKind = inventoryRootFrame + let currentFrameKind = inventoryRootFrame ? isInventoryDrilldownFrameIntent(sourceIntent) ? "inventory_drilldown" : isInventoryRootFrameIntent(sourceIntent) @@ -2925,7 +2967,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes : null; let resolvedCounterpartyFromDisplay = false; const previousFiltersRaw = previousAddressDebug.extracted_filters; - const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" + let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {}; if (!toNonEmptyString(previousFilters.contract)) { @@ -2961,13 +3003,23 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) { previousFilters.period_to = toNonEmptyString(navigationDateScope?.period_to); } + const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && + hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); + if (rootContextOnlyPivot) { + previousIntent = null; + previousAnchorType = null; + previousAnchor = null; + previousFilters = buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame); + currentFrameKind = inventoryRootFrame ? "inventory_root" : currentFrameKind; + followupSelectionMode = "carry_root_context"; + } const displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent); const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType); const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ?? (toNonEmptyString(alternateMessage) ? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities) : null); - if (resolvedEntityFromFollowup) { + if (resolvedEntityFromFollowup && !rootContextOnlyPivot) { if (resolvedEntityFromFollowup.entityType === "counterparty") { previousFilters.counterparty = resolvedEntityFromFollowup.value; previousAnchorType = "counterparty"; @@ -2988,7 +3040,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes followupSelectionMode = "carry_referenced_entity"; } } - if (!toNonEmptyString(previousFilters.item) && + if (!rootContextOnlyPivot && + !toNonEmptyString(previousFilters.item) && navigationFocusObjectType === "item" && navigationFocusObjectLabel && (sourceIntentHint === "inventory_on_hand_as_of_date" || @@ -3028,6 +3081,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_value: previousAnchor, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, + root_context_only: rootContextOnlyPivot || undefined, root_intent: inventoryRootFrame?.intent ?? undefined, root_filters: inventoryRootFrame?.filters ?? undefined, root_anchor_type: inventoryRootFrame?.anchorType ?? undefined, @@ -3047,10 +3101,13 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage, const hasFollowupContext = Boolean(carryoverMeta?.followupContext); const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null; const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null; + const rootContextOnly = selectionMode === "carry_root_context"; const explicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const targetIntent = selectionMode === "switch_to_suggested_intent" ? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null - : explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; + : rootContextOnly + ? explicitIntent ?? null + : explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal); const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase()); const hasExplicitIntent = Boolean(explicitIntent); @@ -3075,6 +3132,9 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage, if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) { reasons.push("operation_intent_from_current_message"); } + if (rootContextOnly) { + reasons.push("root_context_only_carryover"); + } return { schema_version: "address_dialog_continuation_contract_v2", source_message: sourceMessage, @@ -4471,19 +4531,25 @@ function resolveAssistantOrchestrationDecision(input) { const semanticDeepInvestigationHintDetected = semanticGuardHints?.deep_investigation_signal_detected === true; const semanticAggregateShapeDetected = semanticExtraction?.query_shape === "AGGREGATE_LOOKUP" || semanticExtraction?.aggregation_profile === "management_profile"; + const rootContextOnlyFollowup = Boolean(followupContext && followupContext.root_context_only === true); const followupSemanticOverrideToDeepAllowed = Boolean(followupContext && !supportedAddressIntentDetected && - (llmContractMode === "unsupported" || + (rootContextOnlyFollowup || + llmContractMode === "unsupported" || semanticAggregateShapeDetected || semanticDeepInvestigationHintDetected || !semanticApplyCanonicalRecommended)); const unsupportedIntentOrMode = (resolvedModeDetection.mode !== "address_query" && resolvedIntentResolution.intent === "unknown") || - llmContractMode === "unsupported"; + llmContractMode === "unsupported" || + (rootContextOnlyFollowup && + resolvedIntentResolution.intent === "unknown" && + (!llmContractIntent || llmContractIntent === "unknown")); const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane && !llmRuntimeUnavailableDetected && unsupportedIntentOrMode && strongDataSignal && - (llmContractMode === "deep_analysis" || + (rootContextOnlyFollowup || + llmContractMode === "deep_analysis" || !dataRetrievalSignal || strictDeepInvestigationCueDetected || semanticDeepInvestigationHintDetected || diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 76d5c32..fe6171a 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -1177,6 +1177,11 @@ function trimInventoryItemArrowSuffix(rawValue: string): string { } function isTemporalWarehousePhrase(candidate: string): boolean { + const temporalZaPattern = + /^(?:за)\s+(?:январ(?:е|ь)|феврал(?:е|ь)|март(?:е)?|апрел(?:е|ь)|ма(?:й|е)|июн(?:е|ь)|июл(?:е|ь)|август(?:е)?|сентябр(?:е|ь)|октябр(?:е|ь)|ноябр(?:е|ь)|декабр(?:е|ь))(?:\s+\d{4}(?:\s+г(?:\.|ода)?)?)?$/iu; + if (temporalZaPattern.test(cleanupAnchorValue(candidate).toLowerCase().replace(/ё/g, "е").trim())) { + return true; + } const normalized = cleanupAnchorValue(candidate) .toLowerCase() .replace(/ё/g, "е") diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 5df17c5..15fc396 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -91,6 +91,12 @@ function hasSameDateHint(text: string): boolean { ); } +function hasSamePeriodHint(text: string): boolean { + return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test( + String(text ?? "") + ); +} + function hasExplicitPeriodLiteral(text: string): boolean { return /(?:^|[^\d*×xх])((?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?)(?=$|[^\d*×xх])/iu.test(String(text ?? "")); } @@ -487,10 +493,19 @@ function shouldRestoreInventoryRootFrame( const previousIntent = followupContext.previous_intent; const comingFromInventoryDrilldown = currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent); - if (!comingFromInventoryDrilldown) { + const normalized = String(userMessage ?? ""); + const hasInventoryRootRestatementCue = + /(?:склад|остат(?:ок|ки)|позици(?:я|и|ю)|товар(?:ы|ов)?|номенклатур)/iu.test(normalized) && + /(?:покажи|показать|выведи|раскрой|еще\s+раз|ещ[её]\s+раз|снова|опять|верни|вернись|повтори|тот\s+же|этот\s+же|same|again)/iu.test( + normalized + ); + const canReenterInventoryRoot = + comingFromInventoryDrilldown || + (currentFrameKind === "inventory_root" && hasSamePeriodHint(normalized)) || + (currentFrameKind === "generic" && hasInventoryRootRestatementCue && hasSamePeriodHint(normalized)); + if (!canReenterInventoryRoot) { return false; } - const normalized = String(userMessage ?? ""); if ( hasSelectedObjectInventorySignal(normalized) || hasInventorySupplierFollowupCue(normalized) || @@ -508,6 +523,7 @@ function shouldRestoreInventoryRootFrame( const hasTemporalPatch = hasExplicitPeriodWindow(extractedFilters) || Boolean(toNonEmptyString(extractedFilters.as_of_date)) || + hasSamePeriodHint(normalized) || hasExplicitPeriodLiteral(normalized) || Boolean(resolveRelativeMonthPeriodFromInventoryRoot(normalized, followupContext)); return hasTemporalPatch; @@ -651,6 +667,7 @@ function mergeFollowupFilters( const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext); const allTimeRequested = hasAllTimeHint(userMessage); const sameDateRequested = hasSameDateHint(userMessage); + const samePeriodRequested = hasSamePeriodHint(userMessage); if (!toNonEmptyString(merged.organization) && previousOrganization) { merged.organization = previousOrganization; reasons.push("organization_from_followup_context"); @@ -821,6 +838,24 @@ function mergeFollowupFilters( reasons.push("as_of_date_from_followup_context"); } } + if ( + samePeriodRequested && + (intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") + ) { + if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) { + merged.period_from = previousPeriodFrom; + reasons.push("period_from_from_followup_context"); + } + if (previousPeriodTo && merged.period_to !== previousPeriodTo) { + merged.period_to = previousPeriodTo; + reasons.push("period_to_from_followup_context"); + } + 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 ( !sameDateRequested && (intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) && diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index bf7c3cc..9b48056 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -99,6 +99,51 @@ function findLastGroundedInventoryAddressDebug(items: unknown[]): Record | null { + if (!Array.isArray(items)) { + return null; + } + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] as { role?: string; debug?: Record } | null; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + const debug = item.debug; + if (String(debug.execution_lane ?? "") !== "address_query") { + continue; + } + const extractedFilters = + debug.extracted_filters && typeof debug.extracted_filters === "object" + ? (debug.extracted_filters as Record) + : null; + const itemLabel = + String(extractedFilters?.item ?? "").trim() || + (String(debug.anchor_type ?? "") === "item" + ? String(debug.anchor_value_resolved ?? debug.anchor_value_raw ?? "").trim() + : ""); + if (itemLabel) { + return debug; + } + } + return null; +} + +function findLastAddressDebug(items: unknown[]): Record | null { + if (!Array.isArray(items)) { + return null; + } + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] as { role?: string; debug?: Record } | null; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + if (String(item.debug.execution_lane ?? "") === "address_query") { + return item.debug; + } + } + return null; +} + function buildInventoryHistoryCapabilityFollowupReply(input: { organization: string | null; addressDebug: Record | null; @@ -135,6 +180,55 @@ function buildInventoryHistoryCapabilityFollowupReply(input: { ].join("\n"); } +function buildAddressMemoryRecapReply(input: { + organization: string | null; + addressDebug: Record | null; + toNonEmptyString: (value: unknown) => string | null; +}): string { + const extractedFilters = + input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" + ? (input.addressDebug.extracted_filters as Record) + : null; + const rootFrameContext = + input.addressDebug?.address_root_frame_context && typeof input.addressDebug.address_root_frame_context === "object" + ? (input.addressDebug.address_root_frame_context as Record) + : null; + const item = + input.toNonEmptyString(extractedFilters?.item) ?? + (String(input.addressDebug?.anchor_type ?? "") === "item" + ? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ?? + input.toNonEmptyString(input.addressDebug?.anchor_value_raw) + : null); + const organization = + input.organization ?? + input.toNonEmptyString(extractedFilters?.organization) ?? + input.toNonEmptyString(rootFrameContext?.organization); + const scopedDate = + formatIsoDateForReply(extractedFilters?.as_of_date) ?? + formatIsoDateForReply(rootFrameContext?.as_of_date) ?? + formatIsoDateForReply(extractedFilters?.period_to); + + if (item) { + const datePart = scopedDate ? ` в срезе на ${scopedDate}` : ""; + const organizationPart = organization ? ` по компании «${organization}»` : ""; + return [ + `Да, помню. Мы обсуждали позицию «${item}»${organizationPart}${datePart}.`, + "Могу продолжить по ней без переписывания сущности: кто поставил, когда купили, по каким документам или кому продали." + ].join(" "); + } + + if (organization || scopedDate) { + const organizationPart = organization ? ` по компании «${organization}»` : ""; + const datePart = scopedDate ? ` на ${scopedDate}` : ""; + return [ + `Да, помню. Мы уже смотрели адресный контур${organizationPart}${datePart}.`, + "Могу кратко напомнить контекст или сразу продолжить следующий шаг по этому же сценарию." + ].join(" "); + } + + return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; +} + export async function runAssistantLivingChatRuntime( input: AssistantLivingChatRuntimeInput ): Promise { @@ -157,9 +251,14 @@ export async function runAssistantLivingChatRuntime( let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization); const contextualInventoryHistoryCapabilityFollowup = input.modeDecision?.reason === "inventory_history_capability_followup_detected"; + const contextualMemoryRecapFollowup = + input.modeDecision?.reason === "memory_recap_followup_detected"; const lastGroundedInventoryAddressDebug = contextualInventoryHistoryCapabilityFollowup ? findLastGroundedInventoryAddressDebug(input.sessionItems) : null; + const lastMemoryAddressDebug = contextualMemoryRecapFollowup + ? findLastAddressDebugWithItem(input.sessionItems) ?? findLastAddressDebug(input.sessionItems) + : null; if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { chatText = input.buildAssistantSafetyRefusalReply(); @@ -211,6 +310,15 @@ export async function runAssistantLivingChatRuntime( }); activeOrganization = scopedOrganization ?? activeOrganization; livingChatSource = "deterministic_inventory_history_capability_contract"; + } else if (contextualMemoryRecapFollowup) { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; + chatText = buildAddressMemoryRecapReply({ + organization: scopedOrganization, + addressDebug: lastMemoryAddressDebug, + toNonEmptyString: input.toNonEmptyString + }); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_memory_recap_contract"; } else if (capabilityMetaQuery) { chatText = input.buildAssistantCapabilityContractReply(); livingChatSource = "deterministic_capability_contract"; diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 62f90d8..a87bddb 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2466,6 +2466,32 @@ function isInventoryDrilldownFrameIntent(intent) { intent === "inventory_purchase_to_sale_chain" || intent === "inventory_aging_by_purchase_date"; } +function resolveAddressIntentFamily(intent) { + const normalizedIntent = toNonEmptyString(intent); + if (!normalizedIntent) { + return null; + } + if (normalizedIntent.startsWith("inventory_")) { + return "inventory"; + } + if (normalizedIntent.startsWith("vat_")) { + return "vat"; + } + if (normalizedIntent === "account_balance_snapshot" || normalizedIntent === "documents_forming_balance") { + return "balance"; + } + if (normalizedIntent === "open_items_by_counterparty_or_contract" || + normalizedIntent === "list_documents_by_counterparty" || + normalizedIntent === "bank_operations_by_counterparty" || + normalizedIntent === "list_contracts_by_counterparty" || + normalizedIntent === "list_documents_by_contract" || + normalizedIntent === "bank_operations_by_contract" || + normalizedIntent === "receivables_confirmed_for_period" || + normalizedIntent === "payables_confirmed_for_period") { + return "settlements"; + } + return null; +} function extractAddressCarryoverAnchor(addressDebug) { if (!isAddressLaneDebugPayload(addressDebug)) { return { @@ -2736,6 +2762,48 @@ function hasShortInventoryObjectFollowupSignal(userMessage) { (0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) || (0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(sample)); } +function hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage = null) { + const samples = [ + compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()), + compactWhitespace(String(userMessage ?? "").toLowerCase()), + compactWhitespace(repairAddressMojibake(String(alternateMessage ?? "")).toLowerCase()), + compactWhitespace(String(alternateMessage ?? "").toLowerCase()) + ].filter((item) => item.length > 0); + if (samples.length === 0) { + return false; + } + return samples.some((sample) => /(?:ндс|vat|налог(?:и|ов|ом|у|ами|ах)?|налогов(?:ый|ого)?|tax(?:es)?|сч[её]т[\s-]?фактур|книга\s+покупок|книга\s+продаж|вычет)/iu.test(sample) || + /(?:амортиз|основн(?:ые|ых|ым)?\s+средств|fixed\s*asset|depreciat|\bос\b)/iu.test(sample) || + /(?:закрыти|месяц|затрат|рбп|period\s*close|month\s*close|allocation|residual|cost)/iu.test(sample) || + /(?:оплат|плат(?:е|ё)ж|аванс|зач(?:е|ё)т|выписк|statement|wire|settlement|payment|\b51(?:\.\d{1,2})?\b|\b60(?:\.\d{1,2})?\b|\b62(?:\.\d{1,2})?\b)/iu.test(sample)); +} +function buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame) { + const candidateFilters = inventoryRootFrame?.filters && typeof inventoryRootFrame.filters === "object" + ? inventoryRootFrame.filters + : previousFilters; + const nextFilters = {}; + const organization = toNonEmptyString(candidateFilters?.organization) ?? toNonEmptyString(previousFilters?.organization); + const warehouse = toNonEmptyString(candidateFilters?.warehouse) ?? toNonEmptyString(previousFilters?.warehouse); + const asOfDate = toNonEmptyString(candidateFilters?.as_of_date) ?? toNonEmptyString(previousFilters?.as_of_date); + const periodFrom = toNonEmptyString(candidateFilters?.period_from) ?? toNonEmptyString(previousFilters?.period_from); + const periodTo = toNonEmptyString(candidateFilters?.period_to) ?? toNonEmptyString(previousFilters?.period_to); + if (organization) { + nextFilters.organization = organization; + } + if (warehouse) { + nextFilters.warehouse = warehouse; + } + if (asOfDate) { + nextFilters.as_of_date = asOfDate; + } + if (periodFrom) { + nextFilters.period_from = periodFrom; + } + if (periodTo) { + nextFilters.period_to = periodTo; + } + return nextFilters; +} function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); if (!normalized || countTokens(normalized) > 10) { @@ -2793,6 +2861,18 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes ? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null : false; const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal; + const hasStrongFollowupReference = hasPrimaryIndexReferenceSignal || + hasAlternateIndexReferenceSignal || + hasOrganizationClarificationContinuation || + hasImplicitContinuationSignal || + inventoryShortFollowupPrimary || + inventoryShortFollowupAlternate || + Boolean(debtRoleSwapIntent) || + hasFollowupMarker(userMessage) || + hasReferentialPointer(userMessage) || + (toNonEmptyString(alternateMessage) + ? hasFollowupMarker(String(alternateMessage ?? "")) || hasReferentialPointer(String(alternateMessage ?? "")) + : false); const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) || (toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false); if (hasStandaloneAddressTopic && @@ -2814,6 +2894,23 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes return null; } const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent); + const llmExplicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const resolvedPrimaryIntent = (0, addressIntentResolver_1.resolveAddressIntent)(repairAddressMojibake(String(userMessage ?? ""))).intent; + const resolvedAlternateIntent = toNonEmptyString(alternateMessage) + ? (0, addressIntentResolver_1.resolveAddressIntent)(repairAddressMojibake(String(alternateMessage ?? ""))).intent + : null; + const explicitIntent = llmExplicitIntent && llmExplicitIntent !== "unknown" + ? llmExplicitIntent + : resolvedPrimaryIntent && resolvedPrimaryIntent !== "unknown" + ? resolvedPrimaryIntent + : resolvedAlternateIntent && resolvedAlternateIntent !== "unknown" + ? resolvedAlternateIntent + : null; + const sourceIntentFamily = resolveAddressIntentFamily(sourceIntent); + const explicitIntentFamily = resolveAddressIntentFamily(explicitIntent); + if (sourceIntentFamily && explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasStrongFollowupReference) { + return null; + } let previousIntent = sourceIntent; let followupSelectionMode = "carry_previous_intent"; if (debtRoleSwapIntent) { @@ -2874,7 +2971,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes } }; } - const currentFrameKind = inventoryRootFrame + let currentFrameKind = inventoryRootFrame ? isInventoryDrilldownFrameIntent(sourceIntent) ? "inventory_drilldown" : isInventoryRootFrameIntent(sourceIntent) @@ -2883,7 +2980,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes : null; let resolvedCounterpartyFromDisplay = false; const previousFiltersRaw = previousAddressDebug.extracted_filters; - const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" + let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {}; if (!toNonEmptyString(previousFilters.contract)) { @@ -2919,13 +3016,23 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) { previousFilters.period_to = toNonEmptyString(navigationDateScope?.period_to); } + const rootContextOnlyPivot = Boolean((isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && + hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); + if (rootContextOnlyPivot) { + previousIntent = null; + previousAnchorType = null; + previousAnchor = null; + previousFilters = buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame); + currentFrameKind = inventoryRootFrame ? "inventory_root" : currentFrameKind; + followupSelectionMode = "carry_root_context"; + } const displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent); const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType); const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ?? (toNonEmptyString(alternateMessage) ? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities) : null); - if (resolvedEntityFromFollowup) { + if (resolvedEntityFromFollowup && !rootContextOnlyPivot) { if (resolvedEntityFromFollowup.entityType === "counterparty") { previousFilters.counterparty = resolvedEntityFromFollowup.value; previousAnchorType = "counterparty"; @@ -2946,7 +3053,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes followupSelectionMode = "carry_referenced_entity"; } } - if (!toNonEmptyString(previousFilters.item) && + if (!rootContextOnlyPivot && + !toNonEmptyString(previousFilters.item) && navigationFocusObjectType === "item" && navigationFocusObjectLabel && (sourceIntentHint === "inventory_on_hand_as_of_date" || @@ -2986,6 +3094,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_value: previousAnchor, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, + root_context_only: rootContextOnlyPivot || undefined, root_intent: inventoryRootFrame?.intent ?? undefined, root_filters: inventoryRootFrame?.filters ?? undefined, root_anchor_type: inventoryRootFrame?.anchorType ?? undefined, @@ -3005,10 +3114,13 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage, const hasFollowupContext = Boolean(carryoverMeta?.followupContext); const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null; const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null; + const rootContextOnly = selectionMode === "carry_root_context"; const explicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const targetIntent = selectionMode === "switch_to_suggested_intent" ? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null - : explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; + : rootContextOnly + ? explicitIntent ?? null + : explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal); const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase()); const hasExplicitIntent = Boolean(explicitIntent); @@ -3033,6 +3145,9 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage, if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) { reasons.push("operation_intent_from_current_message"); } + if (rootContextOnly) { + reasons.push("root_context_only_carryover"); + } return { schema_version: "address_dialog_continuation_contract_v2", source_message: sourceMessage, @@ -4274,6 +4389,17 @@ export function resolveAssistantOrchestrationDecision(input) { hasHistoricalCapabilityFollowupSignal(effectiveAddressUserMessage) || hasHistoricalCapabilityFollowupSignal(repairedEffectiveAddressUserMessage)) && isGroundedInventoryContextDebug(lastGroundedAddressDebug)); + const contextualMemoryRecapFollowupDetected = Boolean(!dataScopeMetaQuery && + !capabilityMetaQuery && + !dataRetrievalSignal && + !strongDataSignal && + !aggregateBusinessAnalyticsSignal && + (hasConversationMemoryRecallFollowupSignal(rawUserMessage) || + hasConversationMemoryRecallFollowupSignal(repairedRawUserMessage) || + hasConversationMemoryRecallFollowupSignal(effectiveAddressUserMessage) || + hasConversationMemoryRecallFollowupSignal(repairedEffectiveAddressUserMessage)) && + (lastGroundedAddressDebug || + findLastAddressAssistantItem(sessionItems)?.debug)); const hardMetaMode = dataScopeMetaQuery ? "data_scope" : capabilityMetaQuery && !dataRetrievalSignal @@ -4364,6 +4490,34 @@ export function resolveAssistantOrchestrationDecision(input) { }; } if (nonDomainQueryIndexed) { + if (contextualMemoryRecapFollowupDetected) { + return { + runAddressLane: false, + toolGateDecision: "skip_address_lane", + toolGateReason: "memory_recap_followup_detected", + livingMode: "chat", + livingReason: "memory_recap_followup_detected", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: "non_domain", + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + followup_context_detected: Boolean(followupContext || lastGroundedAddressDebug), + unsupported_address_intent_fallback_to_deep: false, + final_decision: { + run_address_lane: false, + tool_gate_decision: "skip_address_lane", + tool_gate_reason: "memory_recap_followup_detected", + living_mode: "chat", + living_reason: "memory_recap_followup_detected" + } + } + }; + } return { runAddressLane: false, toolGateDecision: "skip_address_lane", @@ -4430,19 +4584,25 @@ export function resolveAssistantOrchestrationDecision(input) { const semanticDeepInvestigationHintDetected = semanticGuardHints?.deep_investigation_signal_detected === true; const semanticAggregateShapeDetected = semanticExtraction?.query_shape === "AGGREGATE_LOOKUP" || semanticExtraction?.aggregation_profile === "management_profile"; + const rootContextOnlyFollowup = Boolean(followupContext && followupContext.root_context_only === true); const followupSemanticOverrideToDeepAllowed = Boolean(followupContext && !supportedAddressIntentDetected && - (llmContractMode === "unsupported" || + (rootContextOnlyFollowup || + llmContractMode === "unsupported" || semanticAggregateShapeDetected || semanticDeepInvestigationHintDetected || !semanticApplyCanonicalRecommended)); const unsupportedIntentOrMode = (resolvedModeDetection.mode !== "address_query" && resolvedIntentResolution.intent === "unknown") || - llmContractMode === "unsupported"; + llmContractMode === "unsupported" || + (rootContextOnlyFollowup && + resolvedIntentResolution.intent === "unknown" && + (!llmContractIntent || llmContractIntent === "unknown")); const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane && !llmRuntimeUnavailableDetected && unsupportedIntentOrMode && strongDataSignal && - (llmContractMode === "deep_analysis" || + (rootContextOnlyFollowup || + llmContractMode === "deep_analysis" || !dataRetrievalSignal || strictDeepInvestigationCueDetected || semanticDeepInvestigationHintDetected || @@ -4462,6 +4622,9 @@ export function resolveAssistantOrchestrationDecision(input) { const vatExplainFollowupSignal = Boolean(followupContext && toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" && /(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`))); + const vatEvaluativeFollowupSignal = Boolean(followupContext && + toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" && + /(?:^|\s)(?:это\s+)?много\s+или\s+мало(?:\?|$)|(?:^|\s)(?:это\s+)?нормально(?:\?|$)|(?:^|\s)(?:это\s+)?плохо(?:\?|$)|(?:^|\s)(?:это\s+)?хорошо(?:\?|$)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`))); const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane && !llmRuntimeUnavailableDetected && (deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) && @@ -4492,7 +4655,7 @@ export function resolveAssistantOrchestrationDecision(input) { const hasPriorAddressAnswerContext = Boolean(lastGroundedAddressDebug || toNonEmptyString(followupContext?.previous_intent)); const metaFollowupOverGroundedAnswer = Boolean(followupContext && hasPriorAddressAnswerContext && - metaAnswerFollowupSignal && + (metaAnswerFollowupSignal || vatEvaluativeFollowupSignal) && !dataScopeMetaQuery && !capabilityMetaQuery && !aggregateBusinessAnalyticsSignal && @@ -4737,7 +4900,28 @@ function hasMetaAnswerFollowupSignal(userMessage) { sample.includes("по этому поводу") || sample.includes("об этом") || (sample.includes("это") && hasReferentialPointer(sample))); - if (!(hasReflectionCue && hasTopicPointerCue)) { + const hasEvaluationCue = samples.some((sample) => /\b(?:много|мало|нормально|хорошо|плохо|критично|перебор|слабо)\b/iu.test(sample)); + if (!((hasReflectionCue || hasEvaluationCue) && + (hasTopicPointerCue || (hasEvaluationCue && samples.some((sample) => /^(?:это|ну это)\b/iu.test(sample)))))) { + return false; + } + return !samples.some((sample) => hasAssistantDataScopeMetaQuestionSignal(sample) || + shouldHandleAsAssistantCapabilityMetaQuery(sample) || + hasDataRetrievalRequestSignal(sample) || + hasStrongDataIntentSignal(sample)); +} +function hasConversationMemoryRecallFollowupSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); + const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const samples = [rawText, repairedText] + .filter((item) => item.length > 0) + .map((item) => item.replace(/ё/g, "е")); + if (samples.length === 0) { + return false; + } + const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample)); + const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/iu.test(sample)); + if (!hasMemoryCue || !hasDiscussionCue) { return false; } return !samples.some((sample) => hasAssistantDataScopeMetaQuestionSignal(sample) || diff --git a/llm_normalizer/backend/src/services/inventoryLifecycleCueHelpers.ts b/llm_normalizer/backend/src/services/inventoryLifecycleCueHelpers.ts index d8cc5ee..285641c 100644 --- a/llm_normalizer/backend/src/services/inventoryLifecycleCueHelpers.ts +++ b/llm_normalizer/backend/src/services/inventoryLifecycleCueHelpers.ts @@ -8,8 +8,11 @@ export function hasInventoryPurchaseStem(text: string): boolean { export function hasInventorySupplierCue(text: string): boolean { const value = toText(text); + if (/(?:купил(?:и|о)?\s+у\s+кого|куплен(?:о)?\s+у\s+кого|купил(?:и|о)?\s+от\s+кого|куплен(?:о)?\s+от\s+кого)/iu.test(value)) { + return true; + } if ( - /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test( + /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+(?:мы\s+)?взяли(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|откуда\s+(?:мы\s+)?взяли(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test( value ) ) { diff --git a/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts b/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts new file mode 100644 index 0000000..22771f7 --- /dev/null +++ b/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage"; + +describe("inventory root frame regressions", () => { + it("restores inventory root frame for restatement on the same period after foreign domain drift", () => { + const result = runAddressDecomposeStage( + "ладно ок. покажи мне еще раз позиции на складе на тот же период рассмотрения", + { + previous_intent: "customer_revenue_and_payments", + previous_filters: { + organization: "ООО \\Альтернатива Плюс\\" + }, + previous_anchor_type: "organization", + previous_anchor_value: "ООО \\Альтернатива Плюс\\", + root_intent: "inventory_on_hand_as_of_date", + root_filters: { + organization: "ООО \\Альтернатива Плюс\\", + period_from: "2022-02-01", + period_to: "2022-02-28", + as_of_date: "2022-02-28" + }, + root_anchor_type: "organization", + root_anchor_value: "ООО \\Альтернатива Плюс\\", + current_frame_kind: "generic" + } + ); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date"); + expect(result?.intent.reasons).toContain("intent_restored_to_inventory_root_frame"); + expect(result?.filters.extracted_filters.organization).toBe("ООО \\Альтернатива Плюс\\"); + expect(result?.filters.extracted_filters.period_from).toBe("2022-02-01"); + expect(result?.filters.extracted_filters.period_to).toBe("2022-02-28"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2022-02-28"); + expect(result?.filters.warnings).toContain("period_from_from_followup_context"); + expect(result?.filters.warnings).toContain("period_to_from_followup_context"); + }); + + it("promotes selected-object provenance slang with 'где мы взяли это' into inventory provenance", () => { + const result = runAddressDecomposeStage( + 'По выбранному объекту "Зеркало для инвалидов поворотное травмобезопасное": где мы взяли это говнище?', + { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + organization: "ООО \\Альтернатива Плюс\\", + warehouse: "Основной склад", + period_from: "2022-02-01", + period_to: "2022-02-28", + as_of_date: "2022-02-28" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + ); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_purchase_provenance_for_item"); + expect(result?.filters.extracted_filters.item).toBe("Зеркало для инвалидов поворотное травмобезопасное"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2022-02-28"); + expect( + result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") || + result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected") + ).toBe(true); + }); +}); diff --git a/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts b/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts index 650ab84..8cce7b6 100644 --- a/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts @@ -23,6 +23,19 @@ describe("inventory warehouse anchor extraction", () => { expect(filters.as_of_date).toBe("2019-03-31"); expect(filters.warehouse).toBeUndefined(); }); + + it("does not materialize 'за май' as warehouse in inventory balance phrasing from the run", () => { + const filters = extractAddressFilters( + "проверить остатки по складу за май 2020 года", + "inventory_on_hand_as_of_date" + ).extracted_filters; + + expect(filters.period_from).toBe("2020-05-01"); + expect(filters.period_to).toBe("2020-05-31"); + expect(filters.as_of_date).toBe("2020-05-31"); + expect(filters.warehouse).toBeUndefined(); + }); + it("treats 'у нас' as implicit self-scope instead of literal warehouse anchor", () => { const result = extractAddressFilters("что на складе у нас", "inventory_on_hand_as_of_date"); diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index d69b035..a911ee5 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -2058,5 +2058,323 @@ describe("assistant address follow-up carryover", () => { expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс"); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); + + it("sanitizes selected-item carryover when inventory drilldown pivots into VAT follow-up", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const followupMessage = "\u0430 \u043d\u0434\u0441?"; + const itemLabel = + "\u041a\u0440\u043e\u043c\u043a\u0430 \u0441 \u043a\u043b\u0435\u0435\u043c 33 \u0434\u0443\u0431 \u043d\u0438\u0430\u0433\u0430\u0440\u0430 137 \u043c"; + const organization = "\u041e\u041e\u041e \\\u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441\\"; + const warehouse = "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0441\u043a\u043b\u0430\u0434"; + + const vatResult = buildAddressLaneResult({ + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "vat_payable_confirmed_as_of_date", + extracted_filters: { + sort: "period_desc", + period_from: "2021-03-01", + period_to: "2021-03-31", + as_of_date: "2021-03-31", + organization + }, + selected_recipe: "address_vat_payable_confirmed_as_of_date_v1", + response_type: "FACTUAL_SUMMARY", + requested_result_mode: "confirmed_balance", + result_mode: "confirmed_balance", + balance_confirmed: true, + reasons: [ + "address_action_detected", + "address_entity_detected", + "address_followup_context_applied" + ] + } + }); + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + return vatResult; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-followup-inventory-vat-pivot-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-inventory-root-seed", + session_id: sessionId, + role: "assistant", + text: "inventory root seed", + reply_type: "factual", + created_at: "2026-04-15T14:01:00.000Z", + trace_id: "address-root-seed", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31", + organization, + warehouse + }, + selected_recipe: "address_inventory_on_hand_as_of_date_v1" + } + } as any); + sessions.appendItem(sessionId, { + message_id: "msg-inventory-sale-seed", + session_id: sessionId, + role: "assistant", + text: "inventory sale trace seed", + reply_type: "factual", + created_at: "2026-04-15T14:02:00.000Z", + trace_id: "address-sale-seed", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_sale_trace_for_item", + extracted_filters: { + item: itemLabel, + organization, + as_of_date: "2021-03-31" + }, + selected_recipe: "address_inventory_sale_trace_for_item_v1", + anchor_type: "item", + anchor_value_raw: itemLabel, + anchor_value_resolved: itemLabel + } + } as any); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: followupMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(second.debug?.detected_intent).toBe("vat_payable_confirmed_as_of_date"); + expect(calls).toHaveLength(1); + expect(calls[0].message).toBe(followupMessage); + expect(calls[0].options?.followupContext?.root_context_only).toBe(true); + expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined(); + expect(calls[0].options?.followupContext?.previous_anchor_type).toBeUndefined(); + expect(calls[0].options?.followupContext?.previous_anchor_value).toBeNull(); + expect(calls[0].options?.followupContext?.previous_filters?.item).toBeUndefined(); + expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization); + expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe(warehouse); + expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2021-03-31"); + expect(calls[0].options?.followupContext?.previous_filters?.period_from).toBe("2021-03-01"); + expect(calls[0].options?.followupContext?.previous_filters?.period_to).toBe("2021-03-31"); + expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); + expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization); + expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2021-03-31"); + expect(calls[0].options?.followupContext?.root_filters?.period_from).toBe("2021-03-01"); + expect(calls[0].options?.followupContext?.root_filters?.period_to).toBe("2021-03-31"); + expect(calls[0].options?.followupContext?.current_frame_kind).toBe("inventory_root"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + + it("treats short supplier follow-up after sale trace as continuation of the active selected object", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const followupMessage = "а купили у кого"; + const saleTraceResult = { + handled: true, + reply_text: "По позиции Столешница 600*3050*26 дуб ниагара подтвержден покупатель: ООО \\Ромашка\\.", + reply_type: "factual", + response_type: "FACTUAL_LIST", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_sale_trace_for_item", + detected_intent_confidence: "medium", + extracted_filters: { + item: "Столешница 600*3050*26 дуб ниагара", + organization: "ООО \\Альтернатива Плюс\\", + as_of_date: "2020-05-31" + }, + selected_recipe: "address_inventory_sale_trace_for_item_v1", + anchor_type: "item", + anchor_value_raw: "Столешница 600*3050*26 дуб ниагара", + anchor_value_resolved: "Столешница 600*3050*26 дуб ниагара", + reasons: ["address_action_detected", "address_entity_detected"] + } + } as any; + const provenanceResult = { + handled: true, + reply_text: "По позиции Столешница 600*3050*26 дуб ниагара подтвержден поставщик: Торговый дом \\Союз\\.", + reply_type: "factual", + response_type: "FACTUAL_SUMMARY", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_purchase_provenance_for_item", + detected_intent_confidence: "medium", + extracted_filters: { + item: "Столешница 600*3050*26 дуб ниагара", + organization: "ООО \\Альтернатива Плюс\\", + as_of_date: "2020-05-31" + }, + selected_recipe: "address_inventory_purchase_provenance_for_item_v1", + reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] + } + } as any; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === followupMessage && options?.followupContext) { + return provenanceResult; + } + return saleTraceResult; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-followup-sale-to-supplier-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-sale-trace-seed", + session_id: sessionId, + role: "assistant", + text: saleTraceResult.reply_text, + reply_type: saleTraceResult.reply_type, + created_at: "2026-04-15T18:00:00.000Z", + trace_id: "address-sale-seed", + debug: saleTraceResult.debug + } as any); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: followupMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].message).toBe(followupMessage); + expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_sale_trace_for_item"); + expect(calls[0].options?.followupContext?.previous_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара"); + expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe("ООО \\Альтернатива Плюс\\"); + expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + + it("does not carry VAT previous_intent into a fresh inventory root query", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const firstMessage = "прогноз ндс на март 2020"; + const secondMessage = "остаток на складе за май 2020"; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === firstMessage) { + return { + handled: true, + reply_text: "Прогноз НДС на март 2020 собран.", + reply_type: "factual", + response_type: "FACTUAL_SUMMARY", + debug: { + detected_mode: "address_query", + detected_intent: "vat_payable_forecast", + detected_intent_confidence: "high", + extracted_filters: { + period_from: "2020-03-01", + period_to: "2020-03-31" + }, + selected_recipe: "address_vat_payable_forecast_v1" + } + }; + } + return { + handled: true, + reply_text: "Нужно уточнить организацию.", + reply_type: "partial_coverage", + response_type: "LIMITED_WITH_REASON", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_on_hand_as_of_date", + detected_intent_confidence: "high", + extracted_filters: { + period_from: "2020-05-01", + period_to: "2020-05-31", + as_of_date: "2020-05-31" + }, + selected_recipe: null, + limited_reason_category: "missing_anchor", + reasons: ["organization_clarification_required", "multiple_known_organizations_detected"] + } + }; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-followup-vat-inventory-${Date.now()}`; + const first = await service.handleMessage({ + session_id: sessionId, + user_message: firstMessage, + useMock: true + } as any); + expect(first.ok).toBe(true); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: secondMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(calls.length).toBeGreaterThanOrEqual(2); + const inventoryCalls = calls.slice(1); + expect(inventoryCalls.every((call) => call.message === secondMessage)).toBe(true); + expect(inventoryCalls.every((call) => call.options?.followupContext === undefined)).toBe(true); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + }); diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts index b4588d0..dc36f0d 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -133,4 +133,35 @@ describe("assistant living chat runtime adapter", () => { expect(output.debug?.living_chat_response_source).toBe("llm_chat_grounding_guard"); expect(executeLlmChat).toHaveBeenCalledTimes(1); }); + + it("builds deterministic memory recap for prior selected-object address context", async () => { + const executeLlmChat = vi.fn(async () => "raw-llm"); + const input = buildRuntimeInput({ + userMessage: "а ты помнишь мы зеркало обсуждали?", + modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" }, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item: "Зеркало для инвалидов поворотное травмобезопасное", + organization: "ООО Альтернатива Плюс", + as_of_date: "2022-02-28" + } + } + } + ], + executeLlmChat + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toContain("Зеркало для инвалидов поворотное травмобезопасное"); + expect(output.chatText).toContain("кто поставил"); + expect(output.debug?.living_chat_response_source).toBe("deterministic_memory_recap_contract"); + expect(executeLlmChat).not.toHaveBeenCalled(); + }); }); diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index 2e2ad73..2996511 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -730,6 +730,98 @@ describe("assistant orchestration contract", () => { expect(decision.livingReason).toBe("meta_followup_over_grounded_answer"); }); + it("routes evaluative VAT follow-up 'это много или мало' to contextual chat instead of replaying address lane", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "это много или мало?", + effectiveAddressUserMessage: "это много или мало?", + followupContext: { + previous_intent: "vat_payable_forecast", + previous_filters: { + period_from: "2020-03-01", + period_to: "2020-03-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + }, + llmPreDecomposeMeta: { + applied: false, + reason: "normalized_fragment_rejected_semantic_guard", + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + }, + semanticExtractionContract: { + valid: false, + apply_canonical_recommended: false + } + } as any, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "vat_payable_forecast" + } + } + ], + useMock: false + } as any); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.toolGateReason).toBe("meta_followup_over_grounded_answer"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("meta_followup_over_grounded_answer"); + }); + + it("routes memory recap follow-up over prior address context to deterministic chat instead of generic non-domain chat", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "а ты помнишь мы зеркало обсуждали?", + effectiveAddressUserMessage: "а ты помнишь мы зеркало обсуждали?", + followupContext: null, + llmPreDecomposeMeta: { + applied: false, + reason: "normalized_fragment_rejected_semantic_guard", + llmCanonicalCandidateDetected: false, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + } + } as any, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item: "Зеркало для инвалидов поворотное травмобезопасное", + as_of_date: "2022-02-28" + } + } + } + ], + useMock: false + } as any); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.toolGateReason).toBe("memory_recap_followup_detected"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("memory_recap_followup_detected"); + }); + it("keeps documentary inventory chain verification in address lane for supported exact intent", () => { const question = "Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";