Compare commits

...
30 Commits
Author SHA1 Message Date
Eleboba 4eb506bf65 Merge pull request 'feat: распознавание голоса пациента (STT через OpenRouter + mock)' (#122) from feat/116-stt into dev
CI / ruff · pytest (push) Successful in 2m25s
CD / ruff · pytest (push) Successful in 2m29s
CD / deploy dev (push) Successful in 3m8s
Reviewed-on: #122
2026-10-31 11:37:05 +03:00
ElebobaandClaude Opus 5.5 d700608825 fix(llm): STT prompt that keeps unclear speech; reasoning off (~60 s → ~3 s)
CI / ruff · pytest (pull_request) Successful in 2m23s
Live check on synthetic speech (espeak) via OpenRouter gemini-2.5-flash:
3–5 s per clip, silence → no speech.

Refs #116

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:29:40 +03:00
ElebobaandClaude Opus 5.5 7443b61974 feat(llm): speech-to-text for patient voice input via OpenRouter (+ offline mock)
CI / ruff · pytest (pull_request) Successful in 2m57s
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
Eleboba 4b166cf488 Merge pull request 'chore(devops): веб-приложение пациента в docker compose' (#113) from chore/web-in-compose into dev
CI / ruff · pytest (push) Successful in 2m40s
CD / ruff · pytest (push) Successful in 2m42s
CI / ruff · pytest (pull_request) Successful in 2m35s
CD / deploy dev (push) Successful in 31s
2026-10-05 13:43:19 +03:00
ElebobaandClaude Opus 5.5 1eb0f47823 chore(devops): patient web app in docker compose — the jury needs only Docker
CI / ruff · pytest (pull_request) Successful in 2m34s
Requirement: no extra software beyond the regulated set. The web app is built in a Node stage and served by
nginx (:8080, /api → app, SSE unbuffered); the server override keeps it off (host nginx serves the bundle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:40:24 +03:00
Eleboba 499cf6cdfc Merge pull request 'docs: README для жюри' (#112) from docs/readme-submission into dev
CD / ruff · pytest (push) Successful in 2m35s
CI / ruff · pytest (push) Successful in 2m43s
CD / deploy dev (push) Successful in 55s
2026-10-05 13:33:16 +03:00
ElebobaandClaude Opus 5.5 65ae3506f7 docs: README for the jury — goals, what is built, run, API, medical limitations
CI / ruff · pytest (pull_request) Successful in 2m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:30:37 +03:00
Eleboba b945c839b8 Merge pull request 'feat: живой ответ агента (стриминг) + вкладка «Чат» на телефоне' (#111) from feat/chat-streaming into dev
CI / ruff · pytest (push) Successful in 2m53s
CD / ruff · pytest (push) Successful in 2m55s
CD / deploy dev (push) Successful in 1m19s
2026-10-05 12:55:14 +03:00
ElebobaandClaude Opus 5.5 d1f92407f9 feat: live agent answers (SSE streaming) + «Чат» tab on phones
CI / ruff · pytest (pull_request) Successful in 2m43s
- POST /dialog/{token}/events/stream: Server-Sent Events with the agent's steps («Смотрю вашу карту ЕМИАС…»,
  «Ищу в клинических рекомендациях…»), the answer as the model writes it (OpenRouter stream=true), then the
  final replies (same as /events, incl. the forbidden-output filter and sources). X-Accel-Buffering: no.
- Web: free-text messages use the stream — a live bubble with steps, typing dots and a cursor; falls back to
  the plain request if the stream breaks.
- Phones: the bottom tab bar gets «Чат» (it had no way back to the chat).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:52:19 +03:00
Eleboba d662a69c7e Merge pull request 'feat(bot): /start без кода на демо-стенде — личная копия Андрея' (#110) from fix/tg-demo-start into dev
CD / ruff · pytest (push) Failing after 7s
CD / deploy dev (push) Skipped
CI / ruff · pytest (push) Successful in 2m43s
2026-10-05 12:33:50 +03:00
ElebobaandClaude Opus 5.5 bfb0c9e7bb feat(bot): /start without a code on the demo stand opens a private copy of veteran Andrey
CI / ruff · pytest (pull_request) Successful in 2m45s
Refs #108

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:28:03 +03:00
Eleboba fa23e1f3c6 Merge pull request 'fix(bot): Telegram через прокси на сервере' (#109) from fix/tg-proxy into dev
CD / ruff · pytest (push) Successful in 3m25s
CI / ruff · pytest (push) Successful in 3m29s
CD / deploy dev (push) Successful in 2m42s
2026-10-05 12:23:03 +03:00
ElebobaandClaude Opus 5.5 9087e2ac8d fix(bot): Telegram through TG_PROXY_URL — docker on the RF server has no IPv4 route to Telegram
CI / ruff · pytest (pull_request) Successful in 2m28s
Closes #108

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:40:53 +03:00
Eleboba cb680ff5b1 Merge pull request 'feat: кризис в конце теста и без блокировки, оружие без триггера, Никита' (#107) from feat/crisis-deferred into dev
CD / ruff · pytest (push) Successful in 2m39s
CI / ruff · pytest (push) Successful in 3m0s
CD / deploy dev (push) Successful in 1m50s
2026-10-04 23:33:01 +03:00
ElebobaandClaude Opus 5.5 531bef1e73 feat: crisis message after the test and non-blocking; weapon answer triggers nothing; ЕМИАС demo = Никита
CI / ruff · pytest (pull_request) Successful in 2m33s
- CRISIS_BLOCKING (default off for the demo): a crisis no longer locks the dialog; the message and the
  doctor's acute alert still happen. The clinical protocol (blocking, immediate stop) stays behind the flag and
  keeps its tests.
- Crisis signals inside a questionnaire (intake red flags, high C-SSRS, PHQ-9 item 9) no longer stop it: the
  test is finished and the crisis message comes at the end; the doctor's alert is raised at once/at the end.
- Weapon access: asked and stored, triggers nothing (rule disabled, no red flag, no safety block).
- ЕМИАС demo persona is «Никита В.»; the one-click demo stays «Андрей С.».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:30:05 +03:00
Eleboba 967ae2d5b6 Merge pull request 'fix(bot): полировка чат-агента' (#105) from fix/agent-polish into dev
CI / ruff · pytest (push) Successful in 2m45s
CD / ruff · pytest (push) Successful in 2m51s
CI / ruff · pytest (pull_request) Successful in 2m41s
CD / deploy dev (push) Successful in 3m27s
2026-10-04 22:46:14 +03:00
ElebobaandClaude Opus 5.5 e72378f178 fix(bot): agent polish — instant small talk, capped tool calls, analogues, plain text, definitions allowed
CI / ruff · pytest (pull_request) Successful in 2m43s
- Greetings / chit-chat answered without the LLM (instant, free, no medical findings dumped into «привет»).
- At most 4 tool calls per turn (one «как дела?» turn fired 23 parallel searches).
- «My drug» is never asked back; analogues → the drug's group, replacement only by the doctor.
- Markdown stripped to plain text with «•»; no sources footer under short replies.
- Diagnosis filter no longer blocks definitions («ПТСР — это расстройство…»); diagnosis-like statements about
  the patient are still blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:43:18 +03:00
Eleboba f0be84d020 Merge pull request 'feat(bot): агент отвечает на любое сообщение + личная копия на любой демо-вход' (#104) from feat/chat-all-text into dev
CD / deploy dev (push) Successful in 3m30s
CD / ruff · pytest (push) Successful in 2m46s
CI / ruff · pytest (push) Successful in 2m57s
2026-10-04 22:30:25 +03:00
ElebobaandClaude Opus 5.5 31585c7f35 feat(bot): every free-text message gets the agent's answer; every demo entry is a private copy
CI / ruff · pytest (pull_request) Successful in 2m41s
- Idle chat: all messages go to the agent (greetings, side-effect and substitute questions included); check-in
  signals are still recorded silently, quit intent keeps its rule reaction. Seen live: «какие будут побочки»
  was logged as a complaint («Спасибо, отметил»), «привет» got a canned phrase.
- Agent prompt: warm short replies to small talk; substitutes → only the doctor changes the drug.
- ЕМИАС demo persona button creates a private copy too (starting from consent + questionnaires, subject to
  the screening switch); the web app drops a stored session of the old shared demo account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:27:26 +03:00
Eleboba 535838e941 Merge pull request 'fix(bot): вопросы о составе без «?»' (#103) from fix/question-words into dev
CD / ruff · pytest (push) Successful in 3m12s
CI / ruff · pytest (push) Successful in 3m45s
CD / deploy dev (push) Successful in 2m32s
2026-10-04 21:44:37 +03:00
ElebobaandClaude Opus 5.5 4b64086ad7 fix(bot): composition questions without «?» are questions
CI / ruff · pytest (pull_request) Successful in 2m44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:41:43 +03:00
Eleboba 0e4dd0d5a2 Merge pull request 'feat: демо-режим для презентации — вход в один клик, тумблер начального теста' (#102) from feat/demo-mode into dev
CI / ruff · pytest (push) Successful in 4m20s
CD / ruff · pytest (push) Successful in 4m34s
CD / deploy dev (push) Successful in 3m20s
2026-10-04 21:13:38 +03:00
ElebobaandClaude Opus 5.5 c8887dc4b3 test: demo_settings is a clinical table
CI / ruff · pytest (pull_request) Successful in 2m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:07:41 +03:00
ElebobaandClaude Opus 5.5 cdd5ad9cde feat: presentation demo mode — one-click private demo patient, screening on/off switch
CI / ruff · pytest (pull_request) Failing after 2m33s
- POST /auth/demo/fresh + «Демо: войти как Андрей» on the welcome screen: every sign-in gets a fresh private
  copy of veteran Andrey (consent, prescription, ЕМИАС record) and lands in the chat — no codes, nobody's
  crisis or half-done questionnaire leaks into another viewer.
- Initial screening switch: SCREENING_ENABLED env default + /demo/settings + a toggle in the demo console;
  off → after consent straight to the chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:01:05 +03:00
Eleboba 99e7b8e55d Merge pull request 'fix: чат отвечает в любой ситуации — без ложного повторного кризиса' (#101) from fix/crisis-refire into dev
CD / deploy dev (push) Successful in 2m42s
CI / ruff · pytest (push) Successful in 3m0s
CD / ruff · pytest (push) Successful in 3m1s
2026-10-04 18:28:41 +03:00
ElebobaandClaude Opus 5.5 dd67905626 fix: chat replies in any situation — no false crisis re-fire, answers in crisis, medicine questions anywhere
CI / ruff · pytest (pull_request) Successful in 2m22s
- An acked crisis is not raised again for the same signal: re-raised only if a newer triggering scale exists
  (seen live: AUDIT-C 0/0/0 re-fired the acked PHQ-9 item 9 crisis).
- In an open crisis every non-acute message gets a reply (agent for questions, support otherwise) plus
  emergency contacts; acute messages still get the fixed script.
- Questions and medicine mentions without «?» are answered mid-questionnaire; free-text steps (check-in,
  side effects, question states) keep parsing their input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:23:03 +03:00
Eleboba a026a8cd48 Merge pull request 'fix: карта ЕМИАС для веб-демо Андрея + REVISION после успешного деплоя' (#100) from fix/emias-demo-token into dev
CD / ruff · pytest (push) Successful in 2m25s
CI / ruff · pytest (push) Successful in 2m38s
CD / deploy dev (push) Successful in 1m50s
2026-10-04 17:55:28 +03:00
ElebobaandClaude Opus 5.5 0bbe1e0932 fix: web demo token tok-andrey shares Andrey's ЕМИАС record; REVISION written only after a healthy deploy
CI / ruff · pytest (pull_request) Successful in 2m44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:52:23 +03:00
Eleboba 475800436e Merge pull request 'feat(bot): чат отвечает в любом состоянии + OpenRouter через прокси' (#99) from feat/chat-anywhere into dev
CD / ruff · pytest (push) Successful in 2m47s
CI / ruff · pytest (push) Successful in 2m49s
CD / deploy dev (push) Successful in 1m58s
2026-10-04 17:46:38 +03:00
ElebobaandClaude Opus 5.5 c489ddbdc4 feat(bot): chat answers questions in any state; OpenRouter via proxy from the RF server
CI / ruff · pytest (pull_request) Successful in 2m53s
A question asked mid-questionnaire, mid-check-in or during a crisis is answered right away (agent →
guidelines/instruction); the pending question is re-shown with its buttons (engine remembers the last
prompt), in a crisis the answer carries the emergency contacts and the crisis stays open until the doctor's
ack. Acute messages still get the fixed crisis script. OpenRouter blocks RF IPs: OPENROUTER_PROXY_URL routes
the agent through an HTTP proxy, as vobot does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:42:03 +03:00
71 changed files with 1800 additions and 69 deletions
+6
View File
@@ -11,6 +11,10 @@ CHAT_AGENT=auto
OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.5-flash-lite
# --- Voice input (speech-to-text): auto | off | mock | openrouter. OpenRouter = synthetic data only (ADR-014).
STT_PROVIDER=auto
STT_MODEL=google/gemini-2.5-flash
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY=
@@ -23,6 +27,8 @@ CLINICAL_DB_URL=postgresql+asyncpg://clinical_app:clinical_app@localhost:5432/cl
# --- Telegram: prototype on synthetic data only; prod channel is MAX / Gosuslugi. Empty = bot disabled.
TG_BOT_TOKEN=
# http://user:pass@host:port or socks5://… — needed on the RF server (docker has no route to Telegram over IPv4)
TG_PROXY_URL=
DOCTOR_NOTIFY_CHAT_ID=
# --- Demo virtual clock (ADR-005)
+1 -1
View File
@@ -6,7 +6,7 @@ install:
up:
@test -f .env || cp .env.example .env
docker compose up -d --build
@echo "API: http://localhost:8000/docs Dashboard: http://localhost:8501"
@echo "Patient app: http://localhost:8080 API: http://localhost:8000/docs Doctor/demo console: http://localhost:8501"
down:
docker compose down
+112 -9
View File
@@ -1,14 +1,117 @@
# PTSRaiAgent — ИИ-помощник сопровождения при ПТСР и депрессии
> Хакатон MedITron · кейс SberMed AI «ИИ-помощник пациента». Прототип на **синтетических данных**.
> Telegram используется только для прототипа на синтетических данных; прод-канал — MAX / Госуслуги.
> Хакатон MedITron · кейс SberMed AI «ИИ-помощник пациента». Прототип работает **только на синтетических данных**.
> Демо-стенд: **https://meditron.tenvex.ru** (кабинет врача: https://meditron.tenvex.ru/doctor/, API: https://meditron.tenvex.ru/docs).
🚧 README заполняется к сдаче (issue «README по требованиям оформления»). Разделы по требованиям жюри:
---
1. Цели и задачи решения
2. Что реализовано
3. Развёртывание, запуск, использование
4. Входные и выходные данные, API
5. Ограничения и особенности медицинской тематики
## 1. Цели и задачи
Пока смотрите [`CLAUDE.md`](CLAUDE.md) и [`docs/`](docs/).
**Проблема.** Ветераны СВО и люди с депрессией до помощи доходят с трудом, а начав лечение, часто его бросают: в первые недели побочные эффекты уже есть, а эффекта ещё нет. Врач между визитами ничего не знает о пациенте.
**Решение.** Цифровой помощник сопровождает пациента между визитами к врачу. Из сценариев кейса закрыты два: **мониторинг после обращения** и **управление приёмом лекарств**.
- Пациент общается с помощником в чате: веб-приложение или Telegram. В проде канал — MAX / Госуслуги.
- Помощник собирает валидированные шкалы, ежедневный чек-ин и отметки о приёме препарата.
- Он отвечает на вопросы о лечении по клиническим рекомендациям Минздрава, по инструкции ГРЛС и по карте пациента в ЕМИАС.
- Врач видит «красные зоны» и получает сигналы при ухудшении.
**Принципы.**
- ИИ не ставит диагнозы и не назначает лечение.
- LLM только формулирует ответы и извлекает данные.
- **Риск, алерты и скоринг считают детерминированные правила** по валидированным шкалам (PHQ-9, PCL-5, GAD-7, AUDIT-C, C-SSRS, DAR-5).
- Клинические решения принимает врач.
## 2. Что реализовано
| Блок | Что есть |
|---|---|
| **Чат-агент** | LLM с инструментами только для чтения: поиск по базе клинических рекомендаций (RAG, депрессия 2024, ПТСР 2023), карта пациента из ЕМИАС (мок), инструкция ГРЛС, детерминированная проверка препарата под пациента (сопутствующие болезни, непереносимость вспомогательных веществ, взаимодействия). Ответ печатается в реальном времени (SSE), видны шаги агента. Провайдеры: GigaChat (функции), OpenRouter (временно, только синтетика), офлайн-mock. |
| **Диалоговый движок** | Обезличивание → проверка безопасности → FSM. Согласие (152-ФЗ, отзыв в один шаг), проверка возраста, онбординг ветерана (PC-PTSD-5 → PCL-5 → PHQ-9 → AUDIT-C), опросник intake (C-SSRS, PHQ-2→9, TSQ→PCL-5, RUS-AUDIT-S), ежедневный чек-ин, напоминания о дозах, побочные эффекты. На вопросы отвечает в любом состоянии. |
| **Безопасность** | Лексикон риска (100% recall на наборе кризисных фраз), фиксированный кризисный скрипт без LLM, алерт врачу и дежурному, план безопасности. В демо кризисное окно показывается в конце теста и не блокирует диалог (`CRISIS_BLOCKING`). |
| **Правила мониторинга** | 17 правил в `config/rules.yaml`: п.9 PHQ-9, рост баллов, побочки 3 дня подряд, намерение бросить, пропуски 3 из 7, сон < 4 ч, алкоголь, молчание > 3 дней и другие. |
| **Кабинет врача** | Список «красных зон» с индексом риска прерывания терапии, карточка (графики шкал, сон, настроение, дозы, алерты, план безопасности, ИИ-саммари), живые алерты, подключение пациента с кодом и QR, аудит доступа. |
| **Веб-приложение пациента** | Вход через ЕМИАС (демо: код от врача), анонимный вход или демо-вход в один клик (у каждого посетителя своя копия пациента). Экраны: сегодня, лекарства, чат, врач, профиль. |
| **Демо-консоль** | Виртуальные часы (13 дней терапии за 3 минуты), проигрывание сценария, сплит-экран, тумблер начального теста. |
| **Инфраструктура** | Docker compose, CI (ruff + pytest, около 1060 тестов), CD: мерж в `dev` выкатывает стенд. |
## 3. Развёртывание и запуск
**Требования:** только Docker с Compose v2 — всё остальное собирается в контейнерах. Для разработки — Python 3.12 и [uv](https://docs.astral.sh/uv/), Node 20.
```bash
cp .env.example .env # по умолчанию офлайн: LLM_PROVIDER=mock, агент выключен
make up # postgres + API (:8000) + кабинет врача/демо-консоль (:8501) + приложение пациента (:8080)
make seed # синтетический кабинет: врач, ветеран Андрей и 7 пациентов
```
- API и Swagger: http://localhost:8000/docs
- Кабинет врача и демо-консоль: http://localhost:8501
- Веб-приложение пациента: http://localhost:8080 (собирается внутри Docker, Node на машине не нужен; для разработки — `cd web && npm run dev`, :5173)
**Включить чат-агента** в `.env`:
- `CHAT_AGENT=gigachat` + `GIGACHAT_CREDENTIALS=…` — целевой вариант для РФ;
- или `CHAT_AGENT=openrouter` + `OPENROUTER_API_KEY=…` (+ `OPENROUTER_PROXY_URL` с сервера в РФ) — только для прототипа;
- проверить из терминала: `uv run python -m app.llm.agent "Можно мне ибупрофен?" --persona andrey`.
**Демо-переключатели:**
- `SCREENING_ENABLED` — начальный тест (дублируется тумблером в демо-консоли);
- `CRISIS_BLOCKING` — клинический протокол: кризис блокирует диалог до подтверждения врача;
- `DEMO_CLOCK` — виртуальные часы;
- `INTAKE_DEMO` — опросники intake на синтетике.
**Сервер:** `infra/deploy/` — override compose, nginx-vhost, `deploy.sh`. Выкатка на стенд — `.gitea/workflows/deploy.yml`.
**Разработка:** `uv sync`, `make lint test`.
## 4. Входные и выходные данные, API
**Входные данные:**
- сообщения и ответы кнопками пациента;
- назначения (мок МИС, `data/meds/mis_prescriptions.json`);
- карта ЕМИАС (мок, `data/emias/records.yaml`);
- выдержки ГРЛС (`data/meds/grls/`);
- клинические рекомендации (`config/rag/corpus/`).
**Выходные данные:** ответы пациенту, баллы шкал, алерты врачу, карточка пациента, журнал аудита.
Все клинические конфиги (шкалы, пороги, правила, тексты, лексикон) лежат в `config/` и правятся без кода.
Основные эндпоинты (`/api/v1`, полная спецификация — `/docs`):
| Метод и путь | Назначение |
|---|---|
| `POST /dialog/{token}/events` | событие диалога: текст, кнопка, start → ответы помощника |
| `POST /dialog/{token}/events/stream` | то же в виде SSE: шаги агента, текст по мере генерации, финальные ответы |
| `GET /dialog/{token}/outbox` | системные сообщения: напоминания, чек-ины |
| `POST /auth/emias`, `/auth/emias/demo`, `/auth/demo/fresh`, `/auth/anonymous` | входы веб-приложения |
| `GET /me/{token}/today` | план на сегодня: дозы, отметки за неделю, визит |
| `GET /doctors/{id}/patients` | список пациентов врача с цветом и индексом риска |
| `GET /patients/{token}?doctor_id=` | карточка пациента (пишется в аудит) |
| `GET /doctors/{id}/alerts`, `POST /alerts/{id}/ack` | алерты и их подтверждение |
| `POST /patients/connect` | подключение пациента: назначение и одноразовый код |
| `GET/POST /demo/clock…`, `/demo/reset`, `/demo/settings` | демо-управление |
## 5. Ограничения и особенности медицинской тематики
- **Только синтетические данные.** Персоны, карты ЕМИАС, назначения и выдержки ГРЛС вымышлены или пересказаны для прототипа и подлежат сверке.
- **Не медицинское изделие в текущем виде.** Сервис не ставит диагнозы и не меняет терапию. Пороги шкал, тексты и лексикон — стартовые значения из литературы, их утверждает психиатр (`docs/clinical/`).
- **152-ФЗ:** отдельное согласие на обработку данных о здоровье, две раздельные БД (identity и clinical) со связью через HMAC-токен, в LLM уходит только обезличенный текст, каждое обращение врача пишется в аудит.
- **Зарубежные сервисы.** OpenRouter используется временно и только на синтетике. Для реальных пациентов нужен GigaChat в РФ-контуре.
- **Безопасность.** Кризисный ответ фиксированный и выдаётся без LLM. Пропуск кризиса недопустим, ложные срабатывания допустимы. Демо-режим (кризис без блокировки) не равен клиническому протоколу: в проде `CRISIS_BLOCKING=true`.
- **Ответы LLM** могут слегка различаться от раза к разу. Фактура берётся только из инструментов, а запрещённые формулировки (диагнозы, дозы, «отмените») фильтруются.
## Структура репозитория
```
app/ бэкенд: core (privacy, safety, шкалы, правила, часы), dialog (движок и сценарии), llm (шлюз, RAG,
чат-агент), db, api, scheduler, integrations (моки МИС/ЕМИАС/ГРЛС, уведомления), services
dashboard/ кабинет врача и демо-консоль (Streamlit)
web/ веб-приложение пациента (React + Vite)
config/ клинические конфиги: шкалы, правила, модули, тексты, лексикон, RAG-корпус
data/ синтетика: сценарий демо, карты ЕМИАС, назначения, выдержки ГРЛС
infra/ postgres init, деплой на сервер
tests/ pytest (около 1060 тестов)
docs/ спецификация, ADR (DECISIONS.md), клинические материалы, концепция и CJM, регламент
```
Подробнее: [`docs/info/SPEC.md`](docs/info/SPEC.md) · [`docs/DECISIONS.md`](docs/DECISIONS.md) · [`docs/info/CLINICAL_MODULES.md`](docs/info/CLINICAL_MODULES.md) · [`docs/source/`](docs/source/) (кейс, концепция, CJM).
+2 -1
View File
@@ -25,7 +25,8 @@ DOCTOR_ID = "doc-smirnova"
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
DEMO_PERSONAS: list[DemoPersona] = [
DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
# ЕМИАС demo sign-in shows this persona as «Никита В.» (the one-click demo on the welcome screen is «Андрей С.»).
DemoPersona(token="tok-andrey", display_name="Никита В.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
]
+105 -7
View File
@@ -17,17 +17,19 @@ issued by the server instead of handing out the pseudonymous token itself.
import logging
import secrets
import time as monotonic_time
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Any, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from app.api import fixtures
from app.api.schemas import DemoPersona
from app.config import get_settings
from app.core import clock
from app.core.instrument_catalog import Catalog, catalog
from app.core.intake_questions import DIARY_KINDS
@@ -35,13 +37,14 @@ from app.core.intake_runtime import intake_enabled, load_runtime
from app.core.interpretation import texts as results_texts
from app.core.meds_reference import find_drug, indication_for, load_reference
from app.core.safety import CrisisResource, crisis_reply
from app.db import repo
from app.db import identity_repo, repo
from app.db.audit import audit
from app.db.clinical import DailyCheckin, Dose, Prescription
from app.db.identity import TOKEN_LEN
from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session
from app.db.session import get_clinical_session, get_identity_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
from app.llm.stt import InvalidAudio, SttError, SttNoSpeech, get_stt, wav_seconds
from app.services import anonymous_care
log = logging.getLogger(__name__)
@@ -81,14 +84,14 @@ async def _attach_doctor(token: str, doctor_id: str) -> None:
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
async def _seed_demo_persona(token: str) -> None:
async def _seed_demo_persona(token: str, source: str | None = None) -> None:
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
doctor's card tell the same story.
"""
card = fixtures.card(token)
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
card = fixtures.card(source or token)
item = next((p for p in fixtures.PATIENTS if p.token == (source or token)), None)
if card is None or item is None:
return
now = clock.now()
@@ -148,11 +151,57 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
if persona is None:
raise HTTPException(404, "persona not found")
if persona.token == DEMO_SOURCE:
# Every visitor gets a private copy (never the shared account); this entry starts from consent and the
# initial questionnaires (subject to the screening switch), unlike the one-click chat demo.
return await _fresh_demo_copy(start_in_chat=False)
await _seed_demo_persona(persona.token)
log.info("web auth: emias demo tok=%s", persona.token[:6])
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
DEMO_SOURCE = "tok-andrey"
ONE_CLICK_NAME = "Андрей С." # «Демо: войти как Андрей»; the ЕМИАС demo persona is named in fixtures (Никита)
@router.post("/auth/demo/fresh", response_model=AuthOut)
async def auth_demo_fresh() -> AuthOut:
return await _fresh_demo_copy(start_in_chat=True)
async def _fresh_demo_copy(*, start_in_chat: bool) -> AuthOut:
"""Presentation entry: a fresh private copy of veteran Andrey for every sign-in — consent given, prescription
and ЕМИАС record in place, dialog resting in the chat. Nobody's crisis or half-done questionnaire leaks into
another viewer's demo. Synthetic data only."""
token = f"{DEMO_SOURCE}-{secrets.token_hex(4)}"
now = clock.now()
persona = next(p for p in fixtures.DEMO_PERSONAS if p.token == DEMO_SOURCE)
name = persona.display_name if not start_in_chat else ONE_CLICK_NAME
snils = f"000-{secrets.randbelow(10**3):03d}-{secrets.randbelow(10**3):03d} {secrets.randbelow(100):02d}"
async with get_identity_session() as s:
await identity_repo.create_patient(
s,
token=token,
display_name=f"{name} (демо)",
snils=snils,
clinic_id="demo",
doctor_id=fixtures.DOCTOR_ID,
now=now,
birth_year=now.year - 34,
sex="m",
)
if start_in_chat:
await identity_repo.record_consent(s, token=token, version="demo", now=now)
await _seed_demo_persona(token, source=DEMO_SOURCE)
async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, sex="m", birth_year=now.year - 34)
if start_in_chat:
consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None}
await repo.set_dialog_state(s, token=token, state="idle", data={**consent, "adult": True}, now=now)
log.info("web auth: demo fresh tok=%s", token[:14])
return AuthOut(token=token, mode="emias", display_name=name)
@router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
"""Anonymous sign-in: a fresh random token; care without an identity record.
@@ -173,7 +222,56 @@ async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
@router.get("/patient/capabilities")
async def patient_capabilities() -> dict[str, bool]:
"""Expose availability without leaking clinical policy or server configuration."""
return {"primary_screening": intake_enabled()}
return {"primary_screening": intake_enabled(), "voice_input": get_stt(get_settings()) is not None}
class SttOut(BaseModel):
text: str
provider: str
# 16 kHz mono 16-bit PCM = 32 000 bytes/s, + header slack.
_WAV_BYTES_PER_SECOND = 32_000
@router.post("/me/{token}/stt", response_model=SttOut)
async def transcribe_voice(token: str, request: Request) -> SttOut:
"""Voice → text for the patient to check and edit (#116). Body: raw `audio/wav`, 16 kHz mono.
Only transcribes: nothing is written to the dialog and the agent is not called — the patient sends the
checked text as an ordinary message, where safety and `scrub()` apply. The audio is neither stored nor logged.
"""
settings = get_settings()
stt = get_stt(settings)
if stt is None:
raise HTTPException(404, "voice input is off")
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
max_bytes = (settings.stt_max_seconds + 2) * _WAV_BYTES_PER_SECOND + 4096
declared = request.headers.get("content-length")
if declared and declared.isdigit() and int(declared) > max_bytes:
raise HTTPException(413, "recording too long")
audio = await request.body()
if len(audio) > max_bytes:
raise HTTPException(413, "recording too long")
try:
seconds = wav_seconds(audio)
except InvalidAudio as exc:
raise HTTPException(415, "expected audio/wav") from exc
if seconds > settings.stt_max_seconds + 1:
raise HTTPException(413, "recording too long")
started = monotonic_time.monotonic() # latency only, not domain time (that is app.core.clock)
try:
text = await stt.transcribe(audio, "wav")
except SttNoSpeech as exc:
raise HTTPException(422, "no speech recognised") from exc
except SttError as exc:
log.warning("stt failed tok=%s provider=%s: %s", token[:6], stt.name, exc)
raise HTTPException(503, "speech recognition unavailable") from exc
ms = int((monotonic_time.monotonic() - started) * 1000)
log.info("stt tok=%s provider=%s secs=%.1f ms=%d chars=%d", token[:6], stt.name, seconds, ms, len(text))
return SttOut(text=text, provider=stt.name)
@router.post("/me/{token}/doctor/connect")
+69
View File
@@ -8,9 +8,14 @@ disclosed — the dashboard polls every 2–3 s), `ack_alert` (in `repo.ack_aler
`app/api/fixtures.py` is kept for tests and as the demo cabinet reference (doctor id).
"""
import asyncio
import json
import logging
import secrets
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from app.api import demo as console
@@ -42,10 +47,12 @@ from app.db.identity import Patient
from app.db.session import get_clinical_session, get_identity_session
from app.dialog.contracts import Event, get_engine
from app.integrations.base import integrations
from app.llm.agent import stream as agent_stream
from app.scheduler import jobs as scheduler
from app.services.anonymous_care import display_name as anonymous_name
from app.services.monitoring import refresh_status
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
router.include_router(max_notifications.router)
router.include_router(questionnaire_router)
@@ -219,6 +226,49 @@ async def dialog_event(token: str, body: DialogIn) -> DialogOut:
return DialogOut(replies=await get_engine().handle(token, body))
@router.post("/dialog/{token}/events/stream", tags=["dialog"])
async def dialog_event_stream(token: str, body: DialogIn) -> StreamingResponse:
"""Same as /events, as Server-Sent Events: `status` (agent steps), `delta` (answer as it is written),
`reset`, then `done` with the final replies (identical to /events). `error` if the engine failed."""
await _require_patient(token)
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
marker = agent_stream.STREAM.set(queue) # copied into the task's context below
try:
async def run() -> None:
try:
replies = await get_engine().handle(token, body)
queue.put_nowait(("done", [r.model_dump() for r in replies]))
except Exception: # noqa: BLE001 — reported to the client, which falls back to /events
log.exception("dialog stream failed")
queue.put_nowait(("error", None))
task = asyncio.create_task(run())
finally:
agent_stream.STREAM.reset(marker)
async def events():
try:
while True:
try:
kind, payload = await asyncio.wait_for(queue.get(), timeout=15)
except TimeoutError:
yield ": keepalive\n\n"
continue
yield f"event: {kind}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
if kind in ("done", "error"):
return
finally:
if not task.done():
await task
return StreamingResponse(
events(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, # nginx: do not buffer SSE
)
@router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"])
async def dialog_outbox(token: str) -> DialogOut:
"""System-initiated replies (reminders, check-ins) waiting for the web console."""
@@ -288,3 +338,22 @@ async def demo_personas() -> list[DemoPersona]:
)
)
return out
@router.get("/demo/settings", tags=["demo"])
async def get_demo_settings() -> dict[str, bool]:
"""Presentation switches: `screening_enabled` — initial questionnaires after consent (off → straight to chat)."""
from app.core import demo_settings
async with get_clinical_session() as s:
return await demo_settings.get_all(s)
@router.post("/demo/settings", tags=["demo"])
async def set_demo_settings(body: dict[str, bool]) -> dict[str, bool]:
from app.core import demo_settings
async with get_clinical_session() as s:
if demo_settings.SCREENING in body:
await demo_settings.set_screening(s, bool(body[demo_settings.SCREENING]))
return await demo_settings.get_all(s)
+25 -1
View File
@@ -19,6 +19,7 @@ from typing import Protocol
import yaml
from aiogram import Bot, Dispatcher, F, Router
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.filters import CommandObject, CommandStart
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from pydantic import BaseModel
@@ -193,11 +194,28 @@ class TelegramChannel:
await self._dispatch(chat_id, person.token, Event(type="start", params={"code": code}))
return
token = await self.directory.token_for(chat_id)
if token is None and get_settings().demo_clock:
token = await self._demo_copy(chat_id)
if token is None:
await self._say(chat_id, texts.not_connected)
return
await self._dispatch(chat_id, token, Event(type="start"))
async def _demo_copy(self, chat_id: int) -> str | None:
"""Demo stand (DEMO_CLOCK): `/start` without a code gets a private copy of veteran Andrey (synthetic),
starting from consent and the intake — the same entry as the ЕМИАС demo button of the web app."""
from app.api.patient_web import _fresh_demo_copy
try:
token = (await _fresh_demo_copy(start_in_chat=False)).token
except Exception:
log.exception("start: demo copy failed")
return None
if not await self.directory.bind(chat_id, token):
return None
log.info("start: demo copy bound tok=%s", _short(token))
return token
async def on_text(self, chat_id: int, text: str | None) -> None:
texts = load_texts()
token = await self.directory.token_for(chat_id)
@@ -272,6 +290,12 @@ _task: asyncio.Task[None] | None = None
_prev_outbox: Outbox | None = None
def make_bot(token: str) -> Bot:
"""aiogram `Bot`, through `TG_PROXY_URL` when it is set."""
proxy = get_settings().tg_proxy_url
return Bot(token, session=AiohttpSession(proxy=proxy)) if proxy else Bot(token)
async def start() -> None:
"""Start polling if TG_BOT_TOKEN is set; no-op otherwise."""
global _bot, _dp, _task, _prev_outbox
@@ -280,7 +304,7 @@ async def start() -> None:
if not token:
log.info("telegram disabled: TG_BOT_TOKEN is empty")
return
bot = Bot(token)
bot = make_bot(token)
channel = TelegramChannel(bot)
dp = Dispatcher()
dp.include_router(build_router(channel))
+15
View File
@@ -37,12 +37,22 @@ class Settings(BaseSettings):
openrouter_api_key: str = ""
openrouter_model: str = "google/gemini-2.5-flash-lite"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
openrouter_proxy_url: str = "" # OpenRouter blocks RF IPs: the server goes through an HTTP proxy (as vobot does)
gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here
chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5
# Voice input (#116): speech-to-text for the patient's messages. `auto` = OpenRouter if a key is set, else mock.
# OpenRouter STT is synthetic data only (ADR-014): the audio leaves the RF and cannot be scrubbed.
stt_provider: Literal["auto", "off", "mock", "openrouter"] = "auto"
stt_model: str = "google/gemini-2.5-flash" # needs audio input
stt_timeout_s: float = 30.0
stt_max_seconds: int = Field(default=60, ge=5, le=300)
# Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi.
tg_bot_token: str = ""
# The RF server reaches api.telegram.org over IPv6 only; docker has IPv4 only → go through a proxy (http/socks).
tg_proxy_url: str = ""
doctor_notify_chat_id: int | None = None
# MAX is opt-in. Its adapter is delivered in the related backend/channel changes.
@@ -77,6 +87,11 @@ class Settings(BaseSettings):
# Virtual clock for the demo (ADR-005).
demo_clock: bool = True
# Initial screening (onboarding questionnaires) on/off — the default; the demo console can flip it at runtime.
screening_enabled: bool = True
# Crisis blocks the dialog until the doctor's ack (clinical protocol) — off for the demo: the crisis message
# and the doctor's alert still happen, the patient keeps using everything.
crisis_blocking: bool = False
tz: str = "Europe/Moscow"
api_base_url: str = "http://localhost:8000"
+34
View File
@@ -0,0 +1,34 @@
"""Presentation switches (demo): stored in `demo_settings`, default from settings/env.
SCREENING_ENABLED=false # env default
POST /api/v1/demo/settings {"screening_enabled": false} # one button in the demo console
Screening off: after consent the patient goes straight to the chat — no onboarding questionnaires.
"""
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db.clinical import DemoSetting
SCREENING = "screening_enabled"
async def get_all(session: AsyncSession) -> dict[str, Any]:
row = await session.get(DemoSetting, SCREENING)
return {SCREENING: bool(row.value) if row is not None else get_settings().screening_enabled}
async def screening_enabled(session: AsyncSession) -> bool:
return (await get_all(session))[SCREENING]
async def set_screening(session: AsyncSession, enabled: bool) -> None:
row = await session.get(DemoSetting, SCREENING)
if row is None:
session.add(DemoSetting(key=SCREENING, value=enabled))
else:
row.value = enabled
await session.flush()
+5
View File
@@ -61,6 +61,11 @@ def red_flag_category(item_id: str) -> str | None:
return next((i["category"] for i in red_flags()["items"] if i["id"] == item_id), None)
def red_flag_triggers(item_id: str) -> bool:
"""False for questions that are asked and stored but never stop/alert (rf_weapons, team decision 2026-10-04)."""
return next((i.get("trigger", True) for i in red_flags()["items"] if i["id"] == item_id), True)
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
"""Stage 1: the red-flag block first (config/instruments/red_flags.yaml), then the fixed order of «Опросники
для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, the context questions; stage 2 (rest of
+9
View File
@@ -192,3 +192,12 @@ class ClockState(ClinicalBase):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
offset_seconds: Mapped[float] = mapped_column(Float, default=0.0)
class DemoSetting(ClinicalBase):
"""Presentation switches changed at runtime from the demo console (e.g. the initial screening on/off)."""
__tablename__ = "demo_settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[Any] = mapped_column(JSONType)
+8
View File
@@ -14,6 +14,7 @@ Decisions:
from typing import Any
from app.config import get_settings
from app.core.safety import CrisisResource, CrisisScript, crisis_reply
from app.dialog.contracts import Reply
from app.dialog.fsm import Ctx, Step
@@ -37,6 +38,9 @@ def open_crisis(ctx: Ctx, categories: list[str], source: str) -> Step:
text = "\n".join([ctx.texts.crisis.repeat, *_resource_lines(script.resources)])
return Step(replies=[Reply(text=text, kind="crisis")])
crisis = {"open": True, "opened_at": ctx.now.isoformat(), "source": source, "variant": script.variant}
if not get_settings().crisis_blocking:
# Non-blocking (demo default): the script is shown, the doctor is alerted, the patient keeps going.
return Step(replies=[Reply(text=script.text, kind="crisis")], data={"crisis": crisis})
return Step(
replies=[Reply(text=script.text, kind="crisis")],
next_state="crisis",
@@ -44,6 +48,10 @@ def open_crisis(ctx: Ctx, categories: list[str], source: str) -> Step:
)
def crisis_resource_lines(resources: list[CrisisResource]) -> list[str]:
return _resource_lines(resources)
def no_consent_crisis_text(script: CrisisScript) -> str:
"""Crisis script without the "doctor notified" line — for people we may not (or did not) notify about."""
return "\n".join([script.body, "", *_resource_lines(script.resources), "", script.grounding])
+73 -1
View File
@@ -28,6 +28,7 @@ Logs carry only the token, states and event codes (guardrail #8).
import asyncio
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
@@ -36,6 +37,7 @@ from weakref import WeakValueDictionary
from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core import clock, safety
from app.core.intake_runtime import intake_enabled
from app.core.privacy import scrub
@@ -46,7 +48,7 @@ from app.db.session import get_clinical_session
from app.dialog import scale_session
from app.dialog.actions import execute
from app.dialog.contracts import Event, Reply, register_engine
from app.dialog.crisis import crisis_open, no_consent_crisis_text, open_crisis
from app.dialog.crisis import crisis_open, crisis_resource_lines, no_consent_crisis_text, open_crisis
from app.dialog.flows import load_flows
from app.dialog.flows.core import close_crisis, revoke
from app.dialog.fsm import (
@@ -66,6 +68,16 @@ from app.services.contracts import MonitoringService, get_monitoring
log = logging.getLogger(__name__)
LAST_PROMPT = "last_prompt"
DEFERRED_CRISIS = "deferred_crisis"
QUESTIONNAIRE_PREFIXES = ("scale.", "veteran.onboarding.", "intake.")
DEFERRABLE_KINDS = frozenset({"crisis", "safety_plan"})
MEDICINE_WORDS = re.compile(
r"(лекарств|таблет|препарат|сертралин|пароксетин|флуоксетин|эсциталопрам|антидепрессант|побочк)", re.I
)
# States with their own free-text handling (idle → flows' `text:idle`; explicit question states).
SIDE_ANSWER_SKIP = frozenset({"idle", "kb.question", "meds.question"})
FREE_TEXT_PREFIXES = ("daily.", "meds.", "kb.") # steps that expect free text (check-in, side effects, questions)
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
MAX_ENTER_CHAIN = 8
_patient_locks = WeakValueDictionary()
@@ -137,12 +149,18 @@ class Engine:
await self._apply(ctx, await self._start_pending(ctx), run)
self._defer_crisis_messages(ctx, event, run)
if _consented(ctx):
for reply in run.replies:
# Outgoing texts may quote the safety plan (names of relatives) — scrub before storing too.
await repo.add_message(
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
)
prompt = next((r for r in reversed(run.replies) if r.buttons), None)
if prompt is not None and ctx.state not in RESTING_STATES:
# Re-shown after an off-script question so the patient (and the web screen) gets the buttons back.
ctx.data[LAST_PROMPT] = prompt.model_dump()
if event.type != "system" or ctx.state != state_before:
# System events the step ignores (reminders inside a scale) do not count as the patient being there.
ctx.data[STEP_AT] = ctx.now.isoformat()
@@ -165,6 +183,21 @@ class Engine:
)
return run.replies
def _defer_crisis_messages(self, ctx: Ctx, event: Event, run: _Run) -> None:
"""Crisis messages triggered by test answers wait until the questionnaire is over (team decision
2026-10-04: let the patient finish the test, then show the crisis message; it blocks nothing). The doctor's
alert is not delayed — only the patient-facing message. A typed acute message is never deferred."""
if get_settings().crisis_blocking:
return # clinical protocol: the crisis takes over immediately
if event.type == "button" and ctx.state.startswith(QUESTIONNAIRE_PREFIXES):
held = [r for r in run.replies if r.kind in DEFERRABLE_KINDS]
if held:
run.replies[:] = [r for r in run.replies if r.kind not in DEFERRABLE_KINDS]
ctx.data[DEFERRED_CRISIS] = [*(ctx.data.get(DEFERRED_CRISIS) or []), *(r.model_dump() for r in held)]
return
if ctx.data.get(DEFERRED_CRISIS) and not ctx.state.startswith(QUESTIONNAIRE_PREFIXES):
run.replies.extend(Reply.model_validate(r) for r in ctx.data.pop(DEFERRED_CRISIS))
def _abandoned(self, ctx: Ctx) -> bool:
"""The current question step was left unanswered longer than `abandon_after_hours` (app.core.clock)."""
if intake_enabled() or not _consented(ctx) or ctx.state in RESTING_STATES or crisis_open(ctx.data):
@@ -289,8 +322,47 @@ class Engine:
step.notify_monitoring = True
return step
side = await self._side_answer(ctx)
if side is not None:
return side
return await self._dispatch(ctx, event)
async def _side_answer(self, ctx: Ctx) -> Step | None:
"""A question asked in the middle of a questionnaire, a check-in or a crisis is answered right away
(agent → guidelines/instruction), then the pending question is shown again with its buttons — the
chat never ignores a question. Idle and the explicit question states have their own handlers.
In a crisis the answer always carries the emergency contacts; the crisis itself stays open until the
doctor acknowledges it, and acute messages never get here (handled above)."""
from app.dialog.flows.knowledge import knowledge_answer, looks_like_question
in_crisis = (crisis_open(ctx.data) or ctx.state == "crisis") and await ctx.monitoring.is_in_crisis(ctx.token)
free_text_step = ctx.state in SIDE_ANSWER_SKIP or ctx.state.startswith(FREE_TEXT_PREFIXES)
if ctx.text is None or (free_text_step and not in_crisis):
return None # idle / check-in / question states parse free text themselves
asks = looks_like_question(ctx.text) or bool(MEDICINE_WORDS.search(ctx.text))
if asks:
answer = await knowledge_answer(ctx, explicit=True)
elif in_crisis:
# Not a question, but in a crisis every message gets a human reply (with contacts below), never silence.
answer = await ctx.gateway.phrase("support")
else:
return None # e.g. a free-text answer to the current scale item — the step handles it
if not answer:
return None
log.info("dialog side answer token=%s state=%s crisis=%s", ctx.token, ctx.state, in_crisis)
replies = [Reply(text=answer)]
if in_crisis:
script = crisis_reply([])
replies.append(
Reply(
text="\n".join([ctx.texts.crisis.repeat, *crisis_resource_lines(script.resources)]), kind="crisis"
)
)
elif ctx.data.get(LAST_PROMPT):
last = Reply.model_validate(ctx.data[LAST_PROMPT])
replies.append(last.model_copy(update={"text": f"{ctx.texts.side_answer_return}\n\n{last.text}"}))
return Step(replies=replies)
def _can_screen(self, ctx: Ctx, screen: str) -> bool:
if crisis_open(ctx.data) or scale_session.active_scale(ctx.state) == screen:
return False
+5 -1
View File
@@ -66,9 +66,13 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
"""Entry state of the next module onboarding: `"{module}.onboarding.start"` for the first module of the
profile (after `after`, if given) that has such a state registered; `"idle"` when none is left."""
from app.core.demo_settings import screening_enabled
from app.core.intake_runtime import intake_enabled
if after is None and not await screening_enabled(ctx.session):
return "idle" # demo switch: no initial questionnaires, straight to the chat
profile = await ctx.profile()
modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.intake_runtime import intake_enabled
# Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
# person has no profile at all before it.
+47 -3
View File
@@ -4,6 +4,7 @@ import math
from dataclasses import asdict, replace
from zoneinfo import ZoneInfo
from app.config import get_settings
from app.core import interpretation
from app.core.intake_questions import (
DIARY_KINDS,
@@ -15,6 +16,7 @@ from app.core.intake_questions import (
group_answers,
next_intake,
red_flag_category,
red_flag_triggers,
scale_question,
texts,
)
@@ -116,6 +118,39 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
return step
def _defer_urgent(row, code, categories=None):
"""Remember the first crisis signal of this questionnaire; the questionnaire goes on (team decision
2026-10-04: the crisis message comes at the end and blocks nothing)."""
if not row.details.get("pending_urgent"):
row.details = {**row.details, "pending_urgent": {"code": code, "categories": categories or []}}
async def _flush_urgent(ctx, row, step):
"""Questionnaire over: acute alert + callback for the doctor, then the (non-blocking) crisis message."""
pending = (row.details or {}).get("pending_urgent")
if not pending or step.next_state == "intake.collect":
return step
row.details = {**row.details, "pending_urgent": None}
code, categories = pending["code"], pending.get("categories") or []
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
await service.alert(
ctx.session,
ctx.token,
f"{row.id}:{code}",
code,
ctx.now,
level=AlertLevel.ACUTE,
params={"record_id": row.id, "deferred": True},
)
await schedule_post_crisis_call(ctx.session, ctx.token, ctx.now, source="questionnaire:" + code)
crisis = open_crisis(ctx, categories, source="questionnaire:" + code)
for reply in crisis.replies:
reply.buttons.append([Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")])
return step.then(crisis)
async def item9_discrepancy(ctx, row):
profile = await ctx.profile()
if profile:
@@ -170,18 +205,27 @@ async def manual_review(ctx, row, code):
async def advance(ctx, row):
return await _flush_urgent(ctx, row, await _advance(ctx, row))
async def _advance(ctx, row):
a = row.answers
c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy()
# Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category.
for item_id, value in a.items():
if item_id.startswith("rf_") and value == 1:
if item_id.startswith("rf_") and value == 1 and red_flag_triggers(item_id):
category = red_flag_category(item_id)
return await urgent(ctx, row, code="red_flag_" + item_id[3:], categories=[category] if category else [])
if get_settings().crisis_blocking: # clinical protocol: stop at once
return await urgent(ctx, row, code="red_flag_" + item_id[3:], categories=[category] if category else [])
_defer_urgent(row, "red_flag_" + item_id[3:], [category] if category else [])
break
# One crisis rule for the whole app (ADR-013): the crisis screen opens at a HIGH C-SSRS risk only. Moderate →
# stop + psychiatrist (about 24 h); low → flag, safety plan, continue.
if c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal")
if get_settings().crisis_blocking:
return await urgent(ctx, row, code="suicide_signal")
_defer_urgent(row, "suicide_signal")
if a.get("dphq_9", 0) > 0 and row.kind in DIARY_KINDS:
# Diary depression module, item 9 ≥1 → the C-SSRS right away («Этап 2»); its risk level decides.
return await item9_assessment(ctx, row)
+12
View File
@@ -62,6 +62,7 @@ from app.dialog.flows.meds import (
)
from app.dialog.fsm import Ctx, Step, abandonable, on_enter, state, trigger
from app.dialog.scale_session import register_scale_intro
from app.llm import agent
PREFIX = "daily"
STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text")
@@ -429,6 +430,9 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text)
if facts is None:
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return Step(replies=[menu_reply(ctx, agent.render(smart))])
text = await knowledge_answer(ctx, explicit=False) if looks_like_question(ctx.text) else None
return Step(replies=[menu_reply(ctx, text or await ctx.gateway.phrase("support"))])
fields: dict[str, Any] = {}
@@ -446,6 +450,14 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
text = (await side_effect_reply(ctx, codes)).text
if fields:
await save_checkin(ctx, **fields)
if not facts.quit_intent:
# Chat first: whatever the patient writes in idle gets the agent's answer; check-in signals above are
# recorded silently for the doctor. Quit intent keeps its rule reaction (explanation + doctor notified).
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return Step(
replies=[menu_reply(ctx, agent.render(smart))], notify_monitoring=bool(fields or facts.side_effects)
)
if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx))
# A question is answered even if it also mentioned alcohol/side effects (those are recorded above).
+6 -2
View File
@@ -33,6 +33,7 @@ from dataclasses import replace
from datetime import timedelta
from typing import Any
from app.config import get_settings
from app.core.privacy import scrub
from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
from app.db import repo
@@ -237,7 +238,10 @@ async def _record(
sex = profile.sex if profile is not None and profile.sex in ("m", "f") else None
result = score(scale.id, answers, sex=sex) # type: ignore[arg-type]
crisis_flags = [f for f in result.flags if f in ctx.texts.settings.crisis_flags]
if crisis_flags or session["idx"] + 1 >= len(scale.items):
# Clinical protocol (crisis_blocking): a crisis flag ends the scale at once. Demo default: the scale is finished
# first and the crisis message comes at the end of the questionnaire (engine defers it), blocking nothing.
stop_now = bool(crisis_flags) and get_settings().crisis_blocking
if stop_now or session["idx"] + 1 >= len(scale.items):
return await _finish(ctx, scale, session, answers, result, crisis_flags)
updated = {**session, "answers": answers, "idx": session["idx"] + 1}
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
@@ -278,7 +282,7 @@ async def _finish(
# Crisis path decided by the scale's own flags (deterministic), not by the LLM.
risk_screen = ctx.texts.settings.risk_screen_scale
if crisis_open(ctx.data):
crisis = Step(next_state="crisis")
crisis = Step(next_state="crisis") if get_settings().crisis_blocking else Step()
else:
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
step = done.then(crisis)
+1
View File
@@ -113,6 +113,7 @@ class DialogTexts(_Strict):
reach_out: ReachOutTexts
visit: VisitTexts
unavailable: str
side_answer_return: str = "Вернёмся к вопросу:"
def dialog_texts_path() -> Path:
+10 -2
View File
@@ -63,7 +63,11 @@ def _load(path: Path) -> dict[str, PatientRecord]:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
out: dict[str, PatientRecord] = {}
for snils, rec in (raw.get("records") or {}).items():
out[pseudo_id(snils)] = PatientRecord.model_validate(rec)
aliases = rec.pop("tokens", []) if isinstance(rec, dict) else []
record = PatientRecord.model_validate(rec)
out[pseudo_id(snils)] = record
for token in aliases: # demo tokens used by the patient web app (e.g. tok-andrey) share the record
out[token] = record
return out
@@ -73,7 +77,11 @@ class EmiasRecordSource:
async def get(self, token: str) -> PatientRecord | None:
try:
base = _load(self._path or default_records_path()).get(token)
records = _load(self._path or default_records_path())
base = records.get(token)
if base is None and "-" in token:
# Fresh demo copies (POST /auth/demo/fresh): "tok-andrey-1a2b3c4d" → the "tok-andrey" record.
base = records.get(token.rsplit("-", 1)[0])
except (OSError, yaml.YAMLError, ValueError) as exc:
log.error("emias records unavailable: %s", type(exc).__name__)
base = None
+2 -2
View File
@@ -92,9 +92,9 @@ class TelegramDoctorChat:
def _get_sender(self) -> ChatSender:
if self._sender is None:
from aiogram import Bot
from app.channels.telegram import make_bot
self._bot = Bot(self._bot_token)
self._bot = make_bot(self._bot_token)
self._sender = self._bot.send_message
return self._sender
+41 -7
View File
@@ -14,12 +14,14 @@ Guarantees (on top of what the dialog engine already did — scrub, consent, saf
import asyncio
import json
import logging
import re
from typing import Any
from pydantic import BaseModel
from app.config import Settings, get_settings
from app.core.types import ScrubbedText
from app.llm.agent import stream
from app.llm.agent.prompts import SYSTEM_PROMPT
from app.llm.agent.providers import (
GigaChatToolProvider,
@@ -35,6 +37,19 @@ from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200
MAX_TOOL_CALLS_PER_TURN = 4 # seen live: one «как дела?» turn issued 23 parallel guideline searches
# Greetings / small talk: answered without the LLM (instant, free, never drags medical findings into «привет»).
SMALL_TALK = re.compile(
r"^\W*(прив\w*|здравств\w*|здаров\w*|добр(ый|ое|ого)\s+\w+|хай|hello|hi|ку)\b"
r"|\b(как\s+(дела|ты|вы|жизнь|поживаешь)|что\s+делаешь|чем\s+занят\w*|спишь|кто\s+ты|ты\s+(бот|робот|кто))\b",
re.I,
)
SMALL_TALK_MAX_WORDS = 8
SMALL_TALK_REPLY = (
"Здравствуйте! Я на связи. Могу рассказать о вашем лечении: как действует препарат и когда ждать эффекта, "
"какие бывают побочные эффекты, что можно и нельзя сочетать с вашими таблетками, что в составе. "
"Спросите — отвечу по вашей карте, инструкции и клиническим рекомендациям."
)
NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"}
@@ -66,6 +81,15 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
return None
def is_small_talk(text: str) -> bool:
"""Short greeting / chit-chat without a medical question in it."""
words = re.findall(r"\w+", text)
medical = r"(лекарств|таблет|препарат|побоч|доз|лечени|можно|ибупрофен|нурофен|алкогол|пив|сертралин|сон|сплю)"
if len(words) > SMALL_TALK_MAX_WORDS or re.search(medical, text, re.I):
return False
return bool(SMALL_TALK.search(text))
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
"""One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError)."""
wire = _wire(messages) if provider.name == "openrouter" else messages # gigachat needs the tool `name`
@@ -102,6 +126,9 @@ async def answer_chat(
except ValueError:
log.warning("chat agent: unscrubbed input rejected")
return None
if is_small_talk(question):
return AgentAnswer(text=SMALL_TALK_REPLY, sources=[], tools=[], provider="small_talk")
stream.emit("status", "Думаю над вопросом…")
ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)},
@@ -123,17 +150,18 @@ async def answer_chat(
{
"role": "assistant",
"content": turn.content,
"tool_calls": [
"tool_calls": [ # the calls we actually run (capped) — history must match the results
{
"id": c.id,
"type": "function",
"function": {"name": c.name, "arguments": json.dumps(c.arguments, ensure_ascii=False)},
}
for c in turn.tool_calls
for c in turn.tool_calls[:MAX_TOOL_CALLS_PER_TURN]
],
}
)
for c in turn.tool_calls:
for c in turn.tool_calls[:MAX_TOOL_CALLS_PER_TURN]:
stream.emit("status", stream.TOOL_STATUS.get(c.name, "Проверяю данные…"))
result = await run_tool(ctx, c.name, c.arguments)
messages.append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": result})
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
@@ -159,8 +187,14 @@ def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
return AgentAnswer(text=text, sources=ctx.sources[:4], tools=ctx.used, provider=provider)
_MD_BULLET = re.compile(r"^\s*[*-]\s+", re.M)
_MD_MARKS = re.compile(r"\*\*|__|^#+\s*", re.M)
SOURCES_MIN_CHARS = 160 # short replies (greetings, small talk) go without the sources footer
def render(answer: AgentAnswer) -> str:
"""Patient-facing text: answer + short sources footer."""
if not answer.sources:
return answer.text
return f"{answer.text}\n\nИсточники: " + "; ".join(answer.sources)
"""Patient-facing text: answer (markdown stripped to plain text with «•» bullets) + short sources footer."""
text = _MD_MARKS.sub("", _MD_BULLET.sub("• ", answer.text)).strip()
if not answer.sources or len(text) < SOURCES_MIN_CHARS:
return text
return f"{text}\n\nИсточники: " + "; ".join(answer.sources)
+9 -1
View File
@@ -32,7 +32,15 @@ _SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
- проводить психотерапию; обещать конфиденциальность сверх реальной;
- выдумывать факты, которых нет в результатах инструментов.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
На приветствия и болтовню («привет», «как дела?», «спишь?») отвечай 1–2 фразами, тепло, от лица помощника
сервиса (не называй себя «языковой моделью» или «ИИ-моделью») и предложи, с чем можешь помочь: вопросы о
лечении, лекарствах, побочных эффектах, самочувствии. Находки из карты в ответ на болтовню не упоминай.
Никогда не переспрашивай, о каком препарате речь, если вопрос о «моём лекарстве»: это текущее назначение.
Про аналоги и замену: назови группу текущего препарата по инструкции (например, «антидепрессант группы СИОЗС»),
скажи, что в группе есть и другие препараты, а подбор и замену делает только лечащий врач.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений. Без markdown: никаких «*», «#»,
«**»; если нужен список — короткие строки с «•».
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия {phone:helpline}, и что врач будет уведомлён."""
+77 -1
View File
@@ -16,6 +16,7 @@ import httpx
from pydantic import BaseModel, Field
from app.config import Settings
from app.llm.agent import stream
log = logging.getLogger(__name__)
@@ -49,9 +50,12 @@ class OpenRouterProvider:
self._model = settings.openrouter_model
self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
self._timeout = settings.chat_agent_timeout_s
self._proxy = settings.openrouter_proxy_url or None
self._client = client
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
if stream.active():
return await self._chat_stream(messages, tools)
body: dict[str, Any] = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1}
body["max_tokens"] = 900
if not any(m["role"] == "tool" for m in messages):
@@ -61,7 +65,7 @@ class OpenRouterProvider:
if self._client is not None:
resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout)
else:
async with httpx.AsyncClient(timeout=self._timeout) as client:
async with httpx.AsyncClient(timeout=self._timeout, proxy=self._proxy) as client:
resp = await client.post(self._url, json=body, headers=headers)
except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc
@@ -173,6 +177,78 @@ class GigaChatToolProvider:
return AssistantTurn(content=msg.content or None, tool_calls=calls)
# ------------------------------------------------------------------------------------------------ streaming
async def _iter_sse(resp: httpx.Response):
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue # comments («: OPENROUTER PROCESSING») and blank lines
data = line[5:].strip()
if data == "[DONE]":
return
try:
yield json.loads(data)
except json.JSONDecodeError:
continue
async def _openrouter_stream(self: "OpenRouterProvider", messages: list[dict[str, Any]], tools: list[dict[str, Any]]):
"""Same request with `stream: true`: content deltas go to the live channel, tool calls are assembled."""
body: dict[str, Any] = {
"model": self._model,
"messages": messages,
"tools": tools,
"temperature": 0.1,
"max_tokens": 900,
"stream": True,
}
if not any(m["role"] == "tool" for m in messages):
body["tool_choice"] = "required"
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
content: list[str] = []
calls: dict[int, dict[str, Any]] = {}
try:
client = self._client or httpx.AsyncClient(timeout=self._timeout, proxy=self._proxy)
try:
async with client.stream("POST", self._url, json=body, headers=headers, timeout=self._timeout) as resp:
if resp.status_code != 200:
await resp.aread()
raise ProviderError(f"http {resp.status_code}")
async for chunk in _iter_sse(resp):
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
if delta.get("content"):
content.append(delta["content"])
stream.emit("delta", delta["content"])
for tc in delta.get("tool_calls") or []:
slot = calls.setdefault(tc.get("index", 0), {"id": "", "name": "", "args": ""})
slot["id"] = tc.get("id") or slot["id"]
fn = tc.get("function") or {}
slot["name"] += fn.get("name") or ""
slot["args"] += fn.get("arguments") or ""
finally:
if self._client is None:
await client.aclose()
except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc
out = []
for i in sorted(calls):
c = calls[i]
try:
args = json.loads(c["args"] or "{}")
except json.JSONDecodeError:
args = {}
out.append(ToolCall(id=c["id"] or c["name"] or f"call{i}", name=c["name"], arguments=args))
if out and content:
stream.emit("reset", None) # a preface before tool calls is not the answer
log.info("openrouter stream ok calls=%d chars=%d", len(out), sum(map(len, content)))
return AssistantTurn(content="".join(content) or None, tool_calls=out)
OpenRouterProvider._chat_stream = _openrouter_stream # type: ignore[attr-defined]
# ------------------------------------------------------------------------------------------------ offline mock
_DRUG_WORDS = re.compile(
+32
View File
@@ -0,0 +1,32 @@
"""Live progress of the chat agent for streaming channels (web SSE).
The SSE endpoint sets a queue in the context; the agent pushes events while it works:
status — a tool step in plain Russian («Смотрю вашу карту ЕМИАС…»)
delta — a chunk of the answer as the model writes it
reset — the streamed text so far was a preface before tool calls: clear it
Channels without a queue (Telegram, tests) are unaffected. Deltas are the raw model text: the final reply
(forbidden-output filter, sources footer) always comes in the closing `done` event and replaces it.
"""
import asyncio
from contextvars import ContextVar
from typing import Any
STREAM: ContextVar[asyncio.Queue[tuple[str, Any]] | None] = ContextVar("agent_stream", default=None)
TOOL_STATUS = {
"get_patient_record": "Смотрю вашу карту ЕМИАС…",
"check_drug_for_patient": "Проверяю препарат по вашей карте: болезни, непереносимости, другие лекарства…",
"get_drug_info": "Открываю инструкцию к препарату (ГРЛС)…",
"search_guidelines": "Ищу в клинических рекомендациях Минздрава…",
}
def active() -> bool:
return STREAM.get() is not None
def emit(kind: str, payload: Any) -> None:
q = STREAM.get()
if q is not None:
q.put_nowait((kind, payload))
+3 -1
View File
@@ -43,7 +43,9 @@ FORBIDDEN_OUTPUT: tuple[re.Pattern[str], ...] = tuple(
for p in (
r"диагноз",
r"у (вас|тебя) (\w+ )?(депресси|птср|тревожн\w* расстройств|расстройств|биполяр|шизофрен)",
r"(это|похоже на|признаки) (\w+ )?(депресси|птср|посттравматическ|расстройств)",
# Diagnosis-like statements about the patient. A plain definition («ПТСР — это расстройство, которое…»)
# is education, not a diagnosis, so bare «это …» is no longer blocked.
r"(похоже на|признаки|скорее всего,? у вас|у вас,? похоже) (\w+ )?(депресси|птср|посттравматическ|расстройств)",
r"(увеличьте|уменьшите|снизьте|повысьте|удвойте|измените|увеличь|уменьши|снизь) (\w+ )?доз",
r"(?<!не )отмен(ите|и) ",
r"(?<!не )(прекратите|перестаньте|бросьте) (\w+ )?(приём|прием|принимать|пить)",
+162
View File
@@ -0,0 +1,162 @@
"""Speech-to-text for the patient's voice input (#116, slide 11: record → check the text → send).
The service only transcribes: the text goes back to the patient to check and edit, and reaches the dialog as an
ordinary text message — safety, `scrub()` and the LLM gateway apply there. Audio is never stored or logged.
* `OpenRouterSTT` — an audio-capable model via OpenRouter `/chat/completions` (`input_audio`). TEMPORARY, synthetic
data only (ADR-014): voice is personal data that `scrub()` cannot depersonalise, and OpenRouter is a foreign
service (152-FZ). Real patients need an RF provider (SaluteSpeech, #121) behind the same `SpeechToText` contract.
* `MockSTT` — offline, returns a fixed synthetic phrase (ADR-004: the demo works without network).
"""
import asyncio
import base64
import logging
import struct
from functools import lru_cache
from pathlib import Path
from typing import Protocol
import httpx
import yaml
from pydantic import BaseModel
from app.config import Settings
log = logging.getLogger(__name__)
_RETRY_STATUSES = {429, 500, 502, 503, 504}
_ATTEMPTS = 3
class SttTexts(BaseModel):
system: str
user: str
silence_marker: str
mock_transcript: str
@lru_cache
def load_texts(path: Path | None = None) -> SttTexts:
from app.config import get_settings
path = path or get_settings().config_dir / "texts" / "stt.yaml"
return SttTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
class SttError(RuntimeError):
"""The provider failed (network, HTTP error, bad response)."""
class SttNoSpeech(SttError):
"""The recording holds no recognisable speech."""
class InvalidAudio(ValueError):
pass
def wav_seconds(audio: bytes) -> float:
"""Duration of a PCM WAV file; raises `InvalidAudio` for anything else."""
if len(audio) < 44 or audio[:4] != b"RIFF" or audio[8:12] != b"WAVE":
raise InvalidAudio("not a WAV file")
pos, byte_rate = 12, 0
while pos + 8 <= len(audio):
chunk_id, size = audio[pos : pos + 4], struct.unpack_from("<I", audio, pos + 4)[0]
if chunk_id == b"fmt " and size >= 16:
byte_rate = struct.unpack_from("<I", audio, pos + 16)[0]
elif chunk_id == b"data":
if not byte_rate:
break
return min(size, len(audio) - pos - 8) / byte_rate
pos += 8 + size + (size & 1)
raise InvalidAudio("malformed WAV")
class SpeechToText(Protocol):
name: str
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str: ...
class MockSTT:
name = "mock"
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
return load_texts().mock_transcript
class OpenRouterSTT:
name = "openrouter"
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
self._key = settings.openrouter_api_key
self._model = settings.stt_model
self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
self._timeout = settings.stt_timeout_s
self._proxy = settings.openrouter_proxy_url or None
self._client = client
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
texts = load_texts()
content = [
{"type": "text", "text": texts.user},
{"type": "input_audio", "input_audio": {"data": base64.b64encode(audio).decode(), "format": fmt}},
]
body = {
"model": self._model,
"messages": [{"role": "system", "content": texts.system}, {"role": "user", "content": content}],
"temperature": 0,
"max_tokens": 800,
# Transcription needs no thinking: with reasoning on, gemini-2.5-flash takes ~60 s instead of ~3 s.
"reasoning": {"enabled": False},
}
data = await self._post(body)
try:
text = (data["choices"][0]["message"]["content"] or "").strip()
except (KeyError, IndexError, TypeError) as exc:
raise SttError("bad response") from exc
text = text.strip("«»\"' \n")
if not text or text.lower() == texts.silence_marker.lower():
raise SttNoSpeech("no speech")
return text
async def _post(self, body: dict) -> dict:
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
for attempt in range(_ATTEMPTS):
try:
if self._client is not None:
resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout)
else:
async with httpx.AsyncClient(timeout=self._timeout, proxy=self._proxy) as client:
resp = await client.post(self._url, json=body, headers=headers)
except httpx.HTTPError as exc:
if attempt + 1 == _ATTEMPTS:
raise SttError(type(exc).__name__) from exc
await asyncio.sleep(0.5 * 2**attempt)
continue
if resp.status_code in _RETRY_STATUSES and attempt + 1 < _ATTEMPTS:
log.warning("stt openrouter http %s, retry %d", resp.status_code, attempt + 1)
await asyncio.sleep(0.5 * 2**attempt)
continue
if resp.status_code != 200:
# Provider error bodies carry no patient data — log the message to make failures diagnosable.
log.error("stt openrouter http %s: %s", resp.status_code, resp.text[:200])
raise SttError(f"http {resp.status_code}")
try:
return resp.json()
except ValueError as exc:
raise SttError("bad json") from exc
raise SttError("retries exhausted")
def get_stt(settings: Settings) -> SpeechToText | None:
"""The configured provider; `None` = voice input is off. `auto` = OpenRouter if a key is set, else mock."""
mode = settings.stt_provider
if mode == "auto":
mode = "openrouter" if settings.openrouter_api_key else "mock"
if mode == "openrouter":
return OpenRouterSTT(settings) if settings.openrouter_api_key else None
if mode == "mock":
return MockSTT()
return None
+32
View File
@@ -51,6 +51,37 @@ async def refresh_status(session: AsyncSession, token: str) -> str:
return status.color
# Which data a crisis rule is triggered by: a crisis for the same code is raised again only when such data is
# newer than the last alert with that code (acked or not) — otherwise an acked PHQ-9 item 9 re-fired on every
# data change for 24 h (seen live: AUDIT-C answers 0/0/0 → crisis again).
_CRISIS_TRIGGERS: dict[str, tuple[str, ...]] = {
"phq9_item9": ("phq9",),
"cssrs_high": ("cssrs_screen",),
"cssrs_moderate": ("cssrs_screen",),
"weapon_access_risk": ("phq9", "cssrs_screen"),
}
async def _drop_handled_crises(s: AsyncSession, token: str, actions: list[Action]) -> list[Action]:
crisis_codes = {a.code for a in actions if a.type == "crisis"}
if not crisis_codes:
return actions
alerts = await repo.list_alerts(s, token=token)
results = await repo.list_scale_results(s, token)
handled: set[str] = set()
for code in crisis_codes:
last_alert = max((a.ts for a in alerts if a.code == code), default=None)
if last_alert is None:
continue
scales = _CRISIS_TRIGGERS.get(code)
last_data = max((r.ts for r in results if scales is None or r.scale in scales), default=None)
if last_data is None or last_data <= last_alert:
handled.add(code)
if handled:
log.info("monitoring token=%s crisis already handled=%s", token, sorted(handled))
return [a for a in actions if a.code not in handled]
def _drop_running_crises(actions: list[Action], open_acute: set[str]) -> list[Action]:
"""Silence every action of a crisis rule whose acute alert is still open (see module docstring)."""
running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute}
@@ -68,6 +99,7 @@ class RuleMonitoring:
return []
open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"}
actions = _drop_running_crises(evaluate(snapshot), open_acute)
actions = await _drop_handled_crises(s, token, actions)
for action in actions:
if action.type == "enhanced_monitoring":
profile.enhanced_monitoring = True
+2
View File
@@ -17,6 +17,8 @@ items:
- id: rf_weapons
text: Есть ли у Вас дома или рядом доступ к оружию?
category: null
# Asked and stored, but NOT a stop rule (team decision 2026-10-04: weapon ownership is common in Russia).
trigger: false
- id: rf_violence
text: Угрожает ли Вам сейчас кто-то дома или применяет к Вам насилие?
category: domestic_violence
+1
View File
@@ -47,6 +47,7 @@ rules:
reason_ru: "C-SSRS: высокий риск (намерение/план или поведение)"
- id: weapon_access_risk
enabled: false # team decision 2026-10-04: weapon ownership is common in Russia — the answer triggers nothing
description: >
Access to weapons together with any suicide-risk signal (recent PHQ-9 item 9 >= 1 or C-SSRS >= moderate).
Means restriction is one of the most evidence-based suicide-prevention measures → crisis safety block.
+3
View File
@@ -118,3 +118,6 @@ visit:
unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия {phone:helpline}.
# Answer to an off-script question is followed by the pending question again (engine side answer).
side_answer_return: "Вернёмся к вопросу:"
+15
View File
@@ -33,6 +33,21 @@ question_starts:
- "чем"
- "где"
- "есть ли"
- "что содержится"
- "что содержат"
- "что входит"
- "что в составе"
- "какой состав"
- "какие побочки"
- "какие побочные"
- "состав"
- "какие"
- "можно"
- "из чего"
- "в чём"
- "в чем"
- "расскажи"
- "объясни"
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
+17
View File
@@ -0,0 +1,17 @@
# Распознавание голоса пациента (#116). Модель только расшифровывает речь — не отвечает и не советует.
# Расшифровка возвращается пациенту на проверку и уходит в диалог обычным текстом (safety → scrub → LLM).
# Системная инструкция модели распознавания.
system: >-
Ты — система распознавания речи. Дословно расшифруй русскую речь из аудио.
Выведи только расшифровку: без кавычек, пояснений и ответа на сказанное.
Ставь знаки препинания. Если в записи совсем нет речи — выведи ровно [тишина].
# Текст рядом с аудио в сообщении пользователя.
user: "Расшифруй эту аудиозапись."
# Что модель возвращает, если речи нет (сравнивается без учёта регистра).
silence_marker: "[тишина]"
# Офлайн-режим (STT_PROVIDER=mock): фиксированная фраза демо-пациента, синтетика.
mock_transcript: "Сегодня долго не мог уснуть. Хочу обсудить это на следующей встрече."
+2 -1
View File
@@ -14,7 +14,8 @@ status: "черновик, утверждает психиатр (#8)"
settings:
# Флаги шкал (config/scales/*.yaml), которые считаются сигналом риска для блока безопасного хранения оружия.
# Положительный п.9 PHQ-9 и C-SSRS — суицидальный риск; AUDIT-C — алкоголь как фактор риска при доступе к оружию.
weapon_risk_flags: [phq9_item9_positive, cssrs_high, cssrs_moderate, auditc_positive]
# The weapon question exists but affects nothing (team decision 2026-10-04): no safety block, no trigger.
weapon_risk_flags: []
intro:
text: |-
+6
View File
@@ -103,6 +103,12 @@ class ConsoleApi:
def reset(self) -> dict[str, Any]:
return dict(self._request("POST", "/api/v1/demo/reset", timeout=60))
def demo_settings(self) -> dict[str, Any]:
return dict(self._request("GET", "/api/v1/demo/settings"))
def set_screening(self, enabled: bool) -> dict[str, Any]:
return dict(self._request("POST", "/api/v1/demo/settings", json={"screening_enabled": enabled}))
# ------------------------------------------------------------------ patient dialog
def send_event(
self,
@@ -33,6 +33,20 @@ w.render_connect_hero()
w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}")
# One switch for the presentation: initial questionnaires after consent on/off (off → straight to the chat).
try:
_screening = bool(w.get_api().demo_settings().get("screening_enabled", True))
_wanted = st.sidebar.toggle(
"Начальный тест (скрининг)",
value=_screening,
help="Выключено: после согласия пациент сразу попадает в чат, без опросников.",
)
if _wanted != _screening:
w.get_api().set_screening(_wanted)
st.sidebar.success("Начальный тест " + ("включён" if _wanted else "выключен"))
except Exception: # noqa: BLE001 — the switch must never break the console
st.sidebar.caption("Переключатель теста недоступен (API)")
with st.container(border=True, key="panel-console-clock"):
w.render_clock_bar(persona, key="console-clock")
+2
View File
@@ -8,10 +8,12 @@
# allergies — аллергии и непереносимости; `tags` сверяются с составом препаратов (вспомогательные вещества)
# other_medications — назначения других специалистов; `classes` сверяются со взаимодействиями
# pregnancy — для женщин: беременность/лактация (null — нет данных)
# tokens — демо-токены веб-приложения, которым принадлежит та же карта
# Текущее психотропное назначение приходит из таблицы prescriptions (мок МИС, app/integrations/mis_mock.py).
records:
"900-000-013 34": # Андрей С., 34, ветеран СВО — герой демо
tokens: [tok-andrey] # демо-вход веб-приложения пациента
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
+9
View File
@@ -47,5 +47,14 @@ services:
ports:
- "8501:8501"
web:
# Patient web app (React) — built inside Docker, no Node needed on the host.
build: ./web
depends_on:
app:
condition: service_healthy
ports:
- "8080:80"
volumes:
pgdata:
+9
View File
@@ -3,6 +3,15 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](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 -2
View File
@@ -29,9 +29,8 @@ cp .env app.new/.env
rm -rf app.old; [ -d app ] && mv app app.old; mv app.new app
chmod 755 $DIR
cd app
echo "$SHA" > REVISION
docker run --rm --user "\$(id -u):\$(id -g)" -e HOME=/tmp -e npm_config_cache=/tmp/.npm -v "\$PWD/web:/w" -w /w node:20-alpine sh -c "npm ci --no-audit --no-fund >/dev/null && npm run build >/dev/null"
docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build </dev/null
for i in \$(seq 1 40); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 3; done
curl -sf 127.0.0.1:8010/health >/dev/null && echo "api ok @ $SHA"
curl -sf 127.0.0.1:8010/health >/dev/null && echo "$SHA" > REVISION && echo "api ok @ $SHA"
SH
+4
View File
@@ -28,3 +28,7 @@ services:
- "127.0.0.1:8511:8501"
restart: unless-stopped
mem_limit: 768m
web:
# On the server the host nginx serves the web bundle itself (deploy.sh builds web/dist).
profiles: ["local-only"]
+1
View File
@@ -20,6 +20,7 @@ dependencies = [
"pandas>=2.2",
"qrcode[pil]>=7.4",
"tzdata>=2026.5",
"aiohttp-socks>=0.8",
]
[dependency-groups]
+2
View File
@@ -12,6 +12,8 @@ def _no_live_llm(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CHAT_AGENT", "off")
monkeypatch.setenv("OPENROUTER_API_KEY", "")
monkeypatch.setenv("LLM_PROVIDER", "mock")
# Tests cover the clinical protocol by default (blocking crisis); demo-mode tests flip it explicitly.
monkeypatch.setenv("CRISIS_BLOCKING", "true")
get_settings.cache_clear()
yield
get_settings.cache_clear()
+83
View File
@@ -0,0 +1,83 @@
"""Live agent output: OpenRouter stream parsing and the SSE endpoint (status → delta → done)."""
import asyncio
import json
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.db import session
from app.db.bootstrap import init_db
from app.llm import agent
from app.llm.agent import stream
from app.llm.agent.providers import OpenRouterProvider
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
def _sse(*chunks: dict) -> bytes:
lines = [": OPENROUTER PROCESSING", ""]
lines += [f"data: {json.dumps(c, ensure_ascii=False)}" for c in chunks] + ["data: [DONE]", ""]
return "\n\n".join(lines).encode()
async def test_openrouter_stream_deltas_and_tool_calls():
body = _sse(
{"choices": [{"delta": {"content": "Сейчас "}}]},
{
"choices": [
{
"delta": {
"tool_calls": [{"index": 0, "id": "c1", "function": {"name": "get_patient_", "arguments": ""}}]
}
}
]
},
{"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"name": "record", "arguments": "{}"}}]}}]},
)
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, content=body)))
provider = OpenRouterProvider(Settings(_env_file=None, openrouter_api_key="k"), client=client)
q: asyncio.Queue = asyncio.Queue()
token = stream.STREAM.set(q)
try:
turn = await provider.chat([{"role": "user", "content": "x"}], [])
finally:
stream.STREAM.reset(token)
assert [c.name for c in turn.tool_calls] == ["get_patient_record"]
events = [q.get_nowait() for _ in range(q.qsize())]
assert ("delta", "Сейчас ") in events and ("reset", None) in events
@pytest.fixture
async def client():
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
yield c
await session.configure()
async def test_sse_endpoint_streams_steps_then_final(client, monkeypatch):
async def fake(token, text, **kw):
stream.emit("status", stream.TOOL_STATUS["get_patient_record"])
stream.emit("delta", "Ответ ")
stream.emit("delta", "агента")
return agent.AgentAnswer(text="Ответ агента", sources=[], tools=["get_patient_record"], provider="fake")
monkeypatch.setattr(agent, "answer_chat", fake)
token = (await client.post("/api/v1/auth/demo/fresh", json={})).json()["token"]
kinds = []
async with client.stream(
"POST", f"/api/v1/dialog/{token}/events/stream", json={"type": "text", "text": "Можно ибупрофен?"}
) as resp:
assert resp.headers["content-type"].startswith("text/event-stream")
async for line in resp.aiter_lines():
if line.startswith("event:"):
kinds.append(line[6:].strip())
if line.startswith("data:") and kinds and kinds[-1] == "done":
final = json.loads(line[5:])
assert kinds[0] == "status" and "delta" in kinds and kinds[-1] == "done"
assert any("Ответ агента" in r["text"] for r in final)
+13 -9
View File
@@ -87,10 +87,14 @@ async def test_emias_rejects_unknown_code(client: AsyncClient) -> None:
async def test_emias_demo_persona(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0]
res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})
assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name}
res = (await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})).json()
# Every visitor gets a private copy of the persona, never the shared account.
assert res["token"].startswith(persona.token + "-") and res["mode"] == "emias"
assert res["display_name"] == persona.display_name
again = (await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})).json()
assert again["token"] != res["token"]
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, persona.token)).doctor_id == fixtures.DOCTOR_ID
assert (await repo.get_profile(s, res["token"])).doctor_id == fixtures.DOCTOR_ID
missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"})
assert missing.status_code == 404
@@ -141,8 +145,8 @@ async def test_today_links_prescription_to_reference_and_therapy_day(client: Asy
async def test_demo_persona_week_mirrors_dashboard_story(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token})
persona = auth.json()["token"]
rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["therapy_day"] > 1
@@ -159,8 +163,8 @@ async def test_questionnaires_are_off_without_release_or_demo_flag(
async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "1")
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token})
persona = auth.json()["token"]
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
@@ -263,9 +267,9 @@ async def test_connect_care_does_not_reassign_or_create_patients(client: AsyncCl
async def test_screening_capability_respects_release_gate(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": False}
assert (await client.get("/api/v1/patient/capabilities")).json()["primary_screening"] is False
monkeypatch.setenv("INTAKE_DEMO", "1")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": True}
assert (await client.get("/api/v1/patient/capabilities")).json()["primary_screening"] is True
async def test_anonymous_contact_request_reaches_doctor(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
+71
View File
@@ -0,0 +1,71 @@
"""Voice input endpoint (#116): transcribes only, validates the audio, never touches the dialog."""
from collections.abc import AsyncIterator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.api import patient_web
from app.db import session
from app.db.bootstrap import init_db
from app.db.clinical import Message
from app.llm.stt import MockSTT, SttError, SttNoSpeech, load_texts
from app.main import app
from tests.test_llm_stt import make_wav
MEM = "sqlite+aiosqlite:///:memory:"
WAV = {"Content-Type": "audio/wav"}
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
monkeypatch.setattr(patient_web, "get_stt", lambda _settings: MockSTT()) # never the network in tests
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def _token(client: AsyncClient) -> str:
return (await client.post("/api/v1/auth/anonymous")).json()["token"]
async def test_transcribes_without_writing_to_dialog(client: AsyncClient) -> None:
token = await _token(client)
resp = await client.post(f"/api/v1/me/{token}/stt", content=make_wav(2), headers=WAV)
assert resp.status_code == 200
assert resp.json() == {"text": load_texts().mock_transcript, "provider": "mock"}
async with session.get_clinical_session() as s:
assert (await s.execute(select(Message))).scalars().all() == []
async def test_capability_and_off(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
assert (await client.get("/api/v1/patient/capabilities")).json()["voice_input"] is True
monkeypatch.setattr(patient_web, "get_stt", lambda _settings: None)
assert (await client.get("/api/v1/patient/capabilities")).json()["voice_input"] is False
token = await _token(client)
assert (await client.post(f"/api/v1/me/{token}/stt", content=make_wav(1), headers=WAV)).status_code == 404
async def test_rejects_unknown_patient_bad_format_and_long_audio(client: AsyncClient) -> None:
assert (await client.post("/api/v1/me/nobody/stt", content=make_wav(1), headers=WAV)).status_code == 404
token = await _token(client)
url = f"/api/v1/me/{token}/stt"
assert (await client.post(url, content=b"OggS" + b"\0" * 200)).status_code == 415
assert (await client.post(url, content=make_wav(70), headers=WAV)).status_code == 413
assert (await client.post(url, content=make_wav(10, rate=48_000), headers=WAV)).status_code == 200
@pytest.mark.parametrize(("error", "status"), [(SttNoSpeech("x"), 422), (SttError("x"), 503)])
async def test_provider_errors(client: AsyncClient, monkeypatch: pytest.MonkeyPatch, error, status) -> None:
class Failing:
name = "failing"
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
raise error
monkeypatch.setattr(patient_web, "get_stt", lambda _settings: Failing())
token = await _token(client)
assert (await client.post(f"/api/v1/me/{token}/stt", content=make_wav(1), headers=WAV)).status_code == status
+1
View File
@@ -79,6 +79,7 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
"questionnaire_notices",
"questionnaire_tasks",
"scheduler_events",
"demo_settings",
} == clinical_tables
await init_db() # idempotent
+36
View File
@@ -0,0 +1,36 @@
"""Demo mode (crisis_blocking off): a crisis signal inside a questionnaire does not stop it; the crisis message
comes at the end and blocks nothing; the weapon question triggers nothing."""
import pytest
from app.db.clinical import Alert
from tests.test_dialog_intake import answer_button, complete_existing, press, released, rows # noqa: F401
@pytest.fixture
def non_blocking(monkeypatch):
from app.config import get_settings
monkeypatch.setattr(get_settings(), "crisis_blocking", False)
async def test_red_flag_does_not_stop_the_questionnaire(released, non_blocking): # noqa: F811
replies = await press(released, "intake:start")
replies = await press(released, answer_button(replies, {"rf_psychosis": 0})) # «Да» on a red flag
assert not any(r.kind == "crisis" for r in replies), "no crisis window in the middle of the test"
assert any(r.buttons for r in replies), "the next question is asked"
(row,) = await rows()
assert row.status != "interrupted" and row.details["pending_urgent"]["code"] == "red_flag_psychosis"
replies = await complete_existing(released, replies, {})
assert any(r.kind == "crisis" for r in replies), "the crisis message comes at the end"
assert any(a.code == "red_flag_psychosis" and a.level == "acute" for a in await rows(Alert))
async def test_weapon_answer_triggers_nothing(released, non_blocking): # noqa: F811
replies = await press(released, "intake:start")
for _ in range(2): # psychosis, harm to others: «Нет»
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies, {"rf_weapons": 0})) # «Да» on weapons
assert not any(r.kind == "crisis" for r in replies)
(row,) = await rows()
assert not row.details.get("pending_urgent")
+45
View File
@@ -0,0 +1,45 @@
"""Presentation mode: fresh private demo patient straight into the chat; screening switch skips onboarding."""
from collections.abc import AsyncIterator
import pytest
from httpx import ASGITransport, AsyncClient
from app.core import demo_settings
from app.db import repo, session
from app.db.bootstrap import init_db
from app.integrations.emias_record import emias_records
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
yield c
await session.configure()
async def test_fresh_demo_patients_are_private_and_ready(client):
a = (await client.post("/api/v1/auth/demo/fresh", json={})).json()["token"]
b = (await client.post("/api/v1/auth/demo/fresh", json={})).json()["token"]
assert a != b and a.startswith("tok-andrey-")
async with session.get_clinical_session() as s:
st = await repo.get_dialog_state(s, a)
assert st.state == "idle" and st.data["consent"] is True
rec = await emias_records.get(a)
assert rec is not None and any("язвенная" in c.name.lower() for c in rec.conditions)
r = await client.post(f"/api/v1/dialog/{a}/events", json={"type": "text", "text": "привет"})
assert r.status_code == 200 and r.json()["replies"]
async def test_screening_switch(client):
assert (await client.get("/api/v1/demo/settings")).json() == {"screening_enabled": True}
assert (await client.post("/api/v1/demo/settings", json={"screening_enabled": False})).json() == {
"screening_enabled": False
}
async with session.get_clinical_session() as s:
assert await demo_settings.screening_enabled(s) is False
+2 -1
View File
@@ -270,7 +270,8 @@ async def test_repeat_crisis_does_not_duplicate_script(dbs: None, engine: Engine
assert r[0].text.startswith(TEXTS.crisis.repeat) and "112" in r[0].text
assert spy.calls == []
r = await text(engine, "ладно")
assert r[0].text == TEXTS.crisis.hold
# In a crisis every message gets a human reply, followed by the emergency contacts (chat never goes silent).
assert r[-1].kind == "crisis" and "112" in r[-1].text and r[0].kind != "crisis"
_, data = await get_state()
assert [e["handled"] for e in data["safety_events"]] == ["crisis_script", "crisis_repeat"]
+24
View File
@@ -0,0 +1,24 @@
"""In idle every message goes to the chat agent (when it is on); quit intent keeps the rule reaction."""
from app.llm import agent
from app.llm.agent import AgentAnswer
from tests.test_dialog_knowledge import TOKEN, engine, joined, say # noqa: F401
async def test_idle_text_goes_to_agent(engine, monkeypatch): # noqa: F811
seen = []
async def fake(token, text, **kw):
seen.append(str(text))
return AgentAnswer(text="ОТВЕТ АГЕНТА", sources=[], tools=["search_guidelines"], provider="fake")
monkeypatch.setattr(agent, "answer_chat", fake)
for msg in ("прив", "какие у меня будут ощущения и побочки от лекарств", "а какие аналоги моего лекарства есть"):
replies = await say(engine, msg)
assert "ОТВЕТ АГЕНТА" in joined(replies), msg
assert len(seen) == 3 and all(t == TOKEN for t in [TOKEN])
async def test_idle_falls_back_when_agent_off(engine): # noqa: F811
replies = await say(engine, "прив")
assert replies and "ОТВЕТ АГЕНТА" not in joined(replies)
+73
View File
@@ -0,0 +1,73 @@
"""Questions are answered in any state (side answer): mid-scale the pending question comes back with its
buttons; in a crisis the answer carries the emergency contacts and the crisis stays open."""
from app.core import clock
from app.db import repo, session
from app.services.contracts import NoopMonitoring
from tests.test_dialog_knowledge import TOKEN, buttons, engine, joined, press, say, state # noqa: F401
class _InCrisis(NoopMonitoring):
async def is_in_crisis(self, token: str) -> bool: # an open acute alert, as RuleMonitoring would report
return True
async def test_question_mid_scale_is_answered_and_question_returns(engine): # noqa: F811
await press(engine, "menu:checkin") # some flow with buttons; then force a scale for determinism
async with session.get_clinical_session() as s:
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
from app.dialog.contracts import Event
first = await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
assert buttons(first), "scale question with buttons expected"
st_before, _ = await state()
replies = await say(engine, "Через сколько начнёт действовать лекарство?")
st_after, data = await state()
assert st_after == st_before # still inside the scale
assert "2–4 недели" in joined(replies) or "врач" in joined(replies).lower()
assert buttons(replies), "the pending question is shown again with its buttons"
assert "Вернёмся к вопросу" in joined(replies)
assert data.get("last_prompt")
async def test_question_in_crisis_gets_answer_and_contacts(engine): # noqa: F811
engine._monitoring = _InCrisis()
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="crisis", data={"consent": True, "crisis": {"open": True}}, now=clock.now()
)
replies = await say(engine, "Через сколько начнёт действовать лекарство?")
st, _ = await state()
assert st == "crisis"
kinds = [r.kind for r in replies]
assert "crisis" in kinds and len(replies) >= 2
assert "112" in joined(replies)
async def test_non_question_mid_scale_still_goes_to_the_scale(engine): # noqa: F811
from app.dialog.contracts import Event
await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
replies = await say(engine, "ну так себе")
assert "Вернёмся к вопросу" not in joined(replies)
async def test_crisis_any_text_gets_a_reply_with_contacts(engine): # noqa: F811
engine._monitoring = _InCrisis()
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="crisis", data={"consent": True, "crisis": {"open": True}}, now=clock.now()
)
for text in ("привет", "мне страшно", "что содержится в моих лекарствах"):
replies = await say(engine, text)
assert len(replies) >= 2, text
assert replies[-1].kind == "crisis" and "112" in joined(replies)
assert replies[0].kind != "crisis" # a real reply first, not only the stub
async def test_medicine_mention_without_question_mark_mid_scale(engine): # noqa: F811
from app.dialog.contracts import Event
await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
replies = await say(engine, "что содержится в моих лекарствах")
assert "Вернёмся к вопросу" in joined(replies) and buttons(replies)
+1 -1
View File
@@ -268,7 +268,7 @@ async def test_auditc_sex_declined_uses_default_threshold(dbs_nosex: None, engin
assert (await results())["auditc"].severity == "positive" # unknown sex → sensitive threshold ≥ 3
# Weapon at home + AUDIT-C positive = risk signal → safe-storage block.
r = await press(engine, "vonb:weapon:self")
assert VT.weapon.safety_block_self in [x.text for x in r]
assert VT.weapon.safety_block_self not in [x.text for x in r] # the weapon answer affects nothing
# ----------------------------------------------------------------------------------------------- weapon / safety
+29
View File
@@ -269,3 +269,32 @@ def test_build_provider_prefers_gigachat():
s = Settings(_env_file=None, chat_agent="auto", gigachat_credentials="x", openrouter_api_key="y")
assert isinstance(agent.build_provider(s), GigaChatToolProvider)
async def test_web_demo_token_shares_andreys_record():
rec = await emias_records.get("tok-andrey")
assert rec is not None and any("язвенная" in c.name.lower() for c in rec.conditions)
@pytest.mark.parametrize("text", ["прив", "Привет!", "как дела у тебя ? что делаешь ? спишь ?", "добрый вечер"])
async def test_small_talk_without_llm(text):
provider, sent = _fake_openrouter("x", _CALLS)
ans = await agent.answer_chat(ANDREY, scrub(text), provider=provider, settings=_settings())
assert ans is not None and ans.provider == "small_talk" and sent == []
async def test_medical_question_is_not_small_talk():
assert not agent.is_small_talk("привет, можно мне ибупрофен?")
async def test_tool_calls_are_capped(sertraline):
many = [
{"id": f"s{i}", "type": "function", "function": {"name": "search_guidelines", "arguments": '{"query": "x"}'}}
for i in range(20)
]
provider, sent = _fake_openrouter("Ответ по рекомендациям, обсудите с врачом.", many)
ans = await agent.answer_chat(ANDREY, scrub("что такое ПТСР?"), provider=provider, settings=_settings())
assert ans is not None and len(ans.tools) == agent.MAX_TOOL_CALLS_PER_TURN
tool_msgs = [m for m in sent[1]["messages"] if m["role"] == "tool"]
assistant = [m for m in sent[1]["messages"] if m["role"] == "assistant"][0]
assert len(tool_msgs) == len(assistant["tool_calls"]) == agent.MAX_TOOL_CALLS_PER_TURN
+107
View File
@@ -0,0 +1,107 @@
"""Speech-to-text providers (#116): OpenRouter request shape, retries, silence, WAV parsing, provider choice."""
import base64
import io
import json
import wave
import httpx
import pytest
from app.config import Settings
from app.llm.stt import (
InvalidAudio,
MockSTT,
OpenRouterSTT,
SttError,
SttNoSpeech,
get_stt,
load_texts,
wav_seconds,
)
def make_wav(seconds: float, rate: int = 16_000) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(b"\x00\x00" * int(rate * seconds))
return buf.getvalue()
def _settings(**kw) -> Settings:
base = {"openrouter_api_key": "test", "stt_provider": "openrouter", "stt_timeout_s": 5.0}
return Settings(_env_file=None, **{**base, **kw})
def _fake(responses: list[httpx.Response]) -> tuple[OpenRouterSTT, list[dict]]:
sent: list[dict] = []
replies = iter(responses)
def handler(request: httpx.Request) -> httpx.Response:
sent.append(json.loads(request.content))
return next(replies)
stt = OpenRouterSTT(_settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
return stt, sent
def _ok(text: str) -> httpx.Response:
return httpx.Response(200, json={"choices": [{"message": {"content": text}}]})
def test_wav_seconds() -> None:
assert wav_seconds(make_wav(2.5)) == pytest.approx(2.5)
with pytest.raises(InvalidAudio):
wav_seconds(b"OggS" + b"\x00" * 100)
with pytest.raises(InvalidAudio):
wav_seconds(b"")
async def test_openrouter_sends_audio_and_returns_transcript() -> None:
audio = make_wav(1)
stt, sent = _fake([_ok("«Сегодня плохо спал.»")])
assert await stt.transcribe(audio) == "Сегодня плохо спал."
body = sent[0]
assert body["model"] == "google/gemini-2.5-flash" and body["temperature"] == 0
assert body["reasoning"] == {"enabled": False}
part = body["messages"][1]["content"][1]
assert part["type"] == "input_audio" and part["input_audio"]["format"] == "wav"
assert base64.b64decode(part["input_audio"]["data"]) == audio
async def test_silence_marker_is_no_speech() -> None:
stt, _ = _fake([_ok(load_texts().silence_marker.upper())])
with pytest.raises(SttNoSpeech):
await stt.transcribe(make_wav(1))
async def test_retries_then_succeeds(monkeypatch) -> None:
monkeypatch.setattr("app.llm.stt.asyncio.sleep", _no_sleep)
stt, sent = _fake([httpx.Response(503), _ok("текст")])
assert await stt.transcribe(make_wav(1)) == "текст"
assert len(sent) == 2
async def test_http_error_raises(monkeypatch) -> None:
monkeypatch.setattr("app.llm.stt.asyncio.sleep", _no_sleep)
stt, _ = _fake([httpx.Response(401, json={"error": {"message": "bad key"}})])
with pytest.raises(SttError):
await stt.transcribe(make_wav(1))
async def _no_sleep(_: float) -> None:
return None
async def test_mock_is_offline() -> None:
assert await MockSTT().transcribe(b"") == load_texts().mock_transcript
def test_provider_choice() -> None:
assert isinstance(get_stt(_settings(stt_provider="auto")), OpenRouterSTT)
assert isinstance(get_stt(_settings(stt_provider="auto", openrouter_api_key="")), MockSTT)
assert get_stt(_settings(stt_provider="openrouter", openrouter_api_key="")) is None
assert get_stt(_settings(stt_provider="off")) is None
+3 -2
View File
@@ -125,8 +125,9 @@ def test_cssrs_levels() -> None:
@pytest.mark.parametrize(
("weapon", "cssrs", "item9", "fires"),
[
(True, "moderate", None, True),
(True, None, 1, True),
# Team decision 2026-10-04: weapon access triggers nothing (rule disabled in config/rules.yaml).
(True, "moderate", None, False),
(True, None, 1, False),
(True, "low", 0, False),
(False, "high", 2, False),
(None, "moderate", None, False),
+32
View File
@@ -0,0 +1,32 @@
"""An acked crisis is not raised again for the same signal; a newer PHQ-9 with item 9 raises a new one."""
from datetime import datetime, timedelta
from app.core.rules import Action
from app.core.types import AlertLevel
from app.services import monitoring
class _A:
def __init__(self, code, ts, scale=None):
self.code, self.ts, self.scale = code, ts, scale
async def test_drop_handled_crises(monkeypatch):
t0 = datetime(2026, 10, 4, 16, 32)
alerts = [_A("phq9_item9", t0 + timedelta(seconds=1))]
results = [_A(None, t0, "phq9"), _A(None, t0 + timedelta(hours=2), "auditc")]
async def list_alerts(s, token):
return alerts
async def list_scale_results(s, token):
return results
monkeypatch.setattr(monitoring.repo, "list_alerts", list_alerts)
monkeypatch.setattr(monitoring.repo, "list_scale_results", list_scale_results)
crisis = Action(type="crisis", level=AlertLevel.ACUTE, code="phq9_item9")
other = Action(type="alert", level=AlertLevel.HIGH, code="phq9_severe")
assert await monitoring._drop_handled_crises(None, "t", [crisis, other]) == [other] # AUDIT-C is not a trigger
results.append(_A(None, t0 + timedelta(hours=3), "phq9")) # a new PHQ-9 after the alert
assert await monitoring._drop_handled_crises(None, "t", [crisis]) == [crisis]
+3 -4
View File
@@ -223,10 +223,9 @@ async def test_weapon_access_from_dialog_facts(db: MemoryNotifier) -> None:
assert snap is not None and snap.weapon_access is True
await scale("cssrs_screen", 3, severity="moderate", scheduled=False)
actions = await monitoring.RuleMonitoring().after_data_change(TOKEN)
assert {a.code for a in actions if a.type == "crisis"} == {"weapon_access_risk"}
plan = next(a for a in actions if a.type == "show_safety_plan")
assert plan.params["block"] == "means_restriction"
assert await color() == "red"
# Team decision 2026-10-04: weapon access is stored but triggers nothing (no crisis, no safety block).
assert "weapon_access_risk" not in {a.code for a in actions}
assert not any(a.type == "show_safety_plan" and a.params.get("block") == "means_restriction" for a in actions)
async def test_snapshot_mapping_and_unknown_patient(db: MemoryNotifier) -> None:
+20 -2
View File
@@ -22,6 +22,7 @@ from app.channels.telegram import (
render_markup,
split_text,
)
from app.config import get_settings
from app.db import identity_repo, session
from app.db.bootstrap import init_db
from app.dialog.contracts import Button, Event, Reply
@@ -163,7 +164,10 @@ async def test_verified_token_without_identity_record(dbs: None, engine: Recordi
assert sender.texts == [load_texts().code_invalid]
async def test_unbound_chat_gets_not_connected(dbs: None, engine: RecordingEngine) -> None:
async def test_unbound_chat_gets_not_connected(
dbs: None, engine: RecordingEngine, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(get_settings(), "demo_clock", False)
sender = FakeSender()
channel = TelegramChannel(sender)
await channel.on_start(CHAT, None)
@@ -251,6 +255,18 @@ async def test_outbox_pushes_to_bound_chat_and_falls_back(dbs: None, engine: Rec
assert [r.text for r in fallback.drain(TOKEN2)] == ["Как вы сегодня?"]
async def test_start_without_code_on_demo_stand_binds_a_demo_copy(
dbs: None, engine: RecordingEngine, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(get_settings(), "demo_clock", True)
sender = FakeSender()
channel = TelegramChannel(sender)
await channel.on_start(CHAT, None)
token = await channel.directory.token_for(CHAT)
assert token is not None and token.startswith("tok-andrey-")
assert [c[1].type for c in engine.calls] == ["start"]
async def test_start_is_noop_without_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": ""})())
before = integrations.outbox
@@ -261,7 +277,9 @@ async def test_start_is_noop_without_token(monkeypatch: pytest.MonkeyPatch) -> N
async def test_start_stop_swaps_outbox(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": "123:fake"})())
monkeypatch.setattr(
telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": "123:fake", "tg_proxy_url": ""})()
)
async def fake_polling(self: object, *bots: object, **kwargs: object) -> None:
import asyncio
+16
View File
@@ -0,0 +1,16 @@
"""TG_PROXY_URL: the bot session goes through the proxy when it is set."""
from app.channels import telegram
from app.config import get_settings
TOKEN = "123456:" + "A" * 35
def test_make_bot_without_proxy(monkeypatch):
monkeypatch.setattr(get_settings(), "tg_proxy_url", "")
assert telegram.make_bot(TOKEN).session.proxy is None
def test_make_bot_with_proxy(monkeypatch):
monkeypatch.setattr(get_settings(), "tg_proxy_url", "http://user:pass@127.0.0.1:8888")
assert telegram.make_bot(TOKEN).session.proxy == "http://user:pass@127.0.0.1:8888"
Generated
+24
View File
@@ -122,6 +122,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
]
[[package]]
name = "aiohttp-socks"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "python-socks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/1d/a306e0111222180e60f17131a3f5d9bc694dd999a8115959a7dd76c2238e/aiohttp_socks-0.12.0.tar.gz", hash = "sha256:3caf9f5a4164611122d412bc11b2f9114fd29c85e1ba27bb38060d3c236bdc8d", size = 12061, upload-time = "2026-08-12T04:43:15.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/64/ca6289632020523ea1841f01363f83ada10eeb0f21dd931fc1118dd85668/aiohttp_socks-0.12.0-py3-none-any.whl", hash = "sha256:ba6f95ec775c761d87f8578ab48f137d0457c676da104984202bf75e747d5ee6", size = 10693, upload-time = "2026-08-12T04:43:14.524Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -1075,6 +1088,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiogram" },
{ name = "aiohttp-socks" },
{ name = "apscheduler" },
{ name = "asyncpg" },
{ name = "fastapi" },
@@ -1103,6 +1117,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiogram", specifier = ">=3.10" },
{ name = "aiohttp-socks", specifier = ">=0.8" },
{ name = "apscheduler", specifier = ">=3.10,<4" },
{ name = "asyncpg", specifier = ">=0.29" },
{ name = "fastapi", specifier = ">=0.115" },
@@ -1340,6 +1355,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "python-socks"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/ad/484ffb79532517b11a90af38647c38652224650b31a7ae1cedd5a418d8ab/python_socks-3.1.1.tar.gz", hash = "sha256:8d3e817cdbe858dc0bb8c8fdc8e79b6ce37acce110d33374c6f57a675cc9029e", size = 232781, upload-time = "2026-09-08T13:03:31.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/23/2c2cef1b4313c55d1713201acd4ee2043fb2cf22e546ca9d36bf4317faea/python_socks-3.1.1-py3-none-any.whl", hash = "sha256:327e0d6378702c73a7790bf732e9f01392f17b48c7348a50b5bd1f710c2df1be", size = 49566, upload-time = "2026-09-08T13:03:29.604Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.vite
+12
View File
@@ -0,0 +1,12 @@
# Patient web app: build with Node in a throwaway stage, serve the static bundle with nginx (+ /api → app).
# The jury only needs Docker: `make up` builds and starts it (http://localhost:8080).
FROM node:20-alpine AS build
WORKDIR /w
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /w/dist /usr/share/nginx/html
+18
View File
@@ -0,0 +1,18 @@
# Same-origin API: the app calls /api/v1/... — proxied to the FastAPI container (SSE streaming unbuffered).
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://app:8000;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 120s;
}
}
+52
View File
@@ -44,6 +44,8 @@ export const api = {
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
signInAnonymous: (focus?: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
// Presentation entry: a fresh private copy of the demo patient, straight to the chat.
signInDemoFresh: () => post<AuthResult>("/auth/demo/fresh", {}),
patientCapabilities: () => request<{ primary_screening: boolean }>("/patient/capabilities"),
connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`),
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
@@ -57,3 +59,53 @@ export const api = {
outbox: async (token: string) =>
(await request<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/outbox`)).replies,
};
export type StreamEvent =
| { kind: "status"; text: string }
| { kind: "delta"; text: string }
| { kind: "reset" }
| { kind: "done"; replies: Reply[] };
/** POST /dialog/{token}/events/stream (Server-Sent Events): agent steps and the answer as it is written,
* then the final replies. Resolves with the final replies; throws if the stream breaks (caller falls back). */
export async function sendEventStream(
token: string,
event: DialogEvent,
onEvent: (e: StreamEvent) => void,
): Promise<Reply[]> {
const res = await fetch(`${BASE}/dialog/${token}/events/stream`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify(event),
});
if (!res.ok || !res.body) throw new ApiError(res.status, res.statusText);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let cut: number;
while ((cut = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, cut);
buf = buf.slice(cut + 2);
let kind = "message";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event:")) kind = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (!data) continue;
const payload = JSON.parse(data);
if (kind === "status") onEvent({ kind, text: String(payload) });
else if (kind === "delta") onEvent({ kind, text: String(payload) });
else if (kind === "reset") onEvent({ kind: "reset" });
else if (kind === "done") {
onEvent({ kind: "done", replies: payload as Reply[] });
return payload as Reply[];
} else if (kind === "error") throw new ApiError(500, "stream error");
}
}
throw new ApiError(0, "stream closed");
}
+1
View File
@@ -16,6 +16,7 @@ import { Icon, LogoMark, type IconName } from "./Icon";
const TABS: { to: string; label: string; icon: IconName }[] = [
{ to: "/today", label: ru.nav.today, icon: "home" },
{ to: "/messages", label: ru.nav.chat, icon: "message" },
{ to: "/meds", label: ru.nav.meds, icon: "pill" },
{ to: "/doctor", label: ru.nav.doctor, icon: "doctor" },
{ to: "/profile", label: ru.nav.profile, icon: "user" },
+8
View File
@@ -16,6 +16,7 @@ export const ru = {
doctor: "Врач",
profile: "Профиль",
messages: "Сообщения",
chat: "Чат",
signIn: "Вход",
help: "Помощь",
},
@@ -41,6 +42,12 @@ export const ru = {
},
welcome: {
title: "Вход",
demo: {
title: "Демо: войти как Андрей",
text: "Ветеран СВО, 34 года, сертралин. Назначения и карта ЕМИАС уже подключены — сразу в чат.",
busy: "Готовим демо…",
error: "Не удалось открыть демо. Попробуйте ещё раз.",
},
emias: {
title: "Войти через ЕМИАС",
perks: [
@@ -245,6 +252,7 @@ export const ru = {
noticeTitle: "Опрос приостановлен",
},
messages: {
typing: "Помощник печатает",
title: "Сообщения",
inputLabel: "Сообщение",
inputPlaceholder: "Напишите сообщение",
+41 -4
View File
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { api } from "../api/client";
import { api, sendEventStream } from "../api/client";
import type { DialogEvent, Reply } from "../api/types";
import { useSession } from "../session/SessionContext";
import { sessionStore, type ChatEntry, type DialogSnapshot } from "../session/store";
@@ -17,8 +17,15 @@ let seq = 0;
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
const asEntries = (replies: Reply[]): ChatEntry[] => replies.map((reply) => ({ id: newId(), from: "assistant", reply }));
/** The agent at work: its steps and the answer as it is being written (cleared when the final reply lands). */
export interface LiveReply {
status: string[];
text: string;
}
interface DialogApi extends DialogSnapshot {
busy: boolean;
live: LiveReply | null;
error: boolean;
started: boolean;
/** Sends an event; `echo` is what the patient «said» (button label / text) for the history. */
@@ -36,6 +43,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
const [owner, setOwner] = useState(token);
const [snap, setSnap] = useState<DialogSnapshot>(() => (token ? sessionStore.loadDialog(token) : EMPTY));
const [busy, setBusy] = useState(false);
const [live, setLive] = useState<LiveReply | null>(null);
const [error, setError] = useState(false);
const tokenRef = useRef(token);
tokenRef.current = token;
@@ -58,7 +66,35 @@ export function DialogProvider({ children }: { children: ReactNode }) {
setBusy(true);
setError(false);
try {
const replies = await api.sendEvent(t, event);
let replies: Reply[];
if (event.type === "text") {
// Free text may go to the agent: stream its steps and the answer so the viewer sees it working.
const said = echo;
if (said) {
// Show the patient's message right away, while the agent is still working.
setSnap((prev) => ({
...prev,
entries: [...prev.entries, { id: newId(), from: "patient" as const, text: said }],
}));
echo = undefined;
}
setLive({ status: [], text: "" });
try {
replies = await sendEventStream(t, event, (e) =>
setLive((prev) => {
const cur = prev ?? { status: [], text: "" };
if (e.kind === "status") return { ...cur, status: [...cur.status, e.text] };
if (e.kind === "delta") return { ...cur, text: cur.text + e.text };
if (e.kind === "reset") return { ...cur, text: "" };
return cur;
}),
);
} catch {
replies = await api.sendEvent(t, event); // proxies without SSE support: plain request
}
} else {
replies = await api.sendEvent(t, event);
}
setSnap((prev) => ({
entries: [
...prev.entries,
@@ -73,6 +109,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
setError(true);
return false;
} finally {
setLive(null);
setBusy(false);
}
},
@@ -102,8 +139,8 @@ export function DialogProvider({ children }: { children: ReactNode }) {
}, [token]);
const value = useMemo<DialogApi>(
() => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }),
[snap, busy, error, send, start, markRead],
() => ({ ...snap, busy, live, error, started: snap.entries.length > 0, send, start, markRead }),
[snap, busy, live, error, send, start, markRead],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
+27 -1
View File
@@ -18,7 +18,7 @@ export function Messages() {
useEffect(() => {
dialog.markRead();
endRef.current?.scrollIntoView({ block: "end" });
}, [dialog.entries.length]);
}, [dialog.entries.length, dialog.live?.text, dialog.live?.status.length]);
const send = async (e?: FormEvent) => {
e?.preventDefault();
@@ -56,6 +56,32 @@ export function Messages() {
</li>
),
)}
{dialog.live && (
<li className="bubble bubble--bot bubble--live" aria-busy="true">
<span className="bubble__avatar">
<Icon name="smile" size={20} />
</span>
<div className="live">
{dialog.live.status.map((st, i) => (
<p key={i} className={`live__step${i === dialog.live!.status.length - 1 && !dialog.live!.text ? " is-active" : ""}`}>
{st}
</p>
))}
{dialog.live.text ? (
<p className="live__text">
{dialog.live.text}
<span className="live__cursor" aria-hidden="true" />
</p>
) : (
<p className="live__dots" aria-label={t.typing}>
<span />
<span />
<span />
</p>
)}
</div>
</li>
)}
</ol>
{quick.length > 0 && (
<div className="quickreplies">
+42
View File
@@ -1,5 +1,9 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { useSession } from "../session/SessionContext";
import { telHref, useHelp } from "../components/HelpSheet";
import { Icon, LogoMark } from "../components/Icon";
import { ru } from "../content/ru";
@@ -7,10 +11,26 @@ import { ru } from "../content/ru";
// A1 · Вход: без сайдбара, одна карточка по центру. ЕМИАС — основной путь, анонимный — по желанию
export function Welcome() {
const navigate = useNavigate();
const { signIn } = useSession();
const [demoBusy, setDemoBusy] = useState(false);
const [demoError, setDemoError] = useState(false);
const { resources, open } = useHelp();
const t = ru.welcome;
const emergency = resources[0];
// Presentation: one click → a fresh private demo patient → the chat.
const demo = async () => {
setDemoBusy(true);
setDemoError(false);
try {
signIn(await api.signInDemoFresh());
navigate("/messages", { replace: true });
} catch {
setDemoError(true);
setDemoBusy(false);
}
};
return (
<div className="signin-page">
<main className="signin" id="main">
@@ -25,6 +45,28 @@ export function Welcome() {
<section className="signin__card">
<h1 className="h1">{t.title}</h1>
<button type="button" className="entry entry--emias" onClick={demo} disabled={demoBusy}>
<span className="entry__top">
<span className="tile-icon tile-icon--teal">
<Icon name="check" size={26} />
</span>
<span className="entry__title">
{demoBusy ? t.demo.busy : t.demo.title}
<Icon name="chevronRight" />
</span>
</span>
<span className="muted small">{t.demo.text}</span>
</button>
{demoError && (
<p className="muted small center" role="alert">
{t.demo.error}
</p>
)}
<div className="divider" role="separator">
<span>{t.or}</span>
</div>
<button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
<span className="entry__top">
<span className="tile-icon tile-icon--teal">
+5 -1
View File
@@ -13,7 +13,11 @@ interface SessionApi {
const Ctx = createContext<SessionApi | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(() => sessionStore.load());
// The old shared demo account ("tok-andrey") is retired: every visitor now gets a private copy.
const [session, setSession] = useState<Session | null>(() => {
const s = sessionStore.load();
return s && s.token === "tok-andrey" ? null : s;
});
const signIn = useCallback((auth: AuthResult) => {
const next: Session = { token: auth.token, mode: auth.mode, displayName: auth.display_name };
+17 -1
View File
@@ -103,7 +103,7 @@ button:disabled {
inset: auto 0 0 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
height: calc(var(--nav-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
background: var(--c-surface);
@@ -2937,3 +2937,19 @@ button.row-card:hover:not(:disabled) {
animation: none;
}
}
/* Live agent reply: steps + the answer as it is written (SSE) */
.bubble--live .live { display: grid; gap: 4px; }
.live__step { margin: 0; font-size: 13px; color: var(--muted, #6b7280); }
.live__step.is-active::after { content: ""; display: inline-block; width: 1em; animation: live-ellipsis 1.2s steps(4) infinite; overflow: hidden; vertical-align: bottom; }
.live__text { margin: 2px 0 0; white-space: pre-wrap; }
.live__cursor { display: inline-block; width: 7px; height: 1em; margin-left: 2px; background: currentColor; vertical-align: text-bottom; animation: live-blink 1s steps(2) infinite; }
.live__dots { display: inline-flex; gap: 4px; margin: 4px 0 0; }
.live__dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--muted, #9ca3af); animation: live-dot 1.2s infinite ease-in-out; }
.live__dots span:nth-child(2) { animation-delay: .15s; }
.live__dots span:nth-child(3) { animation-delay: .3s; }
@keyframes live-blink { 50% { opacity: 0; } }
@keyframes live-dot { 0%, 80%, 100% { opacity: .3; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
@keyframes live-ellipsis { from { width: 0; } to { width: 1em; } }
.tabbar__item span { font-size: 11px; }