CI / ruff · pytest (pull_request) Successful in 2m21s
Refs #120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
97 lines
26 KiB
Markdown
97 lines
26 KiB
Markdown
# Архитектурные решения (ADR)
|
||
|
||
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
|
||
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
|
||
|
||
## 2026-10-31 — ADR-015: Сад — растит честная отметка, после кризиса тишина, материалы не считаются
|
||
**Контекст:** геймификация при ПТСР и депрессии клинически чувствительна (#120). Сад (#118, #119) не должен давить, подменять клиническую динамику и искажать данные для врача. Решение принято командой и **ждёт вычитки психиатром** (отметка в #120).
|
||
**Решение:**
|
||
1. Растит **факт честного ответа**, а не «правильный» ответ: «Пропущу» по дозе растит сад так же, как «Принял(а)» (`answered_dose_statuses`). Иначе сад подталкивал бы отмечать приём, которого не было, и врач видел бы ложную приверженность. Доза без ответа (`missed`) не растит, но ничего и не отнимает.
|
||
2. Настроение и баллы шкал на рост не влияют. Очки только прибавляются, дневные лимиты есть по каждому типу активности. Сравнения с другими пациентами нет.
|
||
3. **Тишина после кризиса:** если за 72 ч был алерт уровня `acute`/`high` из любого источника (чат, шкала, опрос), сервер отдаёт `quiet` — рост сохраняется, но без «Сад подрос» и анимации.
|
||
4. Тексты не обещают улучшения: «Растёт от ваших отметок» вместо «Растёт вместе с вами»; о пропуске — «сад не вянет», без «не страшно».
|
||
5. **Материалы от врача не растят сад:** просмотр пассивен, награда за него поощряла бы пролистывание. Если такой раздел появится, его можно учесть только как отдельное решение после вычитки.
|
||
**Последствия:** правила и пороги — в `config/garden.yaml`, тексты — в `web/src/content/ru.ts`; менять их можно без кода по итогам вычитки.
|
||
|
||
## 2026-10-31 — ADR-014: Голосовой ввод — расшифровка отдельно от диалога; OpenRouter только на синтетике
|
||
**Контекст:** для гранта ВШЭ и пилота «Династия» нужен голосовой ввод (слайд 11: «Записать → Проверить → Сводка»). Голос — ПДн, а `scrub()` его не обезличивает. OpenRouter — зарубежный сервис (ADR-004, guardrail 9).
|
||
**Решение:**
|
||
1. `POST /me/{token}/stt` **только расшифровывает**. Текст возвращается пациенту на проверку и правку, в диалог уходит обычным сообщением, поэтому safety, `scrub()` и шлюз LLM работают как с набранным текстом. Автоотправки нет.
|
||
2. Аудио не сохраняется и не логируется. В лог пишутся только длительность, провайдер, время ответа и длина текста.
|
||
3. Формат — WAV 16 кГц mono, его кодирует браузер: ffmpeg на сервере не нужен, одинаково работает в Chrome и iOS Safari. Лимит — `STT_MAX_SECONDS` (60 с).
|
||
4. Провайдеры за одним протоколом `SpeechToText` (`app/llm/stt.py`): `mock` (офлайн), `openrouter` (модель с аудиовходом, `input_audio`). **OpenRouter допустим только на синтетических данных** (демо, жюри, грант). До первого реального пациента нужен РФ-провайдер, SaluteSpeech (#121).
|
||
**Последствия:** голос в демо работает без новых зависимостей. Для пилота нужен договор с SaluteSpeech.
|
||
|
||
## 2026-10-04 — ADR-013: Правки по клиническому ревью — красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров
|
||
**Контекст:** ревью опросников: обязательны блок красных флагов и план безопасности со звонком после кризиса; маршрутизация должна сначала смотреть на риск и флаги, потом на шкалы; в документе два противоречащих правила кризисного экрана («любое Да» против «только высокий риск»); кризисные номера разбросаны по текстам (и чат-агент давал взрослым детский телефон доверия).
|
||
**Решение:**
|
||
1. Красные флаги (`config/instruments/red_flags.yaml`) идут первыми в этапе 1: голоса/видения, мысли причинить вред другому, доступ к оружию, насилие дома, сильное опьянение или отмена. Любое «Да» останавливает опрос: острый алерт врачу и экран помощи нужного варианта (`harm_others`, `domestic_violence`).
|
||
2. Одно правило кризиса во всём приложении: кризисный экран открывается только при **высоком** риске C-SSRS. Умеренный риск — стоп опроса и психиатр, «ориентир 24 ч»; низкий — флаг врачу, план безопасности, опрос продолжается. Пункт 9 PHQ-9 при отрицательном C-SSRS — флаг «расхождение» и план безопасности; без пройденного C-SSRS (плановый PHQ-9, дневник) — сразу C-SSRS, решает его уровень. `config/questionnaire.yaml`: `cssrs_positive_policy: graded` (C01).
|
||
3. После любого кризиса дежурному создаётся задача «перезвонить пациенту» (алерт `post_crisis_call`, ориентир 24 ч; «позвонили» — подтверждение алерта), одна на пациента, пока открыта. На следующий день приложение само спрашивает «как вы» с кнопкой плана безопасности (`app/services/followup.py`).
|
||
4. Маршрут: риск и флаги → шкалы. PCL-5 ≥50 убран из приоритетной записи (и в правилах дашборда `pcl5_severe` — уровень moderate): высокий PCL-5 ведёт к плановой записи к специалисту.
|
||
5. Все телефоны — только в `config/texts/crisis.yaml`; остальные тексты и промпт агента ссылаются через `{phone:<ключ>}`, неизвестный ключ роняет загрузку, тест не пускает «голые» номера в код и тексты.
|
||
**Причина:** врач назвал пункты 1–2 обязательными; одно правило кризиса исключает расхождение демо с документом; неверный номер на кризисном экране недопустим.
|
||
**Последствия:** формулировки красных флагов — черновик команды, утверждает врач (#8). Старое правило дашборда `phq9_item9` (ADR-006, сценарий без опросника) по-прежнему поднимает кризис по п.9 — свести при следующем ревью правил.
|
||
|
||
## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату
|
||
**Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста».
|
||
**Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется.
|
||
**Причина:** пациенту нужен понятный итог и путь дальше, а не тупик; решения о риске и маршруте при этом остаются детерминированными (ADR-007).
|
||
**Последствия:** отступления от присланного PDF сохранены по `docs/clinical/README.md`: TSQ вместо PC-PTSD-5 (v2 PDF), RUS-AUDIT-S — пункты 3/9/10 (ВОЗ). Открыто: номера 4 пунктов короткой PCL-5 (Price et al., 2016), официальная форма C-SSRS Since Last Contact (сейчас — тексты Recent с периодом «с последнего ответа»), пороги «роста» модулей дневника — согласовать с врачом.
|
||
|
||
## 2026-10-04 — ADR-011: База знаний (RAG) — лексический поиск BM25 и экстрактивные ответы с цитатами
|
||
**Контекст:** агенту нужны ответы по клиническим рекомендациям (КР ПТСР 2023, КР Депрессия 2024) со ссылкой на источник (#60). Эмбеддинги требуют либо сети (GigaChat Embeddings), либо тяжёлых зависимостей (torch, модель на сотни МБ). При этом демо должно работать офлайн (ADR-004), а `pyproject.toml` заморожен (#34).
|
||
**Решение:** PDF переводятся в txt один раз, офлайн (`docs/source/rag/pdf_to_txt.py`: таблицы → markdown, маркеры страниц, текст блок-схем перенесён вручную). Корпус лежит в `config/rag/`. Поиск — BM25 по основам слов (свой стеммер Snowball) с синонимами и переранжированием по самому плотному отрывку. Ответ **экстрактивный**: показываются только предложения из найденного фрагмента с цитатой «[КР ПТСР 2023, стр. N]». LLM может лишь упростить их для пациента, а перефразировка с новым содержанием отбраковывается. Если отрывки покрывают слишком малую долю вопроса (`min_coverage`), ответ — «ответа нет, уточните у врача». Пациенту не показываются предложения о назначении и дозах. Внутренние документы команды (`audiences: [team]`) не доходят ни до пациента, ни до врача.
|
||
**Причина:** работоспособность офлайн и детерминизм важнее recall на перефразированных вопросах. Каждое показанное утверждение можно проверить по странице КР.
|
||
**Последствия:** разговорные формулировки без общих слов с КР находятся хуже — это лечится словарём `synonyms` в `config/rag/sources.yaml`. Качество поиска контролирует набор контрольных вопросов в `tests/test_llm_rag.py` (hit@3 ≥ 15/18). Гибридный поиск с эмбеддингами можно добавить за тем же интерфейсом `BM25Index.search`.
|
||
|
||
## 2026-10-03 — ADR-010: Фокус — ветераны СВО; разработчики делят слои, а не направления
|
||
**Контекст:** три направления (ветераны, семьи, общая популяция с депрессией) и три разработчика. Если каждый ведёт своё направление, все трое правят одни и те же файлы: движок, API, дашборд.
|
||
**Решение:** на хакатоне делаем только ветеранов (эпик #35); семьи (#40) и общая популяция (#43) — опциональные направления на будущее, без срока. Разработчики делят **слои**: A — бот (диалог, каналы, LLM), B — backend (API, сервисы, шедулер, адаптеры, БД, ядро), C — дашборд. Между слоями — замороженные контракты (#34) с заглушками, поэтому все трое стартуют одновременно. Направления подключаются как `app/dialog/flows/<module>.py` + `config/modules.yaml`.
|
||
**Причина:** ноль коллизий по файлам при параллельной работе; общее ядро переиспользуется направлениями, как и задумано в ADR-001. Главный герой демо — Андрей (решение #9).
|
||
**Последствия:** изменения контрактов — только мелкими PR `[contract]` с пингом всех. Владение файлами — в `docs/dev/WORKFLOW.md`.
|
||
|
||
## 2026-10-03 — ADR-009: Процесс — регламент TenVex в облегчённом режиме хакатона
|
||
**Контекст:** 3 дня, сдача в пн 05.10 в 13:00 МСК. Два разработчика и Claude Code-агенты работают параллельно. Опыт TenVex/vobot: без issue и изоляции агенты конфликтуют.
|
||
**Решение:** берём ядро регламента vobot: задача = issue, одна зона на issue, worktree + ветка от `origin/dev`, PR в `dev`, Conventional Commits, лог в теле issue, ADR сюда. Облегчения: PR в `dev` можно мержить самому после зелёного `make lint test`, без обязательного ревью; contract-first/orval нет; `main` = сдаваемая версия, промоушн `dev → main` делает человек.
|
||
**Последствия:** регламент — [`docs/dev/WORKFLOW.md`](dev/WORKFLOW.md).
|
||
|
||
## 2026-10-03 — ADR-008: Конфиги в `config/`, данные в `data/`
|
||
**Контекст:** требования к оформлению: «конфигурационные файлы — в отдельной папке». В SPEC пороги лежали в `data/scales`.
|
||
**Решение:** `config/` — шкалы (пункты, диапазоны, градации), расписания модулей, правила алертов, лексикон риска, тексты для пациента (YAML, вычитывает психиатр). `data/` — синтетика, выдержки ГРЛС, eval-разметка.
|
||
**Причина:** соответствие чек-листу жюри; психиатр правит только `config/`, не трогая код.
|
||
|
||
## 2026-10-03 — ADR-007: «Действия агента» — это выход движка правил, а не tool-calling LLM
|
||
**Контекст:** в демо-сценарии действия записаны как вызовы инструментов (`raise_alert`, `notify_doctor`, `crisis_protocol`, `book_visit`). Это выглядит так, будто их решает LLM, а это противоречит правилу «алерты детерминированы».
|
||
**Решение:** правила (`config/rules.yaml`) по структурированным данным возвращают список действий; оркестратор их исполняет. LLM только формулирует реплику вокруг уже принятого действия.
|
||
**Причина:** юридическая атрибуция ответственности: пороги задаёт врач, ИИ решений не принимает. Это ключевой аргумент для жюри и для специалистов.
|
||
|
||
## 2026-10-03 — ADR-006: Цвета в UI ↔ уровни алертов
|
||
**Решение:** 🔴 = `acute` (кризис, п.9 ≥ 1, C-SSRS с намерением/планом, доступ к оружию при риске); 🟡 = `high` + `moderate` (врачу ≤ 24 ч / флаг к визиту); 🟢 = нет открытых алертов; `info` (молчание, нет ответа) — серый флаг. В БД хранится уровень, цвет вычисляется.
|
||
|
||
## 2026-10-03 — ADR-005: Демо-часы (виртуальное время)
|
||
**Контекст:** демо — 13 дней терапии за 3 минуты. Шедулер на реальном времени это показать не даст.
|
||
**Решение:** всё время в системе берётся из `app.core.clock` (реальное или виртуальное, `DEMO_CLOCK=true`). В демо-консоли есть кнопка «следующий день». Шедулер срабатывает по сдвигу часов. Правила считают окна (3 дня подряд, 7 дней) от виртуального «сейчас».
|
||
**Последствия:** нигде не вызывать `datetime.now()` напрямую. Проверяется в ревью.
|
||
|
||
## 2026-10-03 — ADR-004: Канал-независимый диалоговый движок + веб-демо-консоль + офлайн-режим
|
||
**Контекст:** решение должно запускаться на технике организатора без нестандартного ПО. Telegram и GigaChat на площадке могут быть недоступны.
|
||
**Решение:** диалог — чистая функция `engine.handle(token, event) -> list[Reply]` поверх FSM. Адаптеры: Telegram (aiogram) и веб-чат в демо-консоли (Streamlit). LLM за протоколом с двумя провайдерами: `gigachat` и `mock` (детерминированные ответы для демо-реплик). `LLM_PROVIDER=mock` по умолчанию в `docker compose up`.
|
||
**Причина:** главный критерий жюри — работоспособность. Демо не должно зависеть от сети.
|
||
|
||
## 2026-10-03 — ADR-003: Один Postgres, две базы; без Redis и Alembic в MVP
|
||
**Контекст:** в SPEC два контейнера Postgres и Redis.
|
||
**Решение:** один сервис `postgres` с двумя базами (`identity`, `clinical`; `audit_log` лежит в `clinical`) и двумя ролями: роль клинического модуля не имеет прав на `identity`. Связь — HMAC-токен. Схема создаётся через `create_all` при старте. FSM-состояние хранится в таблице `dialog_state`.
|
||
**Причина:** изоляция сохраняется (разные БД и разные роли), а операционной сложности меньше. Alembic для трёхдневного прототипа — лишнее трение. В проде — отдельные кластеры и миграции.
|
||
|
||
## 2026-10-03 — ADR-002: Ежедневный чек-ин = настроение + приём + модульный вопрос; шкалы — по расписанию
|
||
**Контекст:** в SPEC ежедневно собираются 2–3 пункта PHQ-9 в 14-дневном окне. В демо-персоне и в модулях — ежедневно настроение 0–10, приём, сон, алкоголь, а шкалы целиком по расписанию (PHQ-9 еженедельно/раз в 2 недели) и внепланово по триггеру.
|
||
**Решение:** берём модель из модулей. Ежедневно: настроение 0–10, приём препарата, один модульный вопрос (сон/кошмары у ветерана, алкоголь при флаге AUDIT-C) и свободный текст (`extract` → побочки, намерение бросить, алкоголь). Полные шкалы — по расписанию модуля и внепланово (например, настроение ≤ 3 три дня → внеплановый PHQ-9). П.9 PHQ-9 — только кнопками.
|
||
**Последствия:** новая таблица `daily_checkins` (mood, med_taken, sleep_bucket, alcohol_bucket, flags).
|
||
|
||
## 2026-10-03 — ADR-001: Общее ядро + три модуля, включаемые скринингом
|
||
**Контекст:** три источника требований (SPEC, демо-персона, концепция/CJM) по-разному ставят фокус: ветераны, депрессия, семьи.
|
||
**Решение:** общее ядро (PHQ-9 → C-SSRS, ежедневный чек-ин, молчание как сигнал) и модули `veteran`, `depression`, `family` ([`CLINICAL_MODULES.md`](info/CLINICAL_MODULES.md)). Модули включает стартовый скрининг и/или врач при подключении пациента. Шкалы, расписания и триггеры модулей описаны декларативно в `config/modules.yaml`.
|
||
**Приоритет на хакатон:** ядро, `veteran` и `depression` — P0; `family` — P1 (вход «Я близкий», чек-лист, кнопка «сообщить врачу»).
|
||
**Кейс:** закрываем сценарии «мониторинг после обращения» и «управление приёмом лекарств». Скрининг/триаж — входная точка, а не отдельный третий сценарий.
|