feat(bot): движок диалога — scrub → safety → FSM → мониторинг, согласие, возраст, кризис #50

Merged
Eleboba merged 1 commits from feat/10-dialog-engine into dev 2026-10-03 15:44:11 +03:00
Owner

Что меняется

Настоящий движок диалога вместо EchoEngine: Engine.handle(token, event) с конвейером scrub() → safety (сначала лексикон; при ACUTE LLM не вызывается вообще) → FSM → сохранение → MonitoringService.after_data_change() → исполнение диалоговых действий. Сюда же входят каркас FSM с реестром состояний, core-flow (согласие, возраст, idle, кризис, отзыв согласия), переиспользуемая сессия шкалы и тексты в config/texts/dialog.yaml.

Closes #10

Зона

A

Архитектура

  • app/dialog/engine.py — конвейер, решения по safety, сохранение, регистрация register_engine(Engine) при импорте.
  • app/dialog/fsm.py — Ctx, Step(replies, next_state, data, notify_monitoring), реестры @state, @on_enter, @trigger.
  • app/dialog/flows/ — сценарии подгружаются автоматически (load_flows()); core.py — new → consent → age_gate → <module>.onboarding.start → idle, crisis, consent_revoked.
  • app/dialog/scale_session.py — шкала по пунктам: кнопки; свободный текст → extract_item, при неуверенности — уточнение; п.9 PHQ-9 и C-SSRS только кнопками; флаги кризиса из конфига.
  • app/dialog/actions.py — исполнение schedule_scale, show_safety_plan, med_info, suggest_visit, reach_out, crisis. Алерты движок не создаёт (ADR-007).
  • app/dialog/crisis.py — скрипт отправляется один раз за открытый кризис. Без согласия и при недоступной БД отправляется вариант без фразы «врач уведомлён».

API для #36/#37

from app.dialog.fsm import Ctx, Step, on_enter, state, trigger
from app.dialog.scale_session import start_scale
from app.dialog.flows.core import finish_onboarding

@on_enter("veteran.onboarding.start")
async def _start(ctx: Ctx) -> Step:
    return await start_scale(ctx, "pcptsd5", return_state="veteran.onboarding.pcl5")

@on_enter("veteran.onboarding.done")
async def _done(ctx: Ctx) -> Step:
    return await finish_onboarding(ctx, module="veteran")   # следующий модуль или idle + приветствие

@trigger("system:daily_checkin", replace=True)   # подмена заглушек idle; также "button:menu:checkin", "text:idle"
async def _checkin(ctx, event) -> Step: ...

start_scale(ctx, scale_id, *, return_state, scheduled=True, intro=None) переводит FSM в scale.<id>, по завершении возвращает в return_state. Внеплановую шкалу можно поставить в очередь системным событием scale_due или действием schedule_scale. Шкала из очереди стартует, когда FSM в idle; в crisis — только C-SSRS.

Что читает lane B (#38)

В dialog_state.data (без текста сообщений, только коды):

  • safety_events: список (последние 50) {"ts": ISO, "level": "acute"|"possible", "categories": [...], "rules": [...], "source": "lexicon"|"llm"|"both", "handled": "crisis_script"|"crisis_repeat"|"risk_screen"|"dispatched", "state": "<fsm state>"}. После записи вызывается after_data_change(token).
  • crisis: {"open": bool, "opened_at", "source": "safety:lexicon"|"scale:phq9"|"rule:<id>", "variant", "closed_at"?}.
  • contact_requests: [{"ts"}] — пациент нажал «Связаться с врачом».
  • consent, revoked_at, delete_requested: true — согласие отозвано, данные помечены к удалению.
  • Пока работает NoopMonitoring, из crisis выводит только системное событие doctor_ack. С настоящим мониторингом выход также происходит при is_in_crisis() == False.

Как проверить

make lint test → 23 новых теста в tests/test_dialog_engine.py.

Чек-лист

  • rebase на origin/dev
  • make lint + make test зелёные
  • лог в issue (### 2026-10-03)
  • нет ПДн в логах, нет реальных данных, нет секретов
  • общие файлы и контракты не тронуты
## Что меняется Настоящий движок диалога вместо `EchoEngine`: `Engine.handle(token, event)` с конвейером `scrub()` → safety (сначала лексикон; при ACUTE LLM не вызывается вообще) → FSM → сохранение → `MonitoringService.after_data_change()` → исполнение диалоговых действий. Сюда же входят каркас FSM с реестром состояний, core-flow (согласие, возраст, idle, кризис, отзыв согласия), переиспользуемая сессия шкалы и тексты в `config/texts/dialog.yaml`. Closes #10 ## Зона A ## Архитектура - `app/dialog/engine.py` — конвейер, решения по safety, сохранение, регистрация `register_engine(Engine)` при импорте. - `app/dialog/fsm.py` — `Ctx`, `Step(replies, next_state, data, notify_monitoring)`, реестры `@state`, `@on_enter`, `@trigger`. - `app/dialog/flows/` — сценарии подгружаются автоматически (`load_flows()`); `core.py` — new → consent → age_gate → `<module>.onboarding.start` → idle, crisis, consent_revoked. - `app/dialog/scale_session.py` — шкала по пунктам: кнопки; свободный текст → `extract_item`, при неуверенности — уточнение; п.9 PHQ-9 и C-SSRS только кнопками; флаги кризиса из конфига. - `app/dialog/actions.py` — исполнение `schedule_scale`, `show_safety_plan`, `med_info`, `suggest_visit`, `reach_out`, `crisis`. Алерты движок не создаёт (ADR-007). - `app/dialog/crisis.py` — скрипт отправляется один раз за открытый кризис. Без согласия и при недоступной БД отправляется вариант без фразы «врач уведомлён». ## API для #36/#37 ```python from app.dialog.fsm import Ctx, Step, on_enter, state, trigger from app.dialog.scale_session import start_scale from app.dialog.flows.core import finish_onboarding @on_enter("veteran.onboarding.start") async def _start(ctx: Ctx) -> Step: return await start_scale(ctx, "pcptsd5", return_state="veteran.onboarding.pcl5") @on_enter("veteran.onboarding.done") async def _done(ctx: Ctx) -> Step: return await finish_onboarding(ctx, module="veteran") # следующий модуль или idle + приветствие @trigger("system:daily_checkin", replace=True) # подмена заглушек idle; также "button:menu:checkin", "text:idle" async def _checkin(ctx, event) -> Step: ... ``` `start_scale(ctx, scale_id, *, return_state, scheduled=True, intro=None)` переводит FSM в `scale.<id>`, по завершении возвращает в `return_state`. Внеплановую шкалу можно поставить в очередь системным событием `scale_due` или действием `schedule_scale`. Шкала из очереди стартует, когда FSM в idle; в crisis — только C-SSRS. ## Что читает lane B (#38) В `dialog_state.data` (без текста сообщений, только коды): - `safety_events`: список (последние 50) `{"ts": ISO, "level": "acute"|"possible", "categories": [...], "rules": [...], "source": "lexicon"|"llm"|"both", "handled": "crisis_script"|"crisis_repeat"|"risk_screen"|"dispatched", "state": "<fsm state>"}`. После записи вызывается `after_data_change(token)`. - `crisis`: `{"open": bool, "opened_at", "source": "safety:lexicon"|"scale:phq9"|"rule:<id>", "variant", "closed_at"?}`. - `contact_requests`: `[{"ts"}]` — пациент нажал «Связаться с врачом». - `consent`, `revoked_at`, `delete_requested: true` — согласие отозвано, данные помечены к удалению. - Пока работает `NoopMonitoring`, из `crisis` выводит только системное событие `doctor_ack`. С настоящим мониторингом выход также происходит при `is_in_crisis() == False`. ## Как проверить `make lint test` → 23 новых теста в `tests/test_dialog_engine.py`. ## Чек-лист - [x] rebase на origin/dev - [x] make lint + make test зелёные - [x] лог в issue (### 2026-10-03) - [x] нет ПДн в логах, нет реальных данных, нет секретов - [x] общие файлы и контракты не тронуты
Eleboba added 1 commit 2026-10-03 15:43:28 +03:00
- Engine.handle registered via register_engine; lexicon-first safety (no LLM on ACUTE),
  fixed crisis script once per open crisis, POSSIBLE → C-SSRS screen
- FSM registry (@state/@on_enter/@trigger) with auto-loaded flows; core flow:
  new → consent → age_gate → module onboarding → idle, crisis, consent_revoked
- reusable scale session (buttons, extract_item + clarify, item 9 / C-SSRS buttons-only,
  crisis flags), execution of monitoring dialog actions, texts in config/texts/dialog.yaml

Refs #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Eleboba merged commit e884d896ff into dev 2026-10-03 15:44:11 +03:00
Eleboba deleted branch feat/10-dialog-engine 2026-10-03 15:44:11 +03:00
Sign in to join this conversation.