Files
PTSRaiAgent/docs/DECISIONS.md
ElebobaandClaude Opus 5.5 7443b61974
CI / ruff · pytest (pull_request) Successful in 2m57s
feat(llm): speech-to-text for patient voice input via OpenRouter (+ offline mock)
POST /api/v1/me/{token}/stt takes a 16 kHz mono WAV and only transcribes:
the patient checks the text and sends it as an ordinary message, so safety,
scrub() and the LLM gateway still apply. Audio is neither stored nor logged.
OpenRouter STT is synthetic-data only (ADR-014); SaluteSpeech for the pilot is #121.

Refs #116

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:19:55 +03:00

23 KiB
Raw Permalink Blame History

Архитектурные решения (ADR)

Живой журнал: что решили и почему. Новые записи — сверху. Одна запись = одно решение. При конфликте с docs/info/SPEC.md действует ADR.

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.

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). Модули включает стартовый скрининг и/или врач при подключении пациента. Шкалы, расписания и триггеры модулей описаны декларативно в config/modules.yaml. Приоритет на хакатон: ядро, veteran и depression — P0; family — P1 (вход «Я близкий», чек-лист, кнопка «сообщить врачу»). Кейс: закрываем сценарии «мониторинг после обращения» и «управление приёмом лекарств». Скрининг/триаж — входная точка, а не отдельный третий сценарий.