АРЧ - Усилить root-frame возврат, memory recap и colloquial provenance follow-up
This commit is contained in:
parent
f911f9893b
commit
8056bdfaf2
|
|
@ -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.
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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 логики.
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
Именно эта рамка считается безопасной для текущей архитектуры проекта.
|
||||||
|
|
@ -2778,6 +2778,48 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
|
||||||
(0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) ||
|
(0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) ||
|
||||||
(0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(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) {
|
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
|
||||||
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
if (!normalized || countTokens(normalized) > 10) {
|
if (!normalized || countTokens(normalized) > 10) {
|
||||||
|
|
@ -2916,7 +2958,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const currentFrameKind = inventoryRootFrame
|
let currentFrameKind = inventoryRootFrame
|
||||||
? isInventoryDrilldownFrameIntent(sourceIntent)
|
? isInventoryDrilldownFrameIntent(sourceIntent)
|
||||||
? "inventory_drilldown"
|
? "inventory_drilldown"
|
||||||
: isInventoryRootFrameIntent(sourceIntent)
|
: isInventoryRootFrameIntent(sourceIntent)
|
||||||
|
|
@ -2925,7 +2967,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
: null;
|
: null;
|
||||||
let resolvedCounterpartyFromDisplay = false;
|
let resolvedCounterpartyFromDisplay = false;
|
||||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||||
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||||
? { ...previousFiltersRaw }
|
? { ...previousFiltersRaw }
|
||||||
: {};
|
: {};
|
||||||
if (!toNonEmptyString(previousFilters.contract)) {
|
if (!toNonEmptyString(previousFilters.contract)) {
|
||||||
|
|
@ -2961,13 +3003,23 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) {
|
if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) {
|
||||||
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 displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent);
|
||||||
const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType);
|
const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType);
|
||||||
const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ??
|
const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ??
|
||||||
(toNonEmptyString(alternateMessage)
|
(toNonEmptyString(alternateMessage)
|
||||||
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
||||||
: null);
|
: null);
|
||||||
if (resolvedEntityFromFollowup) {
|
if (resolvedEntityFromFollowup && !rootContextOnlyPivot) {
|
||||||
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
||||||
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
||||||
previousAnchorType = "counterparty";
|
previousAnchorType = "counterparty";
|
||||||
|
|
@ -2988,7 +3040,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
followupSelectionMode = "carry_referenced_entity";
|
followupSelectionMode = "carry_referenced_entity";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!toNonEmptyString(previousFilters.item) &&
|
if (!rootContextOnlyPivot &&
|
||||||
|
!toNonEmptyString(previousFilters.item) &&
|
||||||
navigationFocusObjectType === "item" &&
|
navigationFocusObjectType === "item" &&
|
||||||
navigationFocusObjectLabel &&
|
navigationFocusObjectLabel &&
|
||||||
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
|
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
|
||||||
|
|
@ -3028,6 +3081,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
previous_anchor_type: previousAnchorType ?? undefined,
|
previous_anchor_type: previousAnchorType ?? undefined,
|
||||||
previous_anchor_value: previousAnchor,
|
previous_anchor_value: previousAnchor,
|
||||||
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
|
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
|
||||||
|
root_context_only: rootContextOnlyPivot || undefined,
|
||||||
root_intent: inventoryRootFrame?.intent ?? undefined,
|
root_intent: inventoryRootFrame?.intent ?? undefined,
|
||||||
root_filters: inventoryRootFrame?.filters ?? undefined,
|
root_filters: inventoryRootFrame?.filters ?? undefined,
|
||||||
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
|
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
|
||||||
|
|
@ -3047,9 +3101,12 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||||
const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
|
const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
|
||||||
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
||||||
const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
||||||
|
const rootContextOnly = selectionMode === "carry_root_context";
|
||||||
const explicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
const explicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||||
const targetIntent = selectionMode === "switch_to_suggested_intent"
|
const targetIntent = selectionMode === "switch_to_suggested_intent"
|
||||||
? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
||||||
|
: rootContextOnly
|
||||||
|
? explicitIntent ?? null
|
||||||
: explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
: explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||||
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
||||||
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
|
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
|
||||||
|
|
@ -3075,6 +3132,9 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||||
if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) {
|
if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) {
|
||||||
reasons.push("operation_intent_from_current_message");
|
reasons.push("operation_intent_from_current_message");
|
||||||
}
|
}
|
||||||
|
if (rootContextOnly) {
|
||||||
|
reasons.push("root_context_only_carryover");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
schema_version: "address_dialog_continuation_contract_v2",
|
schema_version: "address_dialog_continuation_contract_v2",
|
||||||
source_message: sourceMessage,
|
source_message: sourceMessage,
|
||||||
|
|
@ -4471,19 +4531,25 @@ function resolveAssistantOrchestrationDecision(input) {
|
||||||
const semanticDeepInvestigationHintDetected = semanticGuardHints?.deep_investigation_signal_detected === true;
|
const semanticDeepInvestigationHintDetected = semanticGuardHints?.deep_investigation_signal_detected === true;
|
||||||
const semanticAggregateShapeDetected = semanticExtraction?.query_shape === "AGGREGATE_LOOKUP" ||
|
const semanticAggregateShapeDetected = semanticExtraction?.query_shape === "AGGREGATE_LOOKUP" ||
|
||||||
semanticExtraction?.aggregation_profile === "management_profile";
|
semanticExtraction?.aggregation_profile === "management_profile";
|
||||||
|
const rootContextOnlyFollowup = Boolean(followupContext && followupContext.root_context_only === true);
|
||||||
const followupSemanticOverrideToDeepAllowed = Boolean(followupContext &&
|
const followupSemanticOverrideToDeepAllowed = Boolean(followupContext &&
|
||||||
!supportedAddressIntentDetected &&
|
!supportedAddressIntentDetected &&
|
||||||
(llmContractMode === "unsupported" ||
|
(rootContextOnlyFollowup ||
|
||||||
|
llmContractMode === "unsupported" ||
|
||||||
semanticAggregateShapeDetected ||
|
semanticAggregateShapeDetected ||
|
||||||
semanticDeepInvestigationHintDetected ||
|
semanticDeepInvestigationHintDetected ||
|
||||||
!semanticApplyCanonicalRecommended));
|
!semanticApplyCanonicalRecommended));
|
||||||
const unsupportedIntentOrMode = (resolvedModeDetection.mode !== "address_query" && resolvedIntentResolution.intent === "unknown") ||
|
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 &&
|
const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||||
!llmRuntimeUnavailableDetected &&
|
!llmRuntimeUnavailableDetected &&
|
||||||
unsupportedIntentOrMode &&
|
unsupportedIntentOrMode &&
|
||||||
strongDataSignal &&
|
strongDataSignal &&
|
||||||
(llmContractMode === "deep_analysis" ||
|
(rootContextOnlyFollowup ||
|
||||||
|
llmContractMode === "deep_analysis" ||
|
||||||
!dataRetrievalSignal ||
|
!dataRetrievalSignal ||
|
||||||
strictDeepInvestigationCueDetected ||
|
strictDeepInvestigationCueDetected ||
|
||||||
semanticDeepInvestigationHintDetected ||
|
semanticDeepInvestigationHintDetected ||
|
||||||
|
|
|
||||||
|
|
@ -1177,6 +1177,11 @@ function trimInventoryItemArrowSuffix(rawValue: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTemporalWarehousePhrase(candidate: string): boolean {
|
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)
|
const normalized = cleanupAnchorValue(candidate)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/ё/g, "е")
|
.replace(/ё/g, "е")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
function hasExplicitPeriodLiteral(text: string): boolean {
|
||||||
return /(?:^|[^\d*×xх])((?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?)(?=$|[^\d*×xх])/iu.test(String(text ?? ""));
|
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 previousIntent = followupContext.previous_intent;
|
||||||
const comingFromInventoryDrilldown =
|
const comingFromInventoryDrilldown =
|
||||||
currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
const normalized = String(userMessage ?? "");
|
|
||||||
if (
|
if (
|
||||||
hasSelectedObjectInventorySignal(normalized) ||
|
hasSelectedObjectInventorySignal(normalized) ||
|
||||||
hasInventorySupplierFollowupCue(normalized) ||
|
hasInventorySupplierFollowupCue(normalized) ||
|
||||||
|
|
@ -508,6 +523,7 @@ function shouldRestoreInventoryRootFrame(
|
||||||
const hasTemporalPatch =
|
const hasTemporalPatch =
|
||||||
hasExplicitPeriodWindow(extractedFilters) ||
|
hasExplicitPeriodWindow(extractedFilters) ||
|
||||||
Boolean(toNonEmptyString(extractedFilters.as_of_date)) ||
|
Boolean(toNonEmptyString(extractedFilters.as_of_date)) ||
|
||||||
|
hasSamePeriodHint(normalized) ||
|
||||||
hasExplicitPeriodLiteral(normalized) ||
|
hasExplicitPeriodLiteral(normalized) ||
|
||||||
Boolean(resolveRelativeMonthPeriodFromInventoryRoot(normalized, followupContext));
|
Boolean(resolveRelativeMonthPeriodFromInventoryRoot(normalized, followupContext));
|
||||||
return hasTemporalPatch;
|
return hasTemporalPatch;
|
||||||
|
|
@ -651,6 +667,7 @@ function mergeFollowupFilters(
|
||||||
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
|
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
|
||||||
const allTimeRequested = hasAllTimeHint(userMessage);
|
const allTimeRequested = hasAllTimeHint(userMessage);
|
||||||
const sameDateRequested = hasSameDateHint(userMessage);
|
const sameDateRequested = hasSameDateHint(userMessage);
|
||||||
|
const samePeriodRequested = hasSamePeriodHint(userMessage);
|
||||||
if (!toNonEmptyString(merged.organization) && previousOrganization) {
|
if (!toNonEmptyString(merged.organization) && previousOrganization) {
|
||||||
merged.organization = previousOrganization;
|
merged.organization = previousOrganization;
|
||||||
reasons.push("organization_from_followup_context");
|
reasons.push("organization_from_followup_context");
|
||||||
|
|
@ -821,6 +838,24 @@ function mergeFollowupFilters(
|
||||||
reasons.push("as_of_date_from_followup_context");
|
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 (
|
if (
|
||||||
!sameDateRequested &&
|
!sameDateRequested &&
|
||||||
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
(intent === "inventory_aging_by_purchase_date" || isInventoryLifecycleHistoryIntent(intent)) &&
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,51 @@ function findLastGroundedInventoryAddressDebug(items: unknown[]): Record<string,
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findLastAddressDebugWithItem(items: unknown[]): Record<string, unknown> | 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<string, unknown> } | 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<string, unknown>)
|
||||||
|
: 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<string, unknown> | 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<string, unknown> } | 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: {
|
function buildInventoryHistoryCapabilityFollowupReply(input: {
|
||||||
organization: string | null;
|
organization: string | null;
|
||||||
addressDebug: Record<string, unknown> | null;
|
addressDebug: Record<string, unknown> | null;
|
||||||
|
|
@ -135,6 +180,55 @@ function buildInventoryHistoryCapabilityFollowupReply(input: {
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAddressMemoryRecapReply(input: {
|
||||||
|
organization: string | null;
|
||||||
|
addressDebug: Record<string, unknown> | 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<string, unknown>)
|
||||||
|
: 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<string, unknown>)
|
||||||
|
: 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(
|
export async function runAssistantLivingChatRuntime(
|
||||||
input: AssistantLivingChatRuntimeInput
|
input: AssistantLivingChatRuntimeInput
|
||||||
): Promise<AssistantLivingChatRuntimeOutput> {
|
): Promise<AssistantLivingChatRuntimeOutput> {
|
||||||
|
|
@ -157,9 +251,14 @@ export async function runAssistantLivingChatRuntime(
|
||||||
let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization);
|
let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization);
|
||||||
const contextualInventoryHistoryCapabilityFollowup =
|
const contextualInventoryHistoryCapabilityFollowup =
|
||||||
input.modeDecision?.reason === "inventory_history_capability_followup_detected";
|
input.modeDecision?.reason === "inventory_history_capability_followup_detected";
|
||||||
|
const contextualMemoryRecapFollowup =
|
||||||
|
input.modeDecision?.reason === "memory_recap_followup_detected";
|
||||||
const lastGroundedInventoryAddressDebug = contextualInventoryHistoryCapabilityFollowup
|
const lastGroundedInventoryAddressDebug = contextualInventoryHistoryCapabilityFollowup
|
||||||
? findLastGroundedInventoryAddressDebug(input.sessionItems)
|
? findLastGroundedInventoryAddressDebug(input.sessionItems)
|
||||||
: null;
|
: null;
|
||||||
|
const lastMemoryAddressDebug = contextualMemoryRecapFollowup
|
||||||
|
? findLastAddressDebugWithItem(input.sessionItems) ?? findLastAddressDebug(input.sessionItems)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) {
|
if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) {
|
||||||
chatText = input.buildAssistantSafetyRefusalReply();
|
chatText = input.buildAssistantSafetyRefusalReply();
|
||||||
|
|
@ -211,6 +310,15 @@ export async function runAssistantLivingChatRuntime(
|
||||||
});
|
});
|
||||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||||
livingChatSource = "deterministic_inventory_history_capability_contract";
|
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) {
|
} else if (capabilityMetaQuery) {
|
||||||
chatText = input.buildAssistantCapabilityContractReply();
|
chatText = input.buildAssistantCapabilityContractReply();
|
||||||
livingChatSource = "deterministic_capability_contract";
|
livingChatSource = "deterministic_capability_contract";
|
||||||
|
|
|
||||||
|
|
@ -2466,6 +2466,32 @@ function isInventoryDrilldownFrameIntent(intent) {
|
||||||
intent === "inventory_purchase_to_sale_chain" ||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
intent === "inventory_aging_by_purchase_date";
|
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) {
|
function extractAddressCarryoverAnchor(addressDebug) {
|
||||||
if (!isAddressLaneDebugPayload(addressDebug)) {
|
if (!isAddressLaneDebugPayload(addressDebug)) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -2736,6 +2762,48 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
|
||||||
(0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) ||
|
(0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) ||
|
||||||
(0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(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) {
|
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
|
||||||
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||||
if (!normalized || countTokens(normalized) > 10) {
|
if (!normalized || countTokens(normalized) > 10) {
|
||||||
|
|
@ -2793,6 +2861,18 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null
|
? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null
|
||||||
: false;
|
: false;
|
||||||
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
|
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) ||
|
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
|
||||||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
|
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
|
||||||
if (hasStandaloneAddressTopic &&
|
if (hasStandaloneAddressTopic &&
|
||||||
|
|
@ -2814,6 +2894,23 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent);
|
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 previousIntent = sourceIntent;
|
||||||
let followupSelectionMode = "carry_previous_intent";
|
let followupSelectionMode = "carry_previous_intent";
|
||||||
if (debtRoleSwapIntent) {
|
if (debtRoleSwapIntent) {
|
||||||
|
|
@ -2874,7 +2971,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const currentFrameKind = inventoryRootFrame
|
let currentFrameKind = inventoryRootFrame
|
||||||
? isInventoryDrilldownFrameIntent(sourceIntent)
|
? isInventoryDrilldownFrameIntent(sourceIntent)
|
||||||
? "inventory_drilldown"
|
? "inventory_drilldown"
|
||||||
: isInventoryRootFrameIntent(sourceIntent)
|
: isInventoryRootFrameIntent(sourceIntent)
|
||||||
|
|
@ -2883,7 +2980,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
: null;
|
: null;
|
||||||
let resolvedCounterpartyFromDisplay = false;
|
let resolvedCounterpartyFromDisplay = false;
|
||||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||||
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||||
? { ...previousFiltersRaw }
|
? { ...previousFiltersRaw }
|
||||||
: {};
|
: {};
|
||||||
if (!toNonEmptyString(previousFilters.contract)) {
|
if (!toNonEmptyString(previousFilters.contract)) {
|
||||||
|
|
@ -2919,13 +3016,23 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) {
|
if (!toNonEmptyString(previousFilters.period_to) && toNonEmptyString(navigationDateScope?.period_to)) {
|
||||||
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 displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent);
|
||||||
const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType);
|
const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType);
|
||||||
const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ??
|
const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ??
|
||||||
(toNonEmptyString(alternateMessage)
|
(toNonEmptyString(alternateMessage)
|
||||||
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
|
||||||
: null);
|
: null);
|
||||||
if (resolvedEntityFromFollowup) {
|
if (resolvedEntityFromFollowup && !rootContextOnlyPivot) {
|
||||||
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
if (resolvedEntityFromFollowup.entityType === "counterparty") {
|
||||||
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
previousFilters.counterparty = resolvedEntityFromFollowup.value;
|
||||||
previousAnchorType = "counterparty";
|
previousAnchorType = "counterparty";
|
||||||
|
|
@ -2946,7 +3053,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
followupSelectionMode = "carry_referenced_entity";
|
followupSelectionMode = "carry_referenced_entity";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!toNonEmptyString(previousFilters.item) &&
|
if (!rootContextOnlyPivot &&
|
||||||
|
!toNonEmptyString(previousFilters.item) &&
|
||||||
navigationFocusObjectType === "item" &&
|
navigationFocusObjectType === "item" &&
|
||||||
navigationFocusObjectLabel &&
|
navigationFocusObjectLabel &&
|
||||||
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
|
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
|
||||||
|
|
@ -2986,6 +3094,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
previous_anchor_type: previousAnchorType ?? undefined,
|
previous_anchor_type: previousAnchorType ?? undefined,
|
||||||
previous_anchor_value: previousAnchor,
|
previous_anchor_value: previousAnchor,
|
||||||
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
|
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
|
||||||
|
root_context_only: rootContextOnlyPivot || undefined,
|
||||||
root_intent: inventoryRootFrame?.intent ?? undefined,
|
root_intent: inventoryRootFrame?.intent ?? undefined,
|
||||||
root_filters: inventoryRootFrame?.filters ?? undefined,
|
root_filters: inventoryRootFrame?.filters ?? undefined,
|
||||||
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
|
root_anchor_type: inventoryRootFrame?.anchorType ?? undefined,
|
||||||
|
|
@ -3005,9 +3114,12 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||||
const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
|
const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
|
||||||
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
||||||
const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
||||||
|
const rootContextOnly = selectionMode === "carry_root_context";
|
||||||
const explicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
const explicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||||
const targetIntent = selectionMode === "switch_to_suggested_intent"
|
const targetIntent = selectionMode === "switch_to_suggested_intent"
|
||||||
? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
||||||
|
: rootContextOnly
|
||||||
|
? explicitIntent ?? null
|
||||||
: explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
: explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||||
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
||||||
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
|
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
|
||||||
|
|
@ -3033,6 +3145,9 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||||
if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) {
|
if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) {
|
||||||
reasons.push("operation_intent_from_current_message");
|
reasons.push("operation_intent_from_current_message");
|
||||||
}
|
}
|
||||||
|
if (rootContextOnly) {
|
||||||
|
reasons.push("root_context_only_carryover");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
schema_version: "address_dialog_continuation_contract_v2",
|
schema_version: "address_dialog_continuation_contract_v2",
|
||||||
source_message: sourceMessage,
|
source_message: sourceMessage,
|
||||||
|
|
@ -4274,6 +4389,17 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||||
hasHistoricalCapabilityFollowupSignal(effectiveAddressUserMessage) ||
|
hasHistoricalCapabilityFollowupSignal(effectiveAddressUserMessage) ||
|
||||||
hasHistoricalCapabilityFollowupSignal(repairedEffectiveAddressUserMessage)) &&
|
hasHistoricalCapabilityFollowupSignal(repairedEffectiveAddressUserMessage)) &&
|
||||||
isGroundedInventoryContextDebug(lastGroundedAddressDebug));
|
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
|
const hardMetaMode = dataScopeMetaQuery
|
||||||
? "data_scope"
|
? "data_scope"
|
||||||
: capabilityMetaQuery && !dataRetrievalSignal
|
: capabilityMetaQuery && !dataRetrievalSignal
|
||||||
|
|
@ -4364,6 +4490,34 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (nonDomainQueryIndexed) {
|
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 {
|
return {
|
||||||
runAddressLane: false,
|
runAddressLane: false,
|
||||||
toolGateDecision: "skip_address_lane",
|
toolGateDecision: "skip_address_lane",
|
||||||
|
|
@ -4430,19 +4584,25 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||||
const semanticDeepInvestigationHintDetected = semanticGuardHints?.deep_investigation_signal_detected === true;
|
const semanticDeepInvestigationHintDetected = semanticGuardHints?.deep_investigation_signal_detected === true;
|
||||||
const semanticAggregateShapeDetected = semanticExtraction?.query_shape === "AGGREGATE_LOOKUP" ||
|
const semanticAggregateShapeDetected = semanticExtraction?.query_shape === "AGGREGATE_LOOKUP" ||
|
||||||
semanticExtraction?.aggregation_profile === "management_profile";
|
semanticExtraction?.aggregation_profile === "management_profile";
|
||||||
|
const rootContextOnlyFollowup = Boolean(followupContext && followupContext.root_context_only === true);
|
||||||
const followupSemanticOverrideToDeepAllowed = Boolean(followupContext &&
|
const followupSemanticOverrideToDeepAllowed = Boolean(followupContext &&
|
||||||
!supportedAddressIntentDetected &&
|
!supportedAddressIntentDetected &&
|
||||||
(llmContractMode === "unsupported" ||
|
(rootContextOnlyFollowup ||
|
||||||
|
llmContractMode === "unsupported" ||
|
||||||
semanticAggregateShapeDetected ||
|
semanticAggregateShapeDetected ||
|
||||||
semanticDeepInvestigationHintDetected ||
|
semanticDeepInvestigationHintDetected ||
|
||||||
!semanticApplyCanonicalRecommended));
|
!semanticApplyCanonicalRecommended));
|
||||||
const unsupportedIntentOrMode = (resolvedModeDetection.mode !== "address_query" && resolvedIntentResolution.intent === "unknown") ||
|
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 &&
|
const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||||
!llmRuntimeUnavailableDetected &&
|
!llmRuntimeUnavailableDetected &&
|
||||||
unsupportedIntentOrMode &&
|
unsupportedIntentOrMode &&
|
||||||
strongDataSignal &&
|
strongDataSignal &&
|
||||||
(llmContractMode === "deep_analysis" ||
|
(rootContextOnlyFollowup ||
|
||||||
|
llmContractMode === "deep_analysis" ||
|
||||||
!dataRetrievalSignal ||
|
!dataRetrievalSignal ||
|
||||||
strictDeepInvestigationCueDetected ||
|
strictDeepInvestigationCueDetected ||
|
||||||
semanticDeepInvestigationHintDetected ||
|
semanticDeepInvestigationHintDetected ||
|
||||||
|
|
@ -4462,6 +4622,9 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||||
const vatExplainFollowupSignal = Boolean(followupContext &&
|
const vatExplainFollowupSignal = Boolean(followupContext &&
|
||||||
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
|
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}`)));
|
/(?:\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 &&
|
const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane &&
|
||||||
!llmRuntimeUnavailableDetected &&
|
!llmRuntimeUnavailableDetected &&
|
||||||
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
|
(deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) &&
|
||||||
|
|
@ -4492,7 +4655,7 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||||
const hasPriorAddressAnswerContext = Boolean(lastGroundedAddressDebug || toNonEmptyString(followupContext?.previous_intent));
|
const hasPriorAddressAnswerContext = Boolean(lastGroundedAddressDebug || toNonEmptyString(followupContext?.previous_intent));
|
||||||
const metaFollowupOverGroundedAnswer = Boolean(followupContext &&
|
const metaFollowupOverGroundedAnswer = Boolean(followupContext &&
|
||||||
hasPriorAddressAnswerContext &&
|
hasPriorAddressAnswerContext &&
|
||||||
metaAnswerFollowupSignal &&
|
(metaAnswerFollowupSignal || vatEvaluativeFollowupSignal) &&
|
||||||
!dataScopeMetaQuery &&
|
!dataScopeMetaQuery &&
|
||||||
!capabilityMetaQuery &&
|
!capabilityMetaQuery &&
|
||||||
!aggregateBusinessAnalyticsSignal &&
|
!aggregateBusinessAnalyticsSignal &&
|
||||||
|
|
@ -4737,7 +4900,28 @@ function hasMetaAnswerFollowupSignal(userMessage) {
|
||||||
sample.includes("по этому поводу") ||
|
sample.includes("по этому поводу") ||
|
||||||
sample.includes("об этом") ||
|
sample.includes("об этом") ||
|
||||||
(sample.includes("это") && hasReferentialPointer(sample)));
|
(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 false;
|
||||||
}
|
}
|
||||||
return !samples.some((sample) => hasAssistantDataScopeMetaQuestionSignal(sample) ||
|
return !samples.some((sample) => hasAssistantDataScopeMetaQuestionSignal(sample) ||
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@ export function hasInventoryPurchaseStem(text: string): boolean {
|
||||||
|
|
||||||
export function hasInventorySupplierCue(text: string): boolean {
|
export function hasInventorySupplierCue(text: string): boolean {
|
||||||
const value = toText(text);
|
const value = toText(text);
|
||||||
|
if (/(?:купил(?:и|о)?\s+у\s+кого|куплен(?:о)?\s+у\s+кого|купил(?:и|о)?\s+от\s+кого|куплен(?:о)?\s+от\s+кого)/iu.test(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (
|
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
|
value
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -23,6 +23,19 @@ describe("inventory warehouse anchor extraction", () => {
|
||||||
expect(filters.as_of_date).toBe("2019-03-31");
|
expect(filters.as_of_date).toBe("2019-03-31");
|
||||||
expect(filters.warehouse).toBeUndefined();
|
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", () => {
|
it("treats 'у нас' as implicit self-scope instead of literal warehouse anchor", () => {
|
||||||
const result = extractAddressFilters("что на складе у нас", "inventory_on_hand_as_of_date");
|
const result = extractAddressFilters("что на складе у нас", "inventory_on_hand_as_of_date");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2058,5 +2058,323 @@ describe("assistant address follow-up carryover", () => {
|
||||||
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс");
|
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс");
|
||||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,4 +133,35 @@ describe("assistant living chat runtime adapter", () => {
|
||||||
expect(output.debug?.living_chat_response_source).toBe("llm_chat_grounding_guard");
|
expect(output.debug?.living_chat_response_source).toBe("llm_chat_grounding_guard");
|
||||||
expect(executeLlmChat).toHaveBeenCalledTimes(1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -730,6 +730,98 @@ describe("assistant orchestration contract", () => {
|
||||||
expect(decision.livingReason).toBe("meta_followup_over_grounded_answer");
|
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", () => {
|
it("keeps documentary inventory chain verification in address lane for supported exact intent", () => {
|
||||||
const question =
|
const question =
|
||||||
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";
|
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue