Compare commits

..
Author SHA1 Message Date
ElebobaandClaude Opus 5.5 65e3a27145 feat(web): voice input — record, check the recognised text, send
CI / ruff · pytest (pull_request) Successful in 2m27s
Microphone button in the chat composer and in free-text steps. The browser
records 16 kHz mono WAV (no server-side ffmpeg), /me/{token}/stt transcribes,
the text lands in the field for the patient to check and edit — never sent
automatically, so safety and scrub() apply on send. Hidden when the server has
voice input off or the browser lacks a microphone API. nginx: 4 MB body limit.

Refs #117

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:43:36 +03:00
Eleboba 63cc5372be Merge pull request 'feat: сад пациента — рост от активностей (без давления)' (#123) from feat/118-garden into dev
CI / ruff · pytest (push) Successful in 2m24s
CD / ruff · pytest (push) Successful in 2m16s
CD / deploy dev (push) Successful in 1m17s
Reviewed-on: #123
2026-10-31 11:37:39 +03:00
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 1cafaa87ec feat(backend): patient garden — deterministic growth from activities
CI / ruff · pytest (pull_request) Successful in 2m17s
GET /api/v1/me/{token}/garden computes the garden on the fly from taken doses,
check-ins/diary and finished questionnaires (no new table). Rules and texts in
config/garden.yaml: per-day caps, points only add up, answers never count.

Refs #118

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:22:44 +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
Eleboba 683a74b004 Merge pull request 'chore(devops): CD — автодеплой dev на сервер' (#98) from chore/cd-deploy into dev
CD / ruff · pytest (push) Successful in 2m21s
CI / ruff · pytest (push) Successful in 2m30s
CD / deploy dev (push) Successful in 1m27s
2026-10-04 16:56:12 +03:00
ElebobaandClaude Opus 5.5 285c95ac6b chore(devops): CD — every merge into dev deploys dev.meditron.tenvex.ru
CI / ruff · pytest (pull_request) Successful in 2m24s
Gitea Actions: lint + tests, then infra/deploy/deploy.sh over ssh as the dedicated ptsr-deploy user
(secrets DEPLOY_SSH_KEY, DEPLOY_HOST), then an HTTPS smoke check. deploy.sh now ships a tar.gz via scp and
retries every remote step (the host drops some ssh connections); the web build runs as the deploy user so the
next release can clean up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:53:35 +03:00
Eleboba 24e8ba4e72 Merge pull request 'fix: анонимная помощь с первичным опросом и доступом к врачу' (#97) from feat/95-anonymous-care into dev
CI / ruff · pytest (push) Successful in 2m34s
Reviewed-on: #97
2026-10-04 16:51:23 +03:00
Pavel Eliseev f5987c72f0 fix(web): offer anonymous screening and doctor contact without identity
CI / ruff · pytest (pull_request) Successful in 2m30s
Refs #95, #96
2026-10-04 16:48:03 +03:00
Pavel Eliseev 0743184f3d fix(api): provide pseudonymous patients access to doctor care 2026-10-04 16:47:06 +03:00
Arxip222 079c3003aa Merge pull request 'fix(clinical): красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров' (#94) from fix/clinical-review-safety into dev
CI / ruff · pytest (push) Successful in 2m26s
2026-10-04 16:28:28 +03:00
Eleboba dff8f316bb Merge pull request 'chore(devops): HTTPS для meditron.tenvex.ru' (#93) from chore/nginx-https-prod into dev
CI / ruff · pytest (push) Successful in 2m14s
2026-10-04 16:25:26 +03:00
ElebobaandClaude Opus 5.5 f14efac7ac chore(devops): HTTPS for meditron.tenvex.ru too
CI / ruff · pytest (pull_request) Successful in 2m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:22:49 +03:00
Eleboba 766c2a2e4b Merge pull request 'chore(devops): HTTPS для dev.meditron.tenvex.ru' (#91) from chore/nginx-https into dev
CI / ruff · pytest (push) Successful in 2m16s
2026-10-04 16:21:49 +03:00
Arxip222 37b612b8c4 Merge remote-tracking branch 'origin/dev' into fix/clinical-review-safety
CI / ruff · pytest (pull_request) Successful in 2m22s
2026-10-04 16:21:24 +03:00
Arxip222andClaude Opus 5.5 44814b7293 fix(clinical): red flags, one crisis rule, callback after a crisis, one table of numbers
Clinical review of the questionnaires (ADR-013):
- Red-flag block first in stage 1 (config/instruments/red_flags.yaml); any «Да» stops the
  questionnaire with an acute alert and the help screen of its category.
- One crisis rule: the crisis screen opens at a HIGH C-SSRS risk only; moderate stops and goes
  to a psychiatrist (24 h as a guide), low is a flag with the safety plan. PHQ-9 item 9 with a
  negative screener is a discrepancy flag; without a completed screener it asks the C-SSRS at once.
  config/questionnaire.yaml: cssrs_positive_policy graded (C01).
- Every crisis leaves a callback task for the doctor on duty (post_crisis_call, one per patient)
  and the app asks how the person is the next day, the safety plan one tap away.
- Routing: risk and flags first, scales second; PCL-5 >= 50 no longer means a priority
  appointment (pcl5_severe is moderate on the dashboard too).
- Crisis numbers live only in config/texts/crisis.yaml; texts and the agent prompt refer to them
  via {phone:<key>}. Fixes the children's helpline offered to adults by the chat agent.

Refs #92

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:21:16 +03:00
Eleboba c09620e6d3 Merge pull request 'feat(ui): единый дизайн всех страниц по образцу кабинета врача' (#88) from feat/85-unified-design into dev
CI / ruff · pytest (push) Successful in 2m26s
Reviewed-on: #88
2026-10-04 16:20:10 +03:00
ElebobaandClaude Opus 5.5 a3180ad232 chore(devops): HTTPS for dev.meditron.tenvex.ru, meditron.tenvex.ru on HTTP until its DNS record
CI / ruff · pytest (pull_request) Successful in 2m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:19:19 +03:00
Soyavo 07652d044c Merge pull request 'feat(bot): prepare MAX notification adapter and setup handoff' (#89) from feat/83-max-bot into dev
CI / ruff · pytest (push) Successful in 2m21s
2026-10-04 16:17:18 +03:00
Soyavo ae4c76aa62 Merge pull request '[contract] Redact input values in settings validation errors' (#90) from fix/87-settings-privacy into dev
CI / ruff · pytest (push) Successful in 2m19s
2026-10-04 16:17:03 +03:00
Pavel Eliseev a4ee3d25f0 fix(web): allow returning home from questionnaires
CI / ruff · pytest (pull_request) Successful in 2m28s
Keep the shared dialog and submitted answers while leaving the focus screen. Remove the history trap and wire the text-step home action.

Refs #85
2026-10-04 16:14:37 +03:00
Pavel Eliseev 7780b484f3 feat(bot): add MAX notifications adapter and operator handoff
CI / ruff · pytest (pull_request) Successful in 2m27s
Refs #83
2026-10-04 16:10:30 +03:00
Pavel Eliseev 28967c7c37 fix(contract): redact input values in settings errors
CI / ruff · pytest (pull_request) Successful in 2m22s
Refs #87
2026-10-04 16:10:30 +03:00
Pavel Eliseev 2a2fdb171e feat(ui): extend minimal doctor design across all screens
CI / ruff · pytest (pull_request) Successful in 2m16s
Refs #85
2026-10-04 16:09:57 +03:00
Soyavo afbfbad19e Merge pull request 'feat(backend): prepare MAX linking, webhook API and durable notifications' (#86) from feat/82-max-backend into dev
CI / ruff · pytest (push) Successful in 2m16s
2026-10-04 16:09:13 +03:00
120 changed files with 4770 additions and 404 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)
+52
View File
@@ -0,0 +1,52 @@
name: CD
# Every merge into dev → lint + tests → deploy to the shared server (dev.meditron.tenvex.ru / meditron.tenvex.ru).
# Secrets: DEPLOY_SSH_KEY (private key of the `ptsr-deploy` user), DEPLOY_HOST (user@host).
on:
push:
branches: [dev]
workflow_dispatch:
concurrency:
group: deploy-dev
cancel-in-progress: false
jobs:
test:
name: ruff · pytest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: "0.8.4"
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run pytest -q
deploy:
name: deploy dev
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ssh key
run: |
install -d -m 700 ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
- name: deploy
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_SSH_OPTS: "-i /root/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
run: |
export DEPLOY_SSH_OPTS="${DEPLOY_SSH_OPTS//\/root\/.ssh/$HOME/.ssh}"
infra/deploy/deploy.sh HEAD
- name: smoke
run: |
sleep 10
for u in https://dev.meditron.tenvex.ru/ https://dev.meditron.tenvex.ru/api/v1/demo/personas https://dev.meditron.tenvex.ru/doctor/; do
code=$(curl -s -o /dev/null -w '%{http_code}' -m 20 "$u"); echo "$u -> $code"; [ "$code" = 200 ]
done
+6 -6
View File
@@ -1,15 +1,15 @@
# Dashboard-only Streamlit settings (doctor dashboard + demo console).
[theme]
base = "light"
primaryColor = "#2A78D6"
backgroundColor = "#F6F7F9"
primaryColor = "#285E4E"
backgroundColor = "#F6F7F3"
secondaryBackgroundColor = "#FFFFFF"
textColor = "#1B1F24"
borderColor = "#E3E6EA"
textColor = "#213B38"
borderColor = "#E1E7E3"
baseRadius = "medium"
# Falls back to the system sans-serif when offline (ADR-004).
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap, sans-serif"
headingFontWeights = 700
font = "sans-serif"
headingFontWeights = 600
[client]
toolbarMode = "minimal"
+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).
+11 -1
View File
@@ -34,6 +34,7 @@ from app.db import repo
from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult
from app.db.identity import Patient
from app.integrations.base import Prescription as PrescriptionOut
from app.services.anonymous_care import display_name as anonymous_name
COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
_MODULES = frozenset(get_args(Module))
@@ -96,6 +97,7 @@ class PatientData:
doses: Sequence[Dose]
prescriptions: Sequence[Prescription]
alerts: Sequence[Alert] # newest first
contact_requested_at: datetime | None = None
async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData:
@@ -104,6 +106,9 @@ async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> P
rx = (
await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id))
).all()
dialog = await repo.get_dialog_state(session, token)
requests = (dialog.data.get("contact_requests") or []) if dialog else []
contact_at = datetime.fromisoformat(requests[-1]["ts"]) if requests else None
return PatientData(
profile=profile,
checkins=await repo.list_checkins(session, token),
@@ -111,6 +116,7 @@ async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> P
doses=doses,
prescriptions=rx,
alerts=await repo.list_alerts(session, token=token),
contact_requested_at=contact_at,
)
@@ -228,6 +234,10 @@ def _last_event(d: PatientData, now: datetime) -> str:
alert = d.alerts[0] if d.alerts else None
scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS]
scale = scales[-1] if scales else None
if d.contact_requested_at is not None and all(
d.contact_requested_at >= ts for ts in [x.ts for x in (alert, scale) if x is not None]
):
return "Пациент просит врача связаться"
if scale is not None and (alert is None or scale.ts > alert.ts):
day = _therapy_day(d.profile, _local(scale.ts, now))
return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})"
@@ -249,7 +259,7 @@ def list_item(d: PatientData, patient: Patient | None, now: datetime) -> Patient
rx = _active_rx(d, today)
return PatientListItem(
token=p.token,
display_name=patient.display_name if patient else "Пациент",
display_name=patient.display_name if patient else anonymous_name(p.token),
age=_age(patient, p, today),
modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES],
drug=_drug_label(rx) if rx else None,
+4 -2
View File
@@ -16,6 +16,7 @@ from app.api.schemas import (
PatientListItem,
SeriesPoint,
)
from app.core.safety import fill_phones
from app.integrations.base import Prescription
TZ = ZoneInfo("Europe/Moscow")
@@ -24,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"),
]
@@ -262,7 +264,7 @@ def andrey_card() -> PatientCard:
"Что помогает: прогулка с собакой, душ",
"Люди и места: брат Олег, спортзал",
"Кому звонить: жена, сослуживец Миша",
"Специалисты: д-р Смирнова, 8-800-2000-122, 112",
fill_phones("Специалисты: д-р Смирнова, {phone:helpline}, 112"),
"Безопасная среда: ружьё хранится у брата",
],
summary="За 13 дней: приверженность 12/13, эпизод намерения бросить на 5-й день (снят после звонка врача). "
+147 -13
View File
@@ -7,9 +7,9 @@ exactly like the web console, and consent, onboarding, scales and check-ins come
Two ways in:
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
patient is linked to the attending doctor, who sees the data on the dashboard.
- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor.
- **Anonymous**: a random pseudonymous token, no identity record, and access to the prototype care team.
Clinical answers are stored on the server and visible to the assigned doctor under a pseudonym.
Consent is kept in the dialog state. Existing doctor-less profiles require explicit care opt-in.
The token returned here is the session credential of the prototype. Production needs short-lived sessions
issued by the server instead of handing out the pseudonymous token itself.
@@ -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,12 +37,16 @@ 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
from app.services.garden import Garden, garden_for
log = logging.getLogger(__name__)
@@ -79,14 +85,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()
@@ -146,26 +152,142 @@ 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; no identity record, no doctor.
"""Anonymous sign-in: a fresh random token; care without an identity record.
The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself.
The clinical side gets a pseudonymous profile assigned to the prototype care team. It holds
no identity fields. Legacy clients may supply `focus`; the common stage 1 screening sets modules itself.
"""
token = secrets.token_hex(TOKEN_LEN // 2)
focus = body.focus if body is not None else None
async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[focus]] if focus else [])
await repo.upsert_profile(
s, token=token, doctor_id=anonymous_care.doctor_id(), modules=[_FOCUS_MODULE[focus]] if focus else []
)
log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous")
@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(), "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")
async def connect_anonymous_doctor(token: str) -> dict[str, bool]:
"""Explicit opt-in for old doctor-less profiles; never reassign an existing doctor."""
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
if profile is None:
raise HTTPException(404, "patient not found")
if not profile.doctor_id:
profile.doctor_id = anonymous_care.doctor_id()
await audit(s, actor=token, action="anonymous_care_opt_in", token=token, ts=clock.now())
return {"has_doctor": True}
class DoseItem(BaseModel):
id: int
drug: str
@@ -305,6 +427,18 @@ async def my_today(token: str) -> TodayOut:
)
@router.get("/me/{token}/garden", response_model=Garden)
async def my_garden(token: str) -> Garden:
"""The patient's garden (#118): grows from medication marks, check-ins/diary and finished questionnaires.
Never shrinks and never depends on answers. Seen only by the patient — not on the dashboard, not in summaries.
"""
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
return await garden_for(s, token)
class ResultFinding(BaseModel):
code: str
title: str
+71 -1
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,9 +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)
@@ -66,7 +74,7 @@ async def _patients(tokens: list[str]) -> dict[str, Patient]:
def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token)
return p.display_name if p else "Пациент"
return p.display_name if p else anonymous_name(token)
# ------------------------------------------------------------------ doctor dashboard
@@ -218,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."""
@@ -287,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)
+150
View File
@@ -0,0 +1,150 @@
# Подключение будущего бота MAX
Серверная часть готова к подключению. Сам бот ещё не создан, секреты не заполнены,
реальная отправка не включена. Изменений интерфейса сайта в этой поставке нет.
Интеграция уведомляет **самого пациента**; врачебные оповещения остаются в существующем канале проекта.
## Что реализовано
- Одноразовая ссылка для привязки аккаунта MAX к текущему пациенту сайта, срок по умолчанию 10 минут.
- Подтверждение подключения, `/help`, `/stop`, отключение при остановке или удалении бота в MAX.
- Нейтральные напоминания из существующего планировщика сайта: тесты, дневник, приём лекарств и другие события outbox.
- Уведомление о завершении теста: сервер каждые примерно 30 секунд находит новые завершённые
`QuestionnaireRecord` и `ScaleResult`, начиная с момента подключения MAX. Незавершённые и старые тесты не отправляются.
- Постоянная очередь, восстановление после перезапуска, ограниченные повторы при сетевых ошибках и ответах 429/5xx.
- В сообщении только короткий текст и кнопка на сайт. Ответы, оценки, диагнозы, назначения и ключ входа на сайт
в MAX не передаются. Переписка с ботом не сохраняется и не отправляется в LLM.
## 1. Создать бота — будущему владельцу
1. Открыть [MAX для партнёров](https://business.max.ru/) и пройти регистрацию/проверку профиля.
Условия допуска зависят от типа владельца; актуальный порядок — в
[официальной инструкции подключения](https://dev.max.ru/help/platform_connection).
2. Создать чат-бота, оформить описание и пройти требуемую MAX модерацию.
3. В настройках бота получить токен и скопировать имя из его публичной ссылки `https://max.ru/ИМЯ`.
4. Передать администратору сервера имя бота, а токен — через защищённое хранилище секретов.
Токен не нужен в переписке, MR или репозитории.
## 2. Заполнить настройки сервера
Нужны доступная identity/clinical БД проекта, публичный HTTPS-адрес сайта и HTTPS-адрес API на порту 443.
Входящие POST от MAX должны доходить до API без браузерной CAPTCHA или страницы авторизации.
Прокси должен передавать заголовок `X-Max-Bot-Api-Secret` и не журналировать тело webhook/Authorization.
Дополнительные таблицы identity создаются существующим `init_db()` при запуске приложения.
В `.env` сервера (вне Git) заполнить:
```dotenv
MAX_ENABLED=false
MAX_BOT_TOKEN=
MAX_BOT_USERNAME=
MAX_WEBHOOK_SECRET=
MAX_WEBHOOK_URL=https://api.example.org/api/v1/webhooks/max
MAX_SITE_URL=https://patient.example.org/
MAX_LINK_TTL_SECONDS=600
# При необходимости — PEM-файл доверенного CA, доступный внутри контейнера:
MAX_CA_BUNDLE=
DEMO_CLOCK=false
```
- `MAX_BOT_USERNAME` — только имя, без `@`, домена или параметров ссылки.
- `MAX_WEBHOOK_SECRET` — отдельная случайная строка, не токен бота: 32–256 символов из `A–Z a–z 0–9 _ -`.
Сгенерировать локально, например `python -c 'import secrets; print(secrets.token_urlsafe(32))'`, сохранить в секреты.
- `MAX_SITE_URL` ведёт в обычный личный кабинет; не добавлять в адрес ключи доступа или данные пациента.
- Исходящие запросы идут на `https://platform-api2.max.ru`, токен передаётся заголовком `Authorization`.
Верификация TLS всегда включена. Если системе нужен корневой сертификат Минцифры, установить его по
официальной инструкции MAX и указать PEM-путь в `MAX_CA_BUNDLE`; не отключать проверку TLS.
- Переменные должны попасть именно в окружение API-контейнера/процесса, а не только панели сайта.
В текущем Compose сервис `app` уже получает `.env` через `env_file`.
После изменения окружения пересоздать процесс/контейнер API. Новые зависимости не требуются.
## 3. Проверить и включить
В каталоге проекта, в том же окружении, где работает API:
```sh
uv run python -m app.channels.max_setup check
```
Команда проверит токен и совпадение имени бота. Сообщений пользователям она не отправляет.
Затем установить `MAX_ENABLED=true`, перезапустить API и выполнить:
```sh
uv run python -m app.channels.max_setup subscribe
uv run python -m app.channels.max_setup status
```
`subscribe` регистрирует только заданный webhook с секретом и событиями
`bot_started`, `message_created`, `bot_stopped`, `dialog_removed`. Старые чужие подписки автоматически
не удаляются: при переносе сервера владелец должен проверить их отдельно через MAX API.
`status` проверяет регистрацию адреса; это ещё не проверка доступности сервера из MAX.
Команды не печатают токены, секрет webhook или сырые ответы платформы.
При запуске через Compose вместо `uv run python ...` использовать
`docker compose exec app python -m app.channels.max_setup check` (также `subscribe` и `status`).
После изменения `.env` пересоздать API: `docker compose up -d --build --force-recreate app`.
Запускать один процесс API с MAX на один токен бота: его worker отправляет до одного сообщения в секунду.
БД защищает очередь от параллельного захвата, но общий лимит скорости нескольких процессов не координируется.
Планировщик и MAX worker запускаются вместе с FastAPI, отдельный процесс бота не нужен.
При `MAX_ENABLED=false` сетевых вызовов MAX и фонового worker нет; сайт продолжает обычную работу.
## API для разработчика сайта
В проекте используется ключ текущего пациента, полученный при входе. Для этих ручек передавать его
как `Authorization: Bearer <patient_token>`; это существующая авторизация прототипа, не новая система SSO.
Не брать token другого пациента и не публиковать демонстрационные учётные записи для реального использования.
| Метод и путь | Запрос | Ответ |
|---|---|---|
| `GET /api/v1/me/notifications/max` | Authorization | `available`, `connected`, `last_delivery` |
| `POST /api/v1/me/notifications/max/link` | Authorization; JSON `{"consent": true}` | `url`, `expires_at` |
| `DELETE /api/v1/me/notifications/max` | Authorization | состояние с `connected: false` |
| `POST /api/v1/webhooks/max` | MAX JSON + `X-Max-Bot-Api-Secret` | `{"success": true}` после записи события |
Поток подключения: пациент явно соглашается на уведомления → сайт получает `url` → открывает ссылку MAX →
пользователь запускает бота → webhook подтверждает привязку → повторный GET показывает `connected: true`.
Ссылка одноразовая; новая ссылка отменяет предыдущую. Изначальный ключ пациента не входит в URL MAX.
Для привязки и доставки дополнительно нужны действующее согласие и подтверждение совершеннолетия в состоянии пациента.
Активную привязку нельзя молча переназначить другому аккаунту: сначала отключить её.
Коды ошибок: 401 — неизвестный/отсутствующий ключ пациента; 403 — нет согласия или неверный секрет webhook;
422 — неверный запрос; 413 — webhook больше 64 КиБ; 503 — MAX выключен или хранилище события недоступно.
Повтор webhook с тем же идентификатором события не создаёт новых отправок. Групповые чаты игнорируются.
`last_delivery`: `queued`, `sending`, `sent`, `failed`, `cancelled` или `null`.
`sent` означает принятие сообщения API MAX, а не прочтение человеком.
## Пробное подключение после создания бота
1. Войти на сайте синтетическим пациентом и дать необходимые согласия.
2. Получить ссылку через POST выше (Swagger `/docs` или будущий интерфейс сайта), перейти в MAX и запустить бота.
3. Убедиться, что GET показывает `connected: true` и приходит подтверждение.
4. Завершить тест на сайте; примерно через 30 секунд плюс очередь должно прийти сообщение без результатов.
5. Дождаться события планировщика и проверить нейтральное напоминание с кнопкой на сайт.
6. Отправить `/stop`: GET должен показать `connected: false`, ожидающие уведомления отменяются.
7. Повторить подключение новой ссылкой, проверить DELETE через API и остановку бота через интерфейс MAX.
При недоставке проверить настройки и подписку, доступность HTTPS, состояние очереди и коды ошибок в логах.
`http_401`/`http_403` требуют проверки токена/прав; `http_429` — ожидания; `transport` — сети/TLS;
`failed` после шести попыток требует вмешательства администратора. Детали ответов и идентификаторы в логи не выводятся.
Отключение отменяет очередь; уже принятый MAX запрос отозвать невозможно.
## Гарантии и ограничения
Очередь и подтверждения событий сохраняются в identity БД. Клинические записи остаются в clinical БД.
Для событий завершения тестов БД повторно просматривается после перезапуска; ключи уведомлений предотвращают
повторную постановку. Напоминания используют существующий механизм outbox/повторов планировщика.
При временных ошибках — до 6 попыток с увеличением задержки и учётом `Retry-After`; зависшая отправка получает
новую аренду через 60 секунд. Потеря процесса между принятием сообщения MAX и записью успеха в БД может
привести к повторному сообщению: MAX не предоставляет ключ идемпотентности отправки.
Не удалять строки дедупликации/очереди без согласованной политики хранения. При большом количестве пациентов
нужны пакетное сканирование завершённых тестов и общий ограничитель скорости; текущая реализация рассчитана на MVP.
Проверки разработчика: `make lint test`; тесты MAX работают с синтетикой и подменённым HTTP-транспортом.
Реальную доставку подтвердит только пробное подключение после выдачи токена и настройки публичного сервера.
Официальные контракты: [отправка сообщений](https://dev.max.ru/docs-api/methods/POST/messages),
[подписка webhook](https://dev.max.ru/docs-api/methods/POST/subscriptions),
[события](https://dev.max.ru/docs-api/objects/Update),
[ссылки запуска бота](https://dev.max.ru/docs/chatbots/bots-coding/prepare).
+224
View File
@@ -0,0 +1,224 @@
"""Notifications-only MAX adapter; no patient text enters the dialog engine or an LLM.
Webhook transactions persist receipts and replies together, then return immediately.
The worker delivers from identity DB with leases and bounded retries. MAX has no send
idempotency key: a crash after API acceptance and before commit can duplicate a message.
"""
import asyncio
import contextlib
import json
import logging
from functools import lru_cache
import yaml
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.channels.max_client import MaxApiError, MaxClient
from app.config import get_settings
from app.core import clock
from app.db.max_notifications import MaxBinding, MaxReceipt
from app.db.session import get_identity_session
from app.dialog.contracts import Reply
from app.integrations import max_store as store
from app.integrations.base import Outbox, integrations
log = logging.getLogger(__name__)
class Texts(BaseModel):
connected: str = Field(max_length=4000)
not_connected: str = Field(max_length=4000)
invalid: str = Field(max_length=4000)
stopped: str = Field(max_length=4000)
help: str = Field(max_length=4000)
notification: str = Field(max_length=4000)
completed: str = Field(max_length=4000)
open_site: str = Field(max_length=128)
@lru_cache
def texts() -> Texts:
path = get_settings().config_dir / "texts" / "max.yaml"
return Texts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
def _event(event: dict) -> tuple[str, int, str, str] | None:
"""Normalize only documented private-user events; unknown/malformed events are ignored."""
kind = event.get("update_type")
stamp = event.get("timestamp")
if type(stamp) is not int or stamp < 0:
return None
message_id = ""
if kind == "message_created":
message = event.get("message")
if not isinstance(message, dict):
return None
recipient, body = message.get("recipient"), message.get("body")
if not isinstance(recipient, dict) or recipient.get("chat_type") != "dialog" or not isinstance(body, dict):
return None
user, text, message_id = message.get("sender"), body.get("text"), body.get("mid")
if not isinstance(message_id, str) or not message_id or not isinstance(text, str):
return None
elif kind in ("bot_started", "bot_stopped", "dialog_removed"):
user, text = event.get("user"), event.get("payload") or ""
else:
return None
if not isinstance(user, dict) or user.get("is_bot") is not False:
return None
uid = user.get("user_id")
if type(uid) is not int or not 0 < uid < 2**63 or not isinstance(text, str) or len(text) > 4000:
return None
# Names, profile details and raw text are neither persisted nor logged.
receipt = store.digest(f"{kind}:{uid}:{message_id or stamp}")
return kind, uid, text.strip(), receipt
async def handle_update(event: dict) -> None:
if not get_settings().max_enabled or (parsed := _event(event)) is None:
return
kind, user_id, text, receipt = parsed
now = clock.now()
try:
async with get_identity_session() as session:
if await session.get(MaxReceipt, receipt):
return
session.add(MaxReceipt(digest=receipt, received_at=now))
await session.flush()
binding = await session.scalar(select(MaxBinding).where(MaxBinding.user_id == user_id))
reply_token = None
command, _, payload = text.partition(" ")
if kind in ("bot_stopped", "dialog_removed") or command.lower() == "/stop":
if binding:
await store.disconnect(session, binding.token)
# When MAX itself stops/deletes the bot, do not attempt a reply.
if kind != "message_created":
return
reply = "stopped"
elif kind == "bot_started" or command.lower() == "/start":
code = text if kind == "bot_started" else payload.strip()
if code:
token = await store.code_token(session, code, now) if len(code) <= 128 else None
linked = (
token and await store.active_patient(token) and await store.bind(session, code, user_id, now)
)
reply = "connected" if linked else "invalid"
reply_token = token if linked else None
else:
reply = "connected" if binding and binding.enabled else "not_connected"
reply_token = binding.token if binding and binding.enabled else None
else:
reply = "help"
await store.enqueue(session, key=receipt, user_id=user_id, token=reply_token, kind=reply, now=now)
except IntegrityError:
# A concurrent delivery of the same webhook can race on its receipt primary key.
async with get_identity_session() as session:
if await session.get(MaxReceipt, receipt) is None:
raise
class MaxOutbox:
def __init__(self, fallback: Outbox):
self.fallback = fallback
async def push(self, token: str, replies: list[Reply]) -> None:
if replies and get_settings().max_enabled and await store.active_patient(token):
async with get_identity_session() as session:
binding = await session.get(MaxBinding, token)
if binding and binding.enabled:
# Hash the event for retry deduplication; never store/transmit its clinical text.
event = json.dumps([r.model_dump() for r in replies], sort_keys=True, ensure_ascii=False)
key = store.digest(f"{token}:{binding.connected_at.isoformat()}:{clock.today()}:{event}")
await store.enqueue(
session, key=key, user_id=binding.user_id, token=token, kind="notification", now=clock.now()
)
await self.fallback.push(token, replies)
async def deliver_one(client: MaxClient) -> bool:
async with get_identity_session() as session:
row = await store.claim(session, clock.now())
if row is None:
return False
if row.token is not None:
async with get_identity_session() as session:
binding = await session.get(MaxBinding, row.token)
valid = (
binding
and binding.enabled
and binding.user_id == row.user_id
and binding.connected_at <= row.created_at
)
if not valid or not await store.active_patient(row.token):
async with get_identity_session() as session:
await store.finish(session, row, state="cancelled", now=clock.now())
return True
settings, messages = get_settings(), texts()
try:
await client.send(
row.user_id, getattr(messages, row.kind), site_url=settings.max_site_url, button=messages.open_site
)
except MaxApiError as exc:
retry = exc.retryable and row.attempts < store.MAX_ATTEMPTS
delay = max(exc.retry_after, min(300, 5 * 2 ** (row.attempts - 1)))
async with get_identity_session() as session:
await store.finish(
session,
row,
state="queued" if retry else "failed",
now=clock.now(),
error=exc.code,
retry_seconds=delay if retry else 0,
)
log.warning("MAX delivery deferred=%s code=%s", retry, exc.code)
else:
async with get_identity_session() as session:
await store.finish(session, row, state="sent", now=clock.now())
return True
async def _run(client: MaxClient) -> None:
tick = 0
while True:
try:
if tick % 30 == 0:
async with get_identity_session() as session:
await store.collect_completed(session, clock.now())
await deliver_one(client)
except Exception as exc:
# No exception text: SQL/HTTP errors may contain identifiers or parameters.
log.error("MAX worker failed type=%s", type(exc).__name__)
tick += 1
# One send per second per process, below MAX's two-per-second per-dialog limit.
await asyncio.sleep(1)
_client: MaxClient | None = None
_task: asyncio.Task | None = None
_wrapper: MaxOutbox | None = None
async def start() -> None:
global _client, _task, _wrapper
if not get_settings().max_enabled or _task is not None:
return
texts() # Validate templates before replacing a working outbox.
_client = MaxClient(get_settings())
_wrapper = MaxOutbox(integrations.outbox)
integrations.outbox = _wrapper
_task = asyncio.create_task(_run(_client), name="max-notifications")
async def stop() -> None:
global _client, _task, _wrapper
if _task:
_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _task
if _wrapper and integrations.outbox is _wrapper:
integrations.outbox = _wrapper.fallback
if _client:
await _client.close()
_client = _task = _wrapper = None
+101
View File
@@ -0,0 +1,101 @@
"""Small MAX Bot API client. Secrets stay in headers; failures never expose response bodies."""
import logging
import ssl
import httpx
from app.config import Settings
API_URL = "https://platform-api2.max.ru"
UPDATE_TYPES = ["bot_started", "message_created", "bot_stopped", "dialog_removed"]
class _HideRecipient(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
# httpx INFO normally logs query strings, which include a MAX user_id.
return "platform-api2.max.ru" not in record.getMessage()
logging.getLogger("httpx").addFilter(_HideRecipient())
class MaxApiError(Exception):
def __init__(self, code: str, *, retryable: bool = False, retry_after: float = 0):
self.code, self.retryable, self.retry_after = code, retryable, retry_after
super().__init__(code)
class MaxClient:
def __init__(self, settings: Settings, *, transport: httpx.AsyncBaseTransport | None = None):
context = ssl.create_default_context()
if settings.max_ca_bundle:
context.load_verify_locations(cafile=str(settings.max_ca_bundle))
self.http = httpx.AsyncClient(
base_url=API_URL,
headers={"Authorization": settings.max_bot_token.get_secret_value()},
timeout=httpx.Timeout(5),
verify=context,
follow_redirects=False,
transport=transport,
)
async def close(self) -> None:
await self.http.aclose()
async def request(self, method: str, path: str, **kwargs) -> dict:
try:
response = await self.http.request(method, path, **kwargs)
except httpx.RequestError:
raise MaxApiError("transport", retryable=True) from None
if not response.is_success:
try:
delay = min(3600, max(0, float(response.headers.get("Retry-After", "0"))))
except ValueError:
delay = 0
raise MaxApiError(
f"http_{response.status_code}",
retryable=response.status_code in (408, 429) or response.status_code >= 500,
retry_after=delay,
)
try:
data = response.json()
except ValueError:
raise MaxApiError("invalid_response", retryable=True) from None
if not isinstance(data, dict):
raise MaxApiError("invalid_response", retryable=True)
if data.get("success") is False:
raise MaxApiError("rejected")
return data
async def send(self, user_id: int, text: str, *, site_url: str, button: str) -> None:
data = await self.request(
"POST",
"/messages",
params={"user_id": user_id, "disable_link_preview": "true"},
json={
"text": text,
"notify": True,
"attachments": [
{
"type": "inline_keyboard",
"payload": {"buttons": [[{"type": "link", "text": button, "url": site_url}]]},
}
],
},
)
if not isinstance(data.get("message"), dict):
raise MaxApiError("missing_message", retryable=True)
async def subscribe(self, settings: Settings) -> None:
data = await self.request(
"POST",
"/subscriptions",
json={
"url": settings.max_webhook_url,
"update_types": UPDATE_TYPES,
"secret": settings.max_webhook_secret.get_secret_value(),
},
)
if data.get("success") is not True:
raise MaxApiError("subscription_not_confirmed")
+56
View File
@@ -0,0 +1,56 @@
"""Operator commands for a future bot owner. Never runs as part of application startup."""
import argparse
import asyncio
import sys
from pydantic import ValidationError
from app.channels.max_client import MaxApiError, MaxClient
from app.config import Settings
async def run(command: str) -> None:
# Validate the same configuration even while the deployed app is still disabled.
settings = Settings(max_enabled=True)
client = MaxClient(settings)
try:
bot = await client.request("GET", "/me")
if bot.get("is_bot") is not True:
raise MaxApiError("not_a_bot")
if bot.get("username") != settings.max_bot_username:
raise MaxApiError("bot_username_mismatch")
if command == "subscribe":
await client.subscribe(settings)
print("Webhook MAX зарегистрирован. Проверьте командой status, затем выполните пробное подключение.")
elif command == "status":
result = await client.request("GET", "/subscriptions")
subscriptions = result.get("subscriptions", [])
configured = any(isinstance(s, dict) and s.get("url") == settings.max_webhook_url for s in subscriptions)
print("Нужный webhook зарегистрирован." if configured else "Нужный webhook не найден.")
if not configured:
raise MaxApiError("webhook_missing")
else:
print("Токен принят MAX, имя бота совпадает с настройкой. Подписка не изменялась.")
finally:
await client.close()
def main() -> int:
parser = argparse.ArgumentParser(description="Подготовка MAX: параметры берутся только из .env/окружения.")
parser.add_argument("command", choices=("check", "subscribe", "status"))
args = parser.parse_args()
try:
asyncio.run(run(args.command))
except ValidationError:
print("Проверьте параметры MAX в .env по app/channels/MAX_SETUP.md.", file=sys.stderr)
return 1
except (MaxApiError, OSError) as exc:
code = exc.code if isinstance(exc, MaxApiError) else "tls_or_file_error"
print(f"Не удалось выполнить настройку MAX: {code}. Секреты и ответ сервера скрыты.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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))
+16 -1
View File
@@ -14,7 +14,7 @@ ROOT_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
# Empty values in .env (e.g. `TG_BOT_TOKEN=`) fall back to defaults instead of failing validation.
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_ignore_empty=True)
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_ignore_empty=True, hide_input_in_errors=True)
# Two separate databases (ADR-003): identity holds direct identifiers, clinical holds pseudonymised data.
identity_db_url: str = "postgresql+asyncpg://identity_app:identity_app@localhost:5432/identity"
@@ -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()
+4 -1
View File
@@ -13,7 +13,7 @@ from pydantic import BaseModel
from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian
from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts
from app.core.intake_questions import DIARY_PARTS, content, diary_config, red_flags, texts
from app.core.scales import load_scale
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
@@ -92,6 +92,9 @@ def catalog() -> Catalog:
),
)
flags = red_flags()
add(FormInfo(id="red_flags", title="Вопросы о безопасности", section="safety"), [i["id"] for i in flags["items"]])
cssrs = load_cssrs_russian()
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items])
+23 -4
View File
@@ -53,11 +53,30 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")}
def red_flags() -> dict:
return yaml.safe_load((get_settings().config_dir / "instruments/red_flags.yaml").read_text(encoding="utf-8"))
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 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
so a positive answer interrupts the questionnaire wherever it comes."""
"""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
PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. Items inside a scale keep the order of the source.
The risk stop rules run after every answer (flows/intake.py), so a positive answer interrupts the questionnaire
wherever it comes."""
flags = red_flags()
for n, item in enumerate(flags["items"]):
if item["id"] not in answers:
text = (flags["intro"] + "\n\n" if n == 0 else "") + item["text"]
return Question(item["id"], text, tuple((o["value"], o["label"]) for o in flags["options"]), "now")
for item in ("phq9_1", "phq9_2"):
if item not in answers:
return scale_question("phq9", item)
+2 -1
View File
@@ -12,6 +12,7 @@ import yaml
from app.config import get_settings
from app.core.intake_questions import content
from app.core.questionnaire import IntakeDecision, IntakePolicy
from app.core.safety import fill_phones
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
@@ -23,7 +24,7 @@ PCL5_IMPROVED_BELOW = 28
def texts() -> dict[str, Any]:
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))
return yaml.safe_load(fill_phones((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8")))
def render(interpretation: dict[str, Any]) -> str:
+6 -5
View File
@@ -50,7 +50,6 @@ class IntakePolicy(BaseModel):
phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
phq9_priority_at: int = Field(ge=0, le=27, strict=True)
pcl5_specialist_at: int = Field(ge=0, le=80, strict=True)
pcl5_priority_at: int = Field(ge=0, le=80, strict=True)
pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
@model_validator(mode="after")
@@ -65,8 +64,6 @@ class IntakePolicy(BaseModel):
raise ValueError("choose a total threshold OR a zone for alcohol expansion")
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
raise ValueError("PHQ-9 thresholds must be ordered")
if self.pcl5_specialist_at >= self.pcl5_priority_at:
raise ValueError("PCL-5 thresholds must be ordered")
return self
@@ -276,7 +273,9 @@ def evaluate_intake(
if c.positive:
flags.append("cssrs_positive")
flags.extend(c.conflicts)
if item9_positive or c.risk_lower_bound == "high":
# One crisis rule (ADR-013): a high C-SSRS risk. Item 9 alone is a crisis only while the screener is not complete;
# with a negative / low screener it is the «discrepancy» flag of the routing table, not the crisis screen.
if c.risk_lower_bound == "high" or (item9_positive and not c.complete):
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
@@ -327,9 +326,11 @@ def evaluate_intake(
# PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
phq_total = phq.total if phq.complete else 0
pcl_total = pcl.total if pcl.complete else 0
# Risk and flags first, scales second (clinical review 2026-10-04). A high PCL-5 alone is not a reason for a
# priority appointment: it goes to the planned specialist route.
if c.risk == "moderate":
primary = "urgent_psychiatrist"
elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at:
elif phq_total >= policy.phq9_priority_at:
primary = "priority_psychiatrist"
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
primary = "psychiatrist_or_psychotherapist"
+19
View File
@@ -395,6 +395,25 @@ def _crisis_cfg(path: Path = CRISIS_PATH) -> _CrisisCfg:
return _CrisisCfg.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
_PHONE_REF = re.compile(r"\{phone:([a-z_]+)\}")
def fill_phones(text: str, path: Path = CRISIS_PATH) -> str:
"""Replace `{phone:<resource>}` with the number from the single table in config/texts/crisis.yaml.
Every patient text and prompt refers to the table instead of repeating a number, so a number is changed in one
place. An unknown resource fails loudly: a wrong number on the crisis screen must never pass silently.
"""
resources = _crisis_cfg(path).resources
def number(m: re.Match[str]) -> str:
if m.group(1) not in resources:
raise ValueError(f"unknown crisis resource {m.group(1)!r}")
return resources[m.group(1)].phone
return _PHONE_REF.sub(number, text)
def _render(body: str, resources: list[CrisisResource], doctor_notified: str, grounding: str) -> str:
lines = [body.strip(), ""]
for r in resources:
+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.
+111 -7
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,
@@ -14,6 +15,8 @@ from app.core.intake_questions import (
diary_questions,
group_answers,
next_intake,
red_flag_category,
red_flag_triggers,
scale_question,
texts,
)
@@ -27,6 +30,7 @@ from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Step, on_enter, state, trigger
from app.services import questionnaire as service
from app.services.followup import schedule_post_crisis_call
def allowed(ctx):
@@ -105,6 +109,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
level=AlertLevel.ACUTE,
params={"record_id": row.id if row else None, "flags": flags},
)
await schedule_post_crisis_call(ctx.session, ctx.token, ctx.now, source="questionnaire:" + code)
step = open_crisis(ctx, categories or [], source="questionnaire:" + code)
step.data["questionnaire_record"] = None
step.data["intake_review_required"] = True
@@ -113,6 +118,74 @@ 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:
profile.enhanced_monitoring = True
await service.alert(
ctx.session,
ctx.token,
row.id + ":phq9_cssrs_discrepancy",
"phq9_cssrs_discrepancy",
ctx.now,
level=AlertLevel.HIGH,
params={"record_id": row.id},
)
row.details = {**row.details, "item9_flagged": True}
async def item9_assessment(ctx, row):
"""Thoughts of death in PHQ-9 item 9 → the C-SSRS screener at once; the screener's level decides (graded)."""
await service.alert(
ctx.session,
ctx.token,
row.id + ":item9_assessment",
"phq9_item9_cssrs",
ctx.now,
level=AlertLevel.HIGH,
params={"record_id": row.id},
)
await service.finish(ctx, row, "complete", extra={"stop_reason": "phq9_item9_cssrs"})
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", row.id + ":cssrs", ctx.now)
return await start(ctx, "cssrs_since_contact", task)
async def manual_review(ctx, row, code):
await service.finish(ctx, row, "review_required", extra={"stop_reason": code})
await service.alert(
@@ -132,12 +205,36 @@ 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()
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal")
# 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 and red_flag_triggers(item_id):
category = red_flag_category(item_id)
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":
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)
if a.get("phq9_9", 0) > 0 and not c.complete:
# Item 9 without a completed screener (only in a scheduled PHQ-9): assess the risk right away.
return await item9_assessment(ctx, row)
if a.get("phq9_9", 0) > 0 and not row.details.get("item9_flagged"):
# Item 9 ≥1 with a negative / low C-SSRS = discrepancy (routing table): flag to the doctor, safety plan.
await item9_discrepancy(ctx, row)
if c.positive and policy.cssrs_positive_policy == "stop_any":
return await urgent(ctx, row, code="cssrs_positive")
if c.conflicts:
@@ -165,7 +262,7 @@ async def advance(ctx, row):
return await manual_review(ctx, row, "answer_conflict")
if q:
replies = [question_reply(row, q)]
if c.complete and c.risk == "low" and not row.details.get("plan_shown"):
if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"):
from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx))
@@ -225,11 +322,12 @@ async def advance(ctx, row):
if row.kind in ("phq9", "pcl5"):
sid = row.kind
total = extra["totals"][sid]["total"]
priority = getattr(policy, sid + "_priority_at")
# Only PHQ-9 has a priority threshold; PCL-5 alone never makes an appointment a priority.
priority = getattr(policy, sid + "_priority_at", None)
specialist = getattr(policy, sid + "_specialist_at")
extra["route"] = (
"priority_psychiatrist"
if total >= priority
if priority is not None and total >= priority
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
)
earlier = [
@@ -284,7 +382,13 @@ async def advance(ctx, row):
return step
from app.dialog.flows.core import menu_reply
return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None})
replies = [menu_reply(ctx, shown)]
if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"):
# The safety plan is never skipped because the risk signal came with the last question.
from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx))
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
# Diary flags that bring a full scale forward («Этап 2», p.4).
+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)
+4 -1
View File
@@ -7,6 +7,7 @@ import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
from app.core.safety import fill_phones
class _Strict(BaseModel):
@@ -91,6 +92,7 @@ class ReachOutTexts(_Strict):
silence: str
missed_doses: str
default: str
after_crisis: str
class VisitTexts(_Strict):
@@ -111,6 +113,7 @@ class DialogTexts(_Strict):
reach_out: ReachOutTexts
visit: VisitTexts
unavailable: str
side_answer_return: str = "Вернёмся к вопросу:"
def dialog_texts_path() -> Path:
@@ -120,4 +123,4 @@ def dialog_texts_path() -> Path:
@lru_cache
def load_dialog_texts(path: Path | None = None) -> DialogTexts:
path = path or dialog_texts_path()
return DialogTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
return DialogTexts.model_validate(yaml.safe_load(fill_phones(path.read_text(encoding="utf-8"))))
+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)
+16 -3
View File
@@ -1,6 +1,8 @@
"""System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools."""
SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
from app.core.safety import fill_phones
_SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах.
Как работать:
@@ -30,6 +32,17 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
- проводить психотерапию; обещать конфиденциальность сверх реальной;
- выдумывать факты, которых нет в результатах инструментов.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
На приветствия и болтовню («привет», «как дела?», «спишь?») отвечай 1–2 фразами, тепло, от лица помощника
сервиса (не называй себя «языковой моделью» или «ИИ-моделью») и предложи, с чем можешь помочь: вопросы о
лечении, лекарствах, побочных эффектах, самочувствии. Находки из карты в ответ на болтовню не упоминай.
Никогда не переспрашивай, о каком препарате речь, если вопрос о «моём лекарстве»: это текущее назначение.
Про аналоги и замену: назови группу текущего препарата по инструкции (например, «антидепрессант группы СИОЗС»),
скажи, что в группе есть и другие препараты, а подбор и замену делает только лечащий врач.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений. Без markdown: никаких «*», «#»,
«**»; если нужен список — короткие строки с «•».
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия 8-800-2000-122, и что врач будет уведомлён."""
доверия {phone:helpline}, и что врач будет уведомлён."""
# Numbers come from the single table config/texts/crisis.yaml.
SYSTEM_PROMPT = fill_phones(_SYSTEM_PROMPT)
+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
+12 -1
View File
@@ -22,11 +22,13 @@ from app.db import repo
from app.db.clinical import PatientProfile
from app.db.intake import QuestionnaireTask
from app.db.session import get_clinical_session
from app.dialog.contracts import Event, Reply, get_engine
from app.dialog.contracts import Button, Event, Reply, get_engine
from app.dialog.texts import load_dialog_texts
from app.integrations.base import integrations
from app.scheduler import demo
from app.services import monitoring
from app.services import questionnaire as service
from app.services.followup import next_day_check_due
log = logging.getLogger(__name__)
_worker = None
@@ -43,6 +45,15 @@ async def plan():
state = await repo.get_dialog_state(session, profile.token)
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
continue
crisis = state.data.get("crisis")
if next_day_check_due(crisis, now):
# The day after a crisis the app asks how the person is, the safety plan one tap away.
texts = load_dialog_texts()
plan_button = Button(label=texts.safety_plan.title.rstrip(":"), data="safety_plan")
await integrations.outbox.push(
profile.token, [Reply(text=texts.reach_out.after_crisis, buttons=[[plan_button]], kind="question")]
)
state.data = {**state.data, "crisis": {**crisis, "followup_sent": now.isoformat()}}
rows = await service.records(session, profile.token)
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
if initial is None:
+24
View File
@@ -0,0 +1,24 @@
"""Pseudonymous care routing; no identity fields or changes to clinical release gates."""
from hashlib import sha256
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
class AnonymousCare(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
doctor_id: str = Field(min_length=1, max_length=64)
def doctor_id() -> str:
path = get_settings().config_dir / "anonymous_care.yaml"
return AnonymousCare.model_validate(yaml.safe_load(path.read_text())).doctor_id
def display_name(token: str) -> str:
# Do not use a piece of the bearer token itself as the public patient label.
label = sha256(("anonymous-patient:" + token).encode()).hexdigest()[:10].upper()
return f"Пациент {label}"
+43
View File
@@ -0,0 +1,43 @@
"""Follow-up after a crisis (clinical review 2026-10-04: «план безопасности и звонок после кризиса — обязательно»).
Every crisis — from the safety lexicon, a rule (PHQ-9 item 9, C-SSRS high) or the questionnaire (red flags, high
risk) — leaves a task for the doctor on duty: call the patient back, about 24 hours as a guide. The task is an alert
on the dashboard; «позвонили» is its acknowledgement (`repo.ack_alert`, audited). One open task per patient: a
repeated crisis signal does not pile up callbacks. On the next day the app itself asks how the person is, with the
safety plan at hand (`app/scheduler/jobs.py`).
"""
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.types import AlertLevel
from app.db import repo
from app.db.clinical import Alert
POST_CRISIS_CALL = "post_crisis_call"
ON_DUTY = "on-duty"
CALL_WITHIN = timedelta(hours=24) # a guide, not a guarantee
NEXT_DAY_CHECK_AFTER = timedelta(hours=20)
async def schedule_post_crisis_call(session: AsyncSession, token: str, now: datetime, *, source: str) -> Alert | None:
if POST_CRISIS_CALL in await repo.open_alert_codes(session, token):
return None
return await repo.add_alert(
session,
token=token,
level=AlertLevel.HIGH,
code=POST_CRISIS_CALL,
reason="Перезвонить пациенту после кризиса — дежурный специалист, ориентир 24 ч. Отметка «позвонили» — "
"подтверждение этого сигнала.",
ts=now,
params={"due_at": (now + CALL_WITHIN).isoformat(), "assignee": ON_DUTY, "source": source},
)
def next_day_check_due(crisis: dict | None, now: datetime) -> bool:
"""The day after a crisis the app asks how the person is — once per crisis episode."""
if not crisis or not crisis.get("opened_at") or crisis.get("followup_sent"):
return False
return now - datetime.fromisoformat(crisis["opened_at"]) >= NEXT_DAY_CHECK_AFTER
+156
View File
@@ -0,0 +1,156 @@
"""The patient's garden (#118, slide 13): light gamification over all modules.
Deterministic, no LLM (guardrail 3), rules in `config/garden.yaml` (ADR-008). Computed on the fly from existing
activity rows — no own table, no migration (ADR-003), and changing the rules replays the whole history.
«Without pressure»: points only add up (a skipped day takes nothing away), growth counts the fact of an activity
and never its content (scale scores, mood), and there is no comparison between patients. Only the patient sees it.
"""
from collections import defaultdict
from collections.abc import Iterable
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock
from app.core.intake_questions import DIARY_KINDS
from app.db.clinical import DailyCheckin, Dose, ScaleResult
from app.db.intake import QuestionnaireRecord
Activity = Literal["meds", "diary", "questionnaire"]
class ActivityRule(BaseModel):
points: int
daily_cap: int
class TitleRule(BaseModel):
from_steps: int
title: str
class GardenConfig(BaseModel):
activities: dict[Activity, ActivityRule]
step_points: int
stages_per_plant: int
max_plants: int
titles: list[TitleRule]
today: dict[Activity, str]
@lru_cache
def load_config(path: Path | None = None) -> GardenConfig:
from app.config import get_settings
path = path or get_settings().config_dir / "garden.yaml"
return GardenConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
class Plant(BaseModel):
stage: int # 0 = not yet sprouted … stages_per_plant = in bloom
class TodayItem(BaseModel):
activity: Activity
title: str
done: bool
detail: str | None = None # e.g. "1 из 2" for doses
class Garden(BaseModel):
points: int
steps: int
title: str
step_points: int
points_in_step: int # progress towards the next growth step: «8 из 10»
plants: list[Plant]
today: list[TodayItem]
def compute(events: Iterable[tuple[Activity, date]], today: date, cfg: GardenConfig | None = None) -> Garden:
"""Points from (activity, day) events: per-day caps, events after `today` (demo clock) ignored."""
cfg = cfg or load_config()
per_day: dict[tuple[Activity, date], int] = defaultdict(int)
for kind, day in events:
if day <= today:
per_day[(kind, day)] += 1
points = sum(
min(n, cfg.activities[kind].daily_cap) * cfg.activities[kind].points for (kind, _), n in per_day.items()
)
steps = points // cfg.step_points
plants = [
Plant(stage=max(0, min(cfg.stages_per_plant, steps - i * cfg.stages_per_plant))) for i in range(cfg.max_plants)
]
title = next(t.title for t in sorted(cfg.titles, key=lambda t: -t.from_steps) if steps >= t.from_steps)
return Garden(
points=points,
steps=steps,
title=title,
step_points=cfg.step_points,
points_in_step=points % cfg.step_points,
plants=plants,
today=[],
)
def _day(ts: datetime, tz) -> date:
return ts.astimezone(tz).date()
async def garden_for(s: AsyncSession, token: str) -> Garden:
cfg = load_config()
now = clock.now()
tz, today = now.tzinfo, now.date()
events: list[tuple[Activity, date]] = []
doses = (await s.scalars(select(Dose).where(Dose.token == token))).all()
for d in doses:
if d.status == "taken":
events.append(("meds", _day(d.answered_at or d.planned_at, tz)))
checkins = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for c in checkins:
if c.mood is not None:
events.append(("diary", c.day))
records = (await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all()
for r in records:
if r.status != "complete":
continue
if r.kind in DIARY_KINDS:
events.append(("diary", r.day))
else:
events.append(("questionnaire", _day(r.completed_at, tz) if r.completed_at else r.day))
scales = (await s.scalars(select(ScaleResult).where(ScaleResult.token == token))).all()
for sc in scales:
if sc.complete:
events.append(("questionnaire", _day(sc.ts, tz)))
garden = compute(events, today, cfg)
done_today = {kind for kind, day in events if day == today}
items: list[TodayItem] = []
due = [d for d in doses if _day(d.planned_at, tz) == today]
if due:
taken = sum(d.status == "taken" for d in due)
due_now = sum(d.planned_at <= now for d in due)
items.append(
TodayItem(
activity="meds",
title=cfg.today["meds"],
done=taken > 0 and taken >= due_now,
detail=f"{taken} из {len(due)}",
)
)
items.append(TodayItem(activity="diary", title=cfg.today["diary"], done="diary" in done_today))
# A questionnaire is not due every day: show it only once done, so an empty day is never a «missed» item.
if "questionnaire" in done_today:
items.append(TodayItem(activity="questionnaire", title=cfg.today["questionnaire"], done=True))
garden.today = items
return garden
+34
View File
@@ -30,6 +30,7 @@ from app.db.audit import audit
from app.db.session import get_clinical_session
from app.integrations.base import integrations
from app.services.contracts import register_monitoring
from app.services.followup import schedule_post_crisis_call
from app.services.snapshot import build_snapshot
log = logging.getLogger(__name__)
@@ -50,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}
@@ -67,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
@@ -93,6 +126,7 @@ class RuleMonitoring:
ts=now,
)
notify.append((ON_DUTY_DOCTOR, level, action.reason_ru))
await schedule_post_crisis_call(s, token, now, source=f"rule:{action.code}")
await refresh_status(s, token)
log.info("monitoring token=%s actions=%s", token, [a.code for a in actions])
for doctor_id, level, text in notify:
+3
View File
@@ -0,0 +1,3 @@
# Routing for the current prototype: anonymous patients use the same doctor workspace,
# without an identity record. Set this to the responsible clinic's doctor when deploying.
doctor_id: doc-smirnova
+38
View File
@@ -0,0 +1,38 @@
# Сад (#118, слайд 13): лёгкая геймификация поверх модулей. Расчёт детерминированный, без LLM.
# Принципы «без давления» (вычитка — #120):
# 1. Сад не вянет: очки только прибавляются, пропуск ничего не отнимает.
# 2. Рост не зависит от ответов: считается факт активности, а не баллы шкал или настроение.
# 3. Нет соревнования: только свой сад, никаких сравнений.
# Очки за активность и дневной лимит по каждому типу (чтобы пять одинаковых действий подряд не давали пятикратный рост).
activities:
meds: # отметка «Принял(а)» по назначенной дозе
points: 2
daily_cap: 4
diary: # ежедневный чек-ин или запись дневника
points: 3
daily_cap: 1
questionnaire: # завершённый опрос (шкала, этап опроса)
points: 4
daily_cap: 1
# Каждые step_points очков — один шаг роста. Растения растут по очереди: у каждого stages_per_plant стадий
# (росток → стебель → бутон → цветок); распустился один — проклёвывается следующий.
step_points: 10
stages_per_plant: 4
max_plants: 6
# Название сада по числу шагов роста (берётся последний порог ≤ шагов).
titles:
- {from_steps: 0, title: "Сад ждёт первых ростков"}
- {from_steps: 1, title: "Появились первые ростки"}
- {from_steps: 4, title: "Распустился первый цветок"}
- {from_steps: 10, title: "Сад растёт"}
- {from_steps: 18, title: "Сад цветёт"}
- {from_steps: 24, title: "Цветущий сад"}
# Подписи пунктов «Сегодня».
today:
meds: "Приём лекарств"
diary: "Запись в дневнике"
questionnaire: "Назначенный опрос"
+27
View File
@@ -0,0 +1,27 @@
# Red flags — asked first in stage 1, before any scale (clinical review 2026-10-04: «обязательно»).
# Any «Да» stops the questionnaire: an acute alert to the doctor, a callback task for the doctor on duty and the
# help screen (crisis script variant by `category`, config/texts/crisis.yaml). Wording — team draft, the doctor
# approves it (#8). These are not a scale: there is no score, only a stop rule.
version: red-flags-v1-2026-10-04
intro: "Сначала несколько коротких вопросов о безопасности. Отвечайте так, как есть сейчас."
options:
- {value: 1, label: Да}
- {value: 0, label: Нет}
items:
- id: rf_psychosis
text: Слышите ли Вы сейчас голоса или видите то, чего не видят и не слышат другие люди?
category: null
- id: rf_harm_others
text: Есть ли у Вас сейчас мысли причинить вред другому человеку?
category: harm_others
- 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
- id: rf_intoxication
text: Находитесь ли Вы сейчас в состоянии сильного опьянения или тяжёлой отмены алкоголя либо других веществ?
category: null
+2 -2
View File
@@ -1,7 +1,8 @@
# Review candidate from the supplied MVP PDF, not a signed clinical protocol.
version: mvp-v2-review-2026-10-03
approval: null
cssrs_positive_policy: pending
# C01 decided by the team after the clinical review (2026-10-04, ADR-013): crisis screen at HIGH risk only.
cssrs_positive_policy: graded
rus_audit_s_expand_from_zone: null
phq2_expand_at: 2
tsq_expand_at: 6
@@ -9,5 +10,4 @@ phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3
-1
View File
@@ -17,5 +17,4 @@ phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3
+6 -3
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.
@@ -62,7 +63,7 @@ rules:
params: {}
actions:
- {type: alert, level: high}
reason_ru: "C-SSRS: умеренный риск, врачу в течение 24 ч"
reason_ru: "C-SSRS: умеренный риск, психиатр — ориентир 24 ч"
# ---------------------------------------------------------------- scale thresholds (yellow)
- id: phq9_severe
@@ -73,10 +74,12 @@ rules:
reason_ru: "PHQ-9 = {total} (тяжёлая депрессия, порог {threshold})"
- id: pcl5_severe
description: Latest PCL-5 total >= 50 → doctor within 24 h.
description: >
Latest PCL-5 total >= 50 → planned specialist review. Not a priority appointment by itself: risk and flags
come first, scales second (clinical review 2026-10-04).
params: {threshold: 50}
actions:
- {type: alert, level: high}
- {type: alert, level: moderate}
reason_ru: "PCL-5 = {total} (порог {threshold})"
- id: phq9_rise
+7
View File
@@ -1,4 +1,6 @@
# Кризисный скрипт (guardrail №2, issue #4). Отправляется ДОСЛОВНО при safety = acute; LLM не вызывается.
# `resources` — ЕДИНСТВЕННАЯ таблица телефонов приложения: остальные тексты и промпты ссылаются на неё через
# `{phone:<ключ>}` (app/core/safety.py::fill_phones). Источники и проверка — docs/clinical/crisis-contacts.md.
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Выбор варианта (app/core/safety.py::crisis_reply): `<audience>.<категория>` по порядку category_priority,
@@ -17,6 +19,11 @@ resources:
name: "Психологическая помощь МЧС России"
phone: "+7 (495) 989-50-50"
note: "круглосуточно; стоимость звонка по тарифу оператора"
# For minors only (age gate stop text); not a general adult crisis line (docs/clinical/crisis-contacts.md).
child_helpline:
name: "Детский телефон доверия"
phone: "8-800-2000-122"
note: "для детей, подростков и родителей; бесплатно, анонимно"
police:
name: "Полиция"
phone: "102"
+10 -5
View File
@@ -53,7 +53,7 @@ age_gate:
unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990."
minor_stop: |-
Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия 8-800-2000-122 (бесплатно, анонимно, круглосуточно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия {phone:child_helpline} (бесплатно, анонимно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если есть угроза жизни прямо сейчас — звоните 112.
idle:
@@ -79,9 +79,9 @@ revoked:
crisis:
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:"
repeat: "Я рядом. Если вам угрожает опасность прямо сейчас — позвоните:"
# Любое другое сообщение, пока кризис открыт (до подтверждения врача).
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50."
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}."
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
@@ -101,7 +101,7 @@ safety_plan:
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
- "Люди и места, которые отвлекают: к кому можно пойти."
- "Кому позвонить, если тяжело: близкий человек, сослуживец."
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112."
- "Специалисты: ваш врач, телефон доверия {phone:helpline}, экстренные службы 112."
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
@@ -109,10 +109,15 @@ reach_out:
silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке."
missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу."
default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно."
# The day after a crisis (app/services/followup.py); the doctor on duty also calls back.
after_crisis: "Как вы сегодня? Вчера было очень тяжело — мне важно знать, что вы в порядке. Ваш план безопасности под кнопкой ниже. С вами также свяжется дежурный специалист. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}."
visit:
button: "Записаться к врачу раньше"
unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50.
Если вам плохо прямо сейчас — звоните 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:
- "чем"
- "где"
- "есть ли"
- "что содержится"
- "что содержат"
- "что входит"
- "что в составе"
- "какой состав"
- "какие побочки"
- "какие побочные"
- "состав"
- "какие"
- "можно"
- "из чего"
- "в чём"
- "в чем"
- "расскажи"
- "объясни"
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
+8
View File
@@ -0,0 +1,8 @@
connected: "Уведомления подключены. Здесь будут появляться напоминания с сайта. Отключить их можно командой /stop."
not_connected: "Чтобы получать уведомления, откройте сайт и подключите MAX в настройках уведомлений."
invalid: "Не удалось подключить уведомления. Создайте новую ссылку на сайте. Если MAX уже подключён к другому аккаунту, сначала отключите прежнюю привязку."
stopped: "Уведомления отключены. Подключить их снова можно по новой ссылке с сайта."
help: "Этот бот присылает уведомления. Пройти тесты, посмотреть результаты и продолжить общение можно на сайте. Команда /stop отключает уведомления."
notification: "На сайте есть напоминание для вас. Откройте личный кабинет, чтобы продолжить."
completed: "Прохождение теста завершено. Подробности доступны в личном кабинете на сайте."
open_site: "Открыть сайт"
+2 -2
View File
@@ -32,13 +32,13 @@ findings:
text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом."
suicide_low:
title: "Тяжёлые мысли"
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия."
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия {phone:helpline}."
tbi:
title: "Контузия или ЧМТ"
text: "Рекомендуем консультацию невролога."
routes:
urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра."
urgent_psychiatrist: "Срочно свяжитесь с психиатром — ориентир: в течение 24 часов."
priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни."
psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту."
psychologist: "Рекомендуем консультацию психолога."
+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: |-
+11 -8
View File
@@ -42,7 +42,10 @@ def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str:
f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>'
"</div>"
)
return f'<div class="mt-doses" style="grid-template-columns:{cols}">{"".join(cells)}</div>{legend}'
return (
f'<div class="mt-dose-scroll"><div class="mt-doses" style="grid-template-columns:{cols}">'
f"{''.join(cells)}</div></div>{legend}"
)
def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str:
@@ -181,7 +184,7 @@ def render(card: PatientCard, today: date | None) -> None:
main, side = st.columns([1.75, 1], gap="large")
with main:
with st.container(border=True):
with st.container(border=True, key="panel-scales"):
c1, c2 = st.columns(2)
for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")):
fig = charts.scale_figure(card.series, scale_id, title, start)
@@ -191,22 +194,22 @@ def render(card: PatientCard, today: date | None) -> None:
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}")
st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.")
with st.container(border=True):
with st.container(border=True, key="panel-wellbeing"):
fig = charts.wellbeing_figure(card.series, start)
if fig is None:
st.caption("Ежедневных чек-инов пока нет")
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing")
st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.")
with st.container(border=True):
with st.container(border=True, key="panel-doses"):
st.html('<div class="mt-panel-title">Календарь приёма</div>')
st.html(dose_strip_html(grid, start))
with side:
with st.container(border=True):
with st.container(border=True, key="panel-alerts"):
_alerts_feed(card.alerts, today)
with st.container(border=True):
with st.container(border=True, key="panel-summary"):
_summary(card.summary)
with st.container(border=True):
with st.container(border=True, key="panel-safety"):
_safety_plan(card.safety_plan)
with st.container(border=True):
with st.container(border=True, key="panel-prescriptions"):
_prescriptions(card)
+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,
+1 -1
View File
@@ -51,7 +51,7 @@ def render_playback_panel(persona: dict[str, Any]) -> None:
token = persona["token"]
scenario, source = w.scenario_for(persona)
state = w.playback_state(token)
st.subheader("🎬 Сценарий демо")
st.subheader("Сценарий демо")
_scenario_source_controls(persona, source)
if scenario.errors:
with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days):
+2 -3
View File
@@ -368,10 +368,9 @@ def render_doctor_panel(highlight_token: str | None, *, key: str = "doc") -> Non
]
for a in fresh_acute[:1]:
st.markdown(
"<div style='background:#c62828;color:#fff;padding:18px 20px;border-radius:12px;"
"font-size:1.35rem;font-weight:700;margin-bottom:8px'>"
"<div class='mt-banner t-red mt-console-alert'><div>"
f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>"
f"<span style='font-size:1.05rem;font-weight:500'>{_escape(a.get('reason', ''))}</span></div>",
f"<div class='mt-sub'>{_escape(a.get('reason', ''))}</div></div></div>",
unsafe_allow_html=True,
)
c1, c2 = st.columns(2)
@@ -68,7 +68,7 @@ def _reset() -> None:
def _result(res: ConnectPatientOut, name: str) -> None:
link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501"))
with st.container(border=True):
with st.container(border=True, key="panel-connect-result"):
st.html(
f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>'
'<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит '
@@ -108,7 +108,7 @@ def main() -> None:
st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,))
left, right = st.columns(2, gap="large")
with left, st.container(border=True):
with left, st.container(border=True, key="panel-connect-person"):
st.html('<div class="mt-panel-title">Пациент</div>')
st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО")
a, b = st.columns(2)
@@ -128,7 +128,7 @@ def main() -> None:
key="cp_modules",
format_func=lambda m: h.MODULE_LABELS[m],
)
with right, st.container(border=True):
with right, st.container(border=True, key="panel-connect-prescription"):
st.html('<div class="mt-panel-title">Назначение</div>')
d1, d2 = st.columns([1.4, 1])
with d1:
+31 -7
View File
@@ -14,21 +14,45 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
from console.playback_ui import render_playback_panel # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
st.html(theme.CSS)
st.html(
ui.page_header(
"Демо-консоль",
"Чат пациента и сценарий сопровождения. Прототип на синтетических данных.",
kicker="Демонстрация",
)
)
persona = w.select_persona()
w.render_connect_hero()
w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}")
st.title("Демо-консоль")
st.caption("Прототип на синтетических данных. Время — виртуальные демо-часы (ADR-005).")
w.render_clock_bar(persona, key="console-clock")
st.divider()
# 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")
chat_col, play_col = st.columns([1.05, 1], gap="large")
with chat_col:
st.subheader(f"💬 Чат пациента — {persona.get('display_name') or persona['token']}")
with chat_col, st.container(border=True, key="panel-console-chat"):
st.subheader(f"Чат пациента — {persona.get('display_name') or persona['token']}")
w.render_chat(persona["token"], height=560, key="console-chat")
with play_col:
with play_col, st.container(border=True, key="panel-console-scenario"):
render_playback_panel(persona)
+10 -10
View File
@@ -15,12 +15,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed")
st.markdown(
"<style>.block-container{padding-top:1.2rem;max-width:100%} "
"[data-testid='stMarkdownContainer'] p, [data-testid='stMarkdownContainer'] li{font-size:1.08rem}</style>",
unsafe_allow_html=True,
)
st.html(theme.CSS)
st.html(ui.page_header("Сплит-экран", "Пациент и врач: сопровождение в реальном времени.", kicker="Демонстрация"))
persona = w.select_persona()
w.show_flash()
@@ -44,15 +44,15 @@ if clicked and nxt is not None:
st.rerun()
left, right = st.columns([1.1, 1], gap="large")
with left:
with left, st.container(border=True, key="panel-split-patient"):
url = w.patient_web_url()
if url:
st.markdown(f"#### 📱 Пациент — {persona.get('display_name') or token}")
st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
st.iframe(url, height=760)
st.caption(f"Веб-приложение пациента: {url}")
else:
st.markdown(f"#### 💬 Пациент — {persona.get('display_name') or token}")
st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
w.render_chat(token, height=600, key="split-chat")
with right:
st.markdown("#### 🩺 Кабинет врача — д-р Смирнова Е. А.")
with right, st.container(border=True, key="panel-split-doctor"):
st.markdown("#### Кабинет врача — д-р Смирнова Е. А.")
w.polled_doctor_panel(token, key="split-doc")
+23 -6
View File
@@ -138,8 +138,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-empty p {{ font-size:.85rem; margin:8px 0 0; }}
/* ---------- chips, badges, bars ---------- */
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px;
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }}
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 5px; margin-right: 4px;
background: #edf3ee; color: #38634b; font-weight: 500; }}
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
border-radius: 5px; white-space: normal; }}
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
@@ -166,7 +166,7 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-banner b {{ color: inherit; }}
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
box-shadow: 0 0 0 4px #faeeee; }}
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650;
.mt-panel-title {{ font-size: .82rem; text-transform: uppercase; letter-spacing: .03em; font-weight: 550;
color: var(--ink-3); margin: 2px 0 8px; }}
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
.mt-alert.t-red {{ border-left-color: var(--red); }}
@@ -175,9 +175,9 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }}
.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }}
.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }}
.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink);
line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }}
.mt-summary {{ background: #f4f6f2; border:1px solid var(--line); border-radius: 12px; padding: 14px 16px;
color: var(--ink); line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--ink-2); font-size: .76rem; margin-top: 8px; }}
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
@@ -195,6 +195,21 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
vertical-align: -1px; }}
/* ---------- internal pages ---------- */
div[class*="st-key-panel-"] {{ background:var(--surface); border:1px solid var(--line); border-radius:12px;
padding:20px; }}
[data-testid="stExpander"] {{ background:var(--surface); border-color:var(--line); border-radius:12px; }}
[data-testid="stChatMessage"] {{ background:#f4f6f2; border:1px solid var(--line); border-radius:12px; }}
[data-testid="stChatInput"] {{ background:var(--surface); border:1px solid var(--line); border-radius:8px; }}
[data-testid="stNumberInputContainer"], [data-testid="stDateInput"] input {{
background:var(--surface); color:var(--ink); border-color:var(--line); border-radius:8px; }}
[data-testid="stApp"] [data-variant="pills"][data-selected] {{ background:#edf3ee; color:#285e4e;
border-color:#b4c6ba; }}
.mt-console-alert {{ font-size:1.1rem; font-weight:600; }}
.mt-console-alert .mt-sub {{ color:var(--red-ink); font-weight:400; }}
.mt-dose-scroll {{ overflow-x:auto; padding-bottom:4px; }}
.mt-doses {{ min-width:max-content; }}
/* ---------- connect result ---------- */
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
font-variant-numeric: tabular-nums; }}
@@ -222,6 +237,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }}
.mt-row-details {{ grid-template-columns:1fr; gap:12px; }}
.mt-list-footer {{ flex-wrap:wrap; }}
.mt-head .mt-side {{ text-align:left; }}
div[class*="st-key-panel-"] {{ padding:16px; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }}
}}
@media (prefers-reduced-motion: reduce) {{
+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:
+20
View File
@@ -3,6 +3,26 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`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. Красные флаги (`config/instruments/red_flags.yaml`) идут первыми в этапе 1: голоса/видения, мысли причинить вред другому, доступ к оружию, насилие дома, сильное опьянение или отмена. Любое «Да» останавливает опрос: острый алерт врачу и экран помощи нужного варианта (`harm_others`, `domestic_violence`).
2. Одно правило кризиса во всём приложении: кризисный экран открывается только при **высоком** риске C-SSRS. Умеренный риск — стоп опроса и психиатр, «ориентир 24 ч»; низкий — флаг врачу, план безопасности, опрос продолжается. Пункт 9 PHQ-9 при отрицательном C-SSRS — флаг «расхождение» и план безопасности; без пройденного C-SSRS (плановый PHQ-9, дневник) — сразу C-SSRS, решает его уровень. `config/questionnaire.yaml`: `cssrs_positive_policy: graded` (C01).
3. После любого кризиса дежурному создаётся задача «перезвонить пациенту» (алерт `post_crisis_call`, ориентир 24 ч; «позвонили» — подтверждение алерта), одна на пациента, пока открыта. На следующий день приложение само спрашивает «как вы» с кнопкой плана безопасности (`app/services/followup.py`).
4. Маршрут: риск и флаги → шкалы. PCL-5 ≥50 убран из приоритетной записи (и в правилах дашборда `pcl5_severe` — уровень moderate): высокий PCL-5 ведёт к плановой записи к специалисту.
5. Все телефоны — только в `config/texts/crisis.yaml`; остальные тексты и промпт агента ссылаются через `{phone:<ключ>}`, неизвестный ключ роняет загрузку, тест не пускает «голые» номера в код и тексты.
**Причина:** врач назвал пункты 1–2 обязательными; одно правило кризиса исключает расхождение демо с документом; неверный номер на кризисном экране недопустим.
**Последствия:** формулировки красных флагов — черновик команды, утверждает врач (#8). Старое правило дашборда `phq9_item9` (ADR-006, сценарий без опросника) по-прежнему поднимает кризис по п.9 — свести при следующем ревью правил.
## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату
**Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста».
**Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется.
+1 -1
View File
@@ -160,7 +160,7 @@ q6 за всю жизнь всем; при положительном q6 уто
| ID | Решение, которое нужно зафиксировать | Статус |
|---|---|---|
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending |
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | **решено 2026-10-04 (ADR-013):** кризисный экран только при высоком риске; умеренный — стоп и психиатр (ориентир 24 ч); низкий — флаг, план безопасности, продолжение. `cssrs_positive_policy: graded`. Подпись врача — в #8. |
| C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending |
| C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending |
| C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | pending |
+3
View File
@@ -1,5 +1,8 @@
# Проверка контактов: 3 октября 2026
> **Единая таблица номеров приложения — `config/texts/crisis.yaml` (`resources`).** Остальные тексты и промпт
> чат-агента ссылаются на неё через `{phone:<ключ>}` (ADR-013); номер меняется в одном месте.
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
или уведомлений службам/врачу не выполнялось.
+19 -7
View File
@@ -1,24 +1,36 @@
#!/usr/bin/env bash
# Deploy origin/dev to the shared server (ssh alias `vobot`, dir /srv/ptsr). Run from a clean checkout:
# Deploy a git ref to the shared server (dir /srv/ptsr). Used by hand and by CD (.gitea/workflows/deploy.yml).
# infra/deploy/deploy.sh [git-ref] default: origin/dev
# Env: DEPLOY_HOST (ssh host/alias, default `vobot`), DEPLOY_SSH_OPTS (extra ssh/scp options).
# Keeps /srv/ptsr/.env (secrets live only on the server) and the Postgres volume.
# The host drops some ssh connections (rate limiting) — every remote step is retried.
set -euo pipefail
REF=${1:-origin/dev}
HOST=${DEPLOY_HOST:-vobot}
OPTS=${DEPLOY_SSH_OPTS:-}
DIR=/srv/ptsr
git fetch -q origin || true
git fetch -q origin 2>/dev/null || true
SHA=$(git rev-parse --short "$REF")
TGZ=$(mktemp --suffix=.tgz)
trap 'rm -f "$TGZ"' EXIT
git archive --format=tar.gz -o "$TGZ" "$REF"
retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; echo "retry $n: $1" >&2; sleep $((n * 10)); done; }
echo "deploy $REF ($SHA) -> $HOST:$DIR"
git archive --format=tar "$REF" | ssh "$HOST" "rm -rf $DIR/app.new && mkdir -p $DIR/app.new && tar -x -C $DIR/app.new"
ssh "$HOST" bash -s <<SH
# shellcheck disable=SC2086
retry scp -q $OPTS "$TGZ" "$HOST:$DIR/release.tgz"
# shellcheck disable=SC2086
retry ssh $OPTS "$HOST" "bash -s" <<SH
set -euo pipefail
cd $DIR
rm -rf app.new && mkdir app.new && tar -xzf release.tgz -C app.new && rm -f release.tgz
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 -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 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 && echo " api ok"
curl -sf 127.0.0.1:8010/health >/dev/null && echo "$SHA" > REVISION && echo "api ok @ $SHA"
SH
+5 -1
View File
@@ -23,8 +23,12 @@ services:
- --browser.gatherUsageStats=false
environment:
API_BASE_URL: http://app:8000
DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-http://localhost}/doctor
DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-https://dev.meditron.tenvex.ru}/doctor
ports: !override
- "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"]
+69 -2
View File
@@ -1,8 +1,9 @@
# meditron.tenvex.ru — PTSRaiAgent prototype (synthetic data only).
# PTSRaiAgent prototype (synthetic data only) — shared host with vobot.
# / patient web app (static, web/dist)
# /api/ FastAPI (127.0.0.1:8010)
# /doctor/ Streamlit doctor dashboard + demo console (127.0.0.1:8511, baseUrlPath=doctor)
# TLS: after the DNS A-record exists — certbot --nginx -d meditron.tenvex.ru
# dev.meditron.tenvex.ru — HTTPS (Let's Encrypt, certbot certonly --nginx, auto-renew).
# meditron.tenvex.ru — HTTPS (same stack for now; will serve the main branch after promotion).
map $http_upgrade $ptsr_connection_upgrade {
default upgrade;
@@ -12,7 +13,73 @@ map $http_upgrade $ptsr_connection_upgrade {
server {
listen 80;
listen [::]:80;
server_name dev.meditron.tenvex.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name dev.meditron.tenvex.ru;
ssl_certificate /etc/letsencrypt/live/dev.meditron.tenvex.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.meditron.tenvex.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m;
root /srv/ptsr/app/web/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # chat agent + scheduler replay on clock advance
}
location ~ ^/(docs|openapi.json|health)$ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
}
location /doctor/ {
proxy_pass http://127.0.0.1:8511;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $ptsr_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_buffering off;
}
}
server {
listen 80;
listen [::]:80;
server_name meditron.tenvex.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name meditron.tenvex.ru;
ssl_certificate /etc/letsencrypt/live/meditron.tenvex.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/meditron.tenvex.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m;
+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)
+85 -16
View File
@@ -12,6 +12,7 @@ from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription
from app.main import app
from app.services.anonymous_care import doctor_id as anonymous_doctor
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "b" * 32
@@ -48,17 +49,17 @@ async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: Asy
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s:
# Only a doctor-less pseudonymous profile: the dialog API serves known profiles only.
# The care team sees a pseudonymous profile without an identity record.
profile = await repo.get_profile(s, first["token"])
assert profile.doctor_id == "" and profile.modules == []
assert profile.doctor_id == anonymous_doctor() and profile.modules == []
async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None:
async def test_anonymous_focus_does_not_remove_access_to_doctor(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"]
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile.modules == ["veteran"] and not profile.doctor_id
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False
assert profile.modules == ["veteran"] and profile.doctor_id == anonymous_doctor()
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is True
assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422
@@ -86,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
@@ -122,7 +127,7 @@ async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
body = (await client.get(f"/api/v1/me/{token}/today")).json()
assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == []
assert body["has_doctor"] is True and body["doses"] == [] and body["prescriptions"] == []
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
@@ -140,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
@@ -158,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"}
@@ -169,8 +174,8 @@ async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeyp
await press()
await press("consent:yes")
buttons = await press("age:adult")
# Stage 1 starts with PHQ-2, in the order of the MVP questionnaire.
assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons)
# Stage 1 starts with the red-flag block, then the order of the MVP questionnaire.
assert any(b.startswith("iq:") and ":rf_psychosis:" in b for b in buttons)
assert "intake:help" in buttons and "intake:pause" in buttons
q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"]
assert q["enabled"] and q["intake"] == "active"
@@ -214,9 +219,73 @@ async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch
assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"]
await press()
await press("consent:yes")
assert any(":phq9_1:" in b for b in await press("age:adult"))
assert any(":rf_psychosis:" in b for b in await press("age:adult"))
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
body = (await client.get("/api/v1/patient/demo-personas")).json()
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
async def test_anonymous_is_visible_only_to_assigned_doctor(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()["token"]
second = (await client.post("/api/v1/auth/anonymous")).json()["token"]
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
names = {p["token"]: p["display_name"] for p in items}
assert names[first].startswith("Пациент ") and names[first] != names[second]
assert first not in names[first]
own = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": anonymous_doctor()})
assert own.status_code == 200 and own.json()["patient"]["display_name"] == names[first]
denied = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": "another-doctor"})
assert denied.status_code == 403
assert (await client.get("/api/v1/doctors/another-doctor/patients")).json() == []
async def test_old_anonymous_profile_requires_explicit_care_opt_in(client: AsyncClient) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
# Merely opening Today must not reveal an old private profile to a doctor.
assert not (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["has_doctor"]
assert (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json() == []
for _ in range(2):
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).json() == {"has_doctor": True}
async with session.get_clinical_session() as s:
p = await repo.get_profile(s, TOKEN)
assert p.doctor_id == anonymous_doctor() and p.modules == ["veteran"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, TOKEN) is None
async def test_connect_care_does_not_reassign_or_create_patients(client: AsyncClient) -> None:
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 404
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="own-doctor")
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 200
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "own-doctor"
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"] is False
monkeypatch.setenv("INTAKE_DEMO", "1")
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:
monkeypatch.setenv("INTAKE_DEMO", "0")
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
for event in [
{"type": "start"},
{"type": "button", "button": "consent:yes"},
{"type": "button", "button": "age:adult"},
{"type": "button", "button": "menu:contact_doctor"},
]:
response = await client.post(f"/api/v1/dialog/{token}/events", json=event)
assert response.status_code == 200
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
assert items[0]["last_event"] == "Пациент просит врача связаться"
card = (await client.get(f"/api/v1/patients/{token}", params={"doctor_id": anonymous_doctor()})).json()
assert card["patient"]["last_event"] == items[0]["last_event"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, token) is 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
+21
View File
@@ -0,0 +1,21 @@
"""Startup validation errors must not print configuration credentials."""
import pytest
from pydantic import ValidationError
from app.config import Settings
def test_incomplete_max_configuration_does_not_expose_token():
with pytest.raises(ValidationError) as error:
Settings(_env_file=None, max_enabled=True, max_bot_token="SYNTHETIC_SECRET")
message = str(error.value)
assert "MAX_BOT_USERNAME" in message
assert "SYNTHETIC_SECRET" not in message and "input_value" not in message
def test_invalid_field_does_not_expose_accidentally_pasted_secret():
with pytest.raises(ValidationError) as error:
Settings(_env_file=None, max_enabled="SYNTHETIC_SECRET")
assert "max_enabled" in str(error.value)
assert "SYNTHETIC_SECRET" not in str(error.value)
+31
View File
@@ -0,0 +1,31 @@
"""Crisis numbers live in one table (config/texts/crisis.yaml); every other text refers to it by `{phone:<key>}`."""
import re
from pathlib import Path
import pytest
from app.core.safety import CRISIS_PATH, fill_phones
from app.llm.agent.prompts import SYSTEM_PROMPT
ROOT = Path(__file__).resolve().parents[1]
# Any phone number except the universal short emergency numbers 112 / 101–103.
NUMBER = re.compile(r"(\+7|\b8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}")
def test_no_number_outside_the_crisis_table():
texts = [p for p in (ROOT / "config" / "texts").glob("*.yaml") if p != CRISIS_PATH]
code = [*(ROOT / "app").rglob("*.py"), *(ROOT / "web" / "src").rglob("*.ts*")]
offenders = [
str(p.relative_to(ROOT))
for p in [*texts, *code]
if p.name != "privacy.py" # the PII scrubber documents the phone format with an example
and NUMBER.search("\n".join(line for line in p.read_text(encoding="utf-8").splitlines() if "#" not in line))
]
assert offenders == []
def test_references_resolve_and_unknown_keys_fail():
assert "989-50-50" in fill_phones("{phone:helpline}") and "989-50-50" in SYSTEM_PROMPT
with pytest.raises(ValueError):
fill_phones("{phone:no_such_line}")
+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
+5 -2
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"]
@@ -482,4 +483,6 @@ def test_dialog_texts_avoid_forbidden_words() -> None:
body = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#"))
for word in ("учёт", "учет", "контрол", "надзор", "диагноз:"):
assert word not in body
assert "152-фз" in body and "8-800-2000-122" in body
assert "152-фз" in body
# Numbers live in one table (config/texts/crisis.yaml); the minors' stop text gets the children's line from it.
assert "8-800-2000-122" not in body and "8-800-2000-122" in load_dialog_texts().age_gate.minor_stop
+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)
+68 -11
View File
@@ -13,6 +13,7 @@ from app.core import clock
from app.core.scales import clear_cache
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Alert
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Event
from app.dialog.engine import Engine
@@ -76,7 +77,8 @@ def answer_button(replies, choices=None):
for b in line:
if b.data.startswith("iq:"):
item = b.data.split(":")[2]
index = choices.get(item, 1 if item.startswith("cssrs_") else 0)
# «Нет» by default for the safety items (C-SSRS, red flags), the first option otherwise.
index = choices.get(item, 1 if item.startswith(("cssrs_", "rf_")) else 0)
for line2 in reply.buttons:
for other in line2:
if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"):
@@ -122,17 +124,17 @@ async def test_pause_restart_and_duplicate_callbacks(released):
old = answer_button(replies)
await asyncio.gather(press(released, old), press(released, old))
(row,) = await rows()
assert row.answers == {"phq9_1": 0}
assert row.answers == {"rf_psychosis": 0}
await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume")
assert "phq9_2" in answer_button(replies)
assert "rf_harm_others" in answer_button(replies)
await press(restarted, old)
assert (await rows())[0].answers == {"phq9_1": 0}
assert (await rows())[0].answers == {"rf_psychosis": 0}
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
@pytest.mark.parametrize("signal", ["help", "phq", "cssrs"])
@pytest.mark.parametrize("signal", ["help", "red_flag", "cssrs"])
async def test_immediate_stop_preserves_partial_answers(released, signal):
replies = await press(released, "intake:start")
if signal == "help":
@@ -142,13 +144,45 @@ async def test_immediate_stop_preserves_partial_answers(released, signal):
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0}))
else:
replies = await complete_existing(released, replies, {"trauma": 1, "phq9_1": 2, "phq9_9": 1})
replies = await press(released, answer_button(replies, {"rf_psychosis": 0}))
assert any(r.kind == "crisis" for r in replies)
(row,) = await rows()
assert row.status == "interrupted" and row.completed_at is None
notices = await rows(QuestionnaireNotice)
assert len(notices) == 1 and notices[0].status == "test_sink"
assert "уведомлён" not in replies[0].text
# Every crisis leaves a callback task for the doctor on duty (one per patient while it is open).
calls = [a for a in await rows(Alert) if a.code == "post_crisis_call"]
assert len(calls) == 1 and calls[0].params["assignee"] == "on-duty"
async def test_red_flag_violence_uses_its_crisis_variant(released):
replies = await press(released, "intake:start")
for _ in range(3): # psychosis, harm to others, weapons: «Нет»
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies, {"rf_violence": 0}))
crisis = next(r for r in replies if r.kind == "crisis")
assert "насили" in crisis.text.lower()
(row,) = await rows()
assert row.details["stop_reason"] == "red_flag_violence"
async def test_item9_with_a_negative_screener_is_a_flag_not_a_crisis(released):
# One crisis rule (ADR-013): C-SSRS was negative, so PHQ-9 item 9 is a discrepancy for the doctor.
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
choices.update(trauma=1, tbi=1, psychotropics=1, phq9_9=1)
replies = await press(released, "intake:start")
for _ in range(60):
button = answer_button(replies, choices)
if button is None or any(r.kind == "safety_plan" for r in replies):
break
replies = await press(released, button)
assert not any(r.kind == "crisis" for r in replies)
assert any(r.kind == "safety_plan" for r in replies)
codes = {a.code for a in await rows(Alert)}
assert "phq9_cssrs_discrepancy" in codes and "post_crisis_call" not in codes
(row,) = await rows()
assert row.status == "complete" and "phq9_cssrs_discrepancy" in row.details["decision"]["flags"]
async def complete_existing(e, replies, choices):
@@ -350,16 +384,24 @@ async def test_diary_morning_then_evening_with_the_ptsd_module(released):
assert any("уже заполнен" in r.text for r in replies)
async def test_depression_module_item9_stops_with_crisis(released, monkeypatch):
async def test_depression_module_item9_asks_the_screener_at_once(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
await run_diary(released, await press(released, "menu:checkin"))
replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1})
assert any(r.kind == "crisis" for r in replies)
replies = await press(released, "menu:checkin")
for _ in range(10):
button = answer_button(replies, {"medication": 2, "dphq_9": 1})
replies = await press(released, button)
if ":cssrs_" in (answer_button(replies) or ""):
break
# Item 9 → the C-SSRS right away («Этап 2»): no crisis screen before the screener says the risk is high.
assert not any(r.kind == "crisis" for r in replies) and any("последнего ответа" in r.text for r in replies)
evening = next(r for r in await rows() if r.kind == "diary_evening")
assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted"
assert evening.answers["dphq_9"] == 1 and evening.details["stop_reason"] == "phq9_item9_cssrs"
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0, "cssrs_q4": 0})
assert any(r.kind == "crisis" for r in replies)
async def test_unknown_sex_goes_to_review_without_a_zone(released):
@@ -380,7 +422,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released):
path.write_text(yaml.safe_dump(data, allow_unicode=True))
await press(released, "intake:resume")
(row,) = await rows()
assert row.status == "review_required" and row.answers == {"phq9_1": 0}
assert row.status == "review_required" and row.answers == {"rf_psychosis": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released):
@@ -434,6 +476,8 @@ async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:start")
for _ in range(5): # the red-flag block
replies = await press(released, answer_button(replies))
await press(released, answer_button(replies))
await press(released, "intake:pause")
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1))
@@ -472,3 +516,16 @@ async def test_without_a_doctor_the_screening_defines_the_modules(released):
assert any("не выявлено" in r.text for r in replies)
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).modules == []
async def test_next_day_after_a_crisis_the_app_asks_once(released, monkeypatch):
replies = await press(released, "intake:start")
await press(released, answer_button(replies, {"rf_psychosis": 0})) # red flag → crisis
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(hours=21))
await jobs.plan()
await jobs.plan()
sent = outbox.drain(TOKEN)
assert len(sent) == 1 and "Как вы сегодня" in sent[0].text
assert any(b.data == "safety_plan" for line in sent[0].buttons for b in line)
+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
+281
View File
@@ -0,0 +1,281 @@
"""End-to-end server-only MAX flow, real SQLite transactions and mocked network."""
from datetime import timedelta
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.channels import max_bot
from app.channels.max_client import MaxApiError
from app.config import get_settings
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import ScaleResult
from app.db.intake import QuestionnaireRecord
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxReceipt
from app.dialog.contracts import Reply
from app.integrations import max_store as store
from app.integrations.base import MemoryOutbox, integrations
from app.main import app
TOKEN = "synthetic-patient"
SECRET = "synthetic-webhook-secret-" * 2
@pytest.fixture(autouse=True)
async def database(monkeypatch):
for key, value in {
"MAX_ENABLED": "true",
"MAX_BOT_TOKEN": "fake-token",
"MAX_BOT_USERNAME": "test_bot",
"MAX_WEBHOOK_SECRET": SECRET,
"MAX_WEBHOOK_URL": "https://api.example.org/api/v1/webhooks/max",
"MAX_SITE_URL": "https://example.org",
}.items():
monkeypatch.setenv(key, value)
get_settings.cache_clear()
max_bot.texts.cache_clear()
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="")
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
yield
await max_bot.stop()
await session.configure()
get_settings.cache_clear()
def event(kind="bot_started", text="", uid=100, *, stamp=1000):
user = {"user_id": uid, "is_bot": False, "first_name": "Synthetic"}
if kind == "message_created":
return {
"update_type": kind,
"timestamp": stamp,
"message": {
"sender": user,
"recipient": {"chat_type": "dialog", "chat_id": 123},
"body": {"mid": f"mid-{stamp}", "text": text},
},
}
return {"update_type": kind, "timestamp": stamp, "user": user, "chat_id": 123, "payload": text}
async def connect():
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
await max_bot.handle_update(event(text=code))
return code
async def test_http_connect_webhook_duplicate_notification_and_stop():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
auth = {"Authorization": f"Bearer {TOKEN}"}
linked = await client.post("/api/v1/me/notifications/max/link", headers=auth, json={"consent": True})
code = parse_qs(urlsplit(linked.json()["url"]).query)["start"][0]
headers = {"X-Max-Bot-Api-Secret": SECRET}
for _ in range(2):
assert (
await client.post("/api/v1/webhooks/max", headers=headers, json=event(text=code))
).status_code == 200
assert (await client.get("/api/v1/me/notifications/max", headers=auth)).json()["connected"]
async with session.get_identity_session() as s:
assert len((await s.scalars(select(MaxReceipt))).all()) == 1
assert len((await s.scalars(select(MaxDelivery))).all()) == 1
sender = AsyncMock()
assert await max_bot.deliver_one(sender)
assert sender.send.call_args.args == (100, max_bot.texts().connected)
await client.post("/api/v1/webhooks/max", headers=headers, json=event("message_created", "/stop", stamp=2000))
assert not (await client.get("/api/v1/me/notifications/max", headers=auth)).json()["connected"]
async def test_unbound_start_help_and_invalid_code_are_generic():
for e in (event(), event("message_created", "private patient text", stamp=2000), event(text="expired", stamp=3000)):
await max_bot.handle_update(e)
async with session.get_identity_session() as s:
rows = (await s.scalars(select(MaxDelivery))).all()
assert {r.kind for r in rows} == {"not_connected", "help", "invalid"}
assert all(r.token is None for r in rows)
assert not (await s.scalars(select(MaxBinding))).all()
@pytest.mark.parametrize("kind", ["bot_stopped", "dialog_removed", "message_created"])
async def test_stopping_cancels_pending_confirmation_and_reminders(kind):
await connect()
await max_bot.MaxOutbox(MemoryOutbox()).push(TOKEN, [Reply(text="private scheduled question")])
await max_bot.handle_update(event(kind, "/stop", stamp=2000))
async with session.get_identity_session() as s:
assert not (await s.get(MaxBinding, TOKEN)).enabled
rows = (await s.scalars(select(MaxDelivery).where(MaxDelivery.token == TOKEN))).all()
assert rows and all(r.state == "cancelled" for r in rows)
sender = AsyncMock()
await max_bot.deliver_one(sender)
assert sender.send.call_count == (1 if kind == "message_created" else 0)
async def test_private_patient_content_stays_in_fallback_only():
await connect()
fallback = MemoryOutbox()
wrapper = max_bot.MaxOutbox(fallback)
replies = [Reply(text="SYNTHETIC confidential result 42", kind="question")]
await wrapper.push(TOKEN, replies)
await wrapper.push(TOKEN, replies)
assert fallback.drain(TOKEN) == replies * 2
async with session.get_identity_session() as s:
rows = (await s.scalars(select(MaxDelivery).where(MaxDelivery.kind == "notification"))).all()
assert len(rows) == 1
assert "confidential" not in repr(rows[0].__dict__)
sender = AsyncMock()
while await max_bot.deliver_one(sender):
pass
assert any(c.args[1] == max_bot.texts().notification for c in sender.send.call_args_list)
assert all("confidential" not in c.args[1] and TOKEN not in str(c.kwargs) for c in sender.send.call_args_list)
async def test_revoked_consent_prevents_binding_and_delivery():
await connect()
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": False, "adult": True}, now=clock.now()
)
sender = AsyncMock()
await max_bot.deliver_one(sender)
sender.send.assert_not_called()
async with session.get_identity_session() as s:
await store.disconnect(s, TOKEN)
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
await max_bot.handle_update(event(text=code, stamp=2000))
async with session.get_identity_session() as s:
assert not (await s.get(MaxBinding, TOKEN)).enabled
@pytest.mark.parametrize(
"bad",
[
{},
{"update_type": "other", "timestamp": 1},
event() | {"timestamp": True},
event() | {"user": {"user_id": True, "is_bot": False}},
event() | {"user": {"user_id": 100, "is_bot": True}},
event() | {"payload": {"wrong": "type"}},
{"update_type": "message_created", "timestamp": 1, "message": {}},
],
)
async def test_malformed_or_bot_events_are_ignored(bad):
await max_bot.handle_update(bad)
async with session.get_identity_session() as s:
assert not (await s.scalars(select(MaxReceipt))).all()
async def test_group_cannot_claim_a_patient_link():
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
update = event("message_created", "/start " + code)
update["message"]["recipient"]["chat_type"] = "chat"
await max_bot.handle_update(update)
async with session.get_identity_session() as s:
assert await s.get(MaxBinding, TOKEN) is None
assert await store.code_token(s, code, clock.now()) == TOKEN
async def test_429_respects_retry_deadline_and_then_succeeds(monkeypatch):
await max_bot.handle_update(event())
now = clock.now()
monkeypatch.setattr(clock, "now", lambda: now)
sender = AsyncMock()
sender.send.side_effect = MaxApiError("http_429", retryable=True, retry_after=120)
assert await max_bot.deliver_one(sender)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.state == "queued" and row.next_attempt_at == now + timedelta(seconds=120)
assert not await max_bot.deliver_one(sender)
now += timedelta(seconds=121)
sender.send.side_effect = None
assert await max_bot.deliver_one(sender)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.state == "sent" and row.attempts == 2
async def test_permanent_error_does_not_retry():
await max_bot.handle_update(event())
sender = AsyncMock()
sender.send.side_effect = MaxApiError("http_403")
await max_bot.deliver_one(sender)
assert not await max_bot.deliver_one(sender)
async with session.get_identity_session() as s:
assert (await s.scalars(select(MaxDelivery))).one().state == "failed"
@pytest.mark.parametrize("source", ["scale", "questionnaire"])
async def test_completed_test_produces_one_neutral_notification(source):
await connect()
sender = AsyncMock()
await max_bot.deliver_one(sender)
sender.reset_mock()
async with session.get_clinical_session() as s:
now = clock.now()
if source == "scale":
s.add(ScaleResult(token=TOKEN, ts=now, scale="phq9", total=1, severity="synthetic", complete=True))
else:
s.add(
QuestionnaireRecord(
id="synthetic-form",
token=TOKEN,
kind="intake",
version="test",
policy_version="test",
started_at=now,
completed_at=now,
day=now.date(),
status="complete",
answers={},
details={},
)
)
for _ in range(2):
async with session.get_identity_session() as s:
await store.collect_completed(s, clock.now())
assert await max_bot.deliver_one(sender)
assert not await max_bot.deliver_one(sender)
sender.send.assert_awaited_once_with(
100, max_bot.texts().completed, site_url="https://example.org", button=max_bot.texts().open_site
)
async def test_private_start_command_accepts_issued_code():
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
await max_bot.handle_update(event("message_created", "/start " + code))
async with session.get_identity_session() as s:
assert (await s.get(MaxBinding, TOKEN)).user_id == 100
async def test_disabled_lifecycle_never_constructs_client(monkeypatch):
monkeypatch.setenv("MAX_ENABLED", "false")
get_settings.cache_clear()
monkeypatch.setattr(max_bot, "MaxClient", lambda _: pytest.fail("disabled MAX must not create a network client"))
before = integrations.outbox
await max_bot.start()
await max_bot.handle_update(event())
await max_bot.stop()
assert integrations.outbox is before
async def test_lifecycle_wraps_once_restores_and_closes(monkeypatch):
sender = AsyncMock()
monkeypatch.setattr(max_bot, "MaxClient", lambda _: sender)
before = integrations.outbox
await max_bot.start()
first = integrations.outbox
await max_bot.start()
assert integrations.outbox is first and first.fallback is before
await max_bot.stop()
assert integrations.outbox is before
sender.close.assert_awaited_once()
+95
View File
@@ -0,0 +1,95 @@
"""Wire contract with MAX, using an in-process HTTP fake (no real bot/token)."""
import json
import logging
import httpx
import pytest
from app.channels.max_client import API_URL, UPDATE_TYPES, MaxApiError, MaxClient
from app.config import Settings
def settings():
return Settings(
_env_file=None,
max_enabled=True,
max_bot_token="synthetic-secret-token",
max_bot_username="synthetic_bot",
max_webhook_secret="synthetic-webhook-secret-" * 2,
max_webhook_url="https://api.example.org/api/v1/webhooks/max",
max_site_url="https://example.org",
)
async def test_message_wire_contract_and_no_identifiers_in_logs(caplog):
def handler(request):
assert str(request.url).startswith(API_URL + "/messages?")
assert request.url.params["user_id"] == "1234567890123"
assert "access_token" not in request.url.params
assert request.headers["Authorization"] == "synthetic-secret-token"
body = json.loads(request.content)
assert body["text"] == "neutral reminder" and "format" not in body
button = body["attachments"][0]["payload"]["buttons"][0][0]
assert button == {"type": "link", "text": "Open", "url": "https://example.org"}
return httpx.Response(200, json={"message": {"body": {"mid": "synthetic-id"}}})
client = MaxClient(settings(), transport=httpx.MockTransport(handler))
with caplog.at_level(logging.INFO):
await client.send(1234567890123, "neutral reminder", site_url="https://example.org", button="Open")
await client.close()
assert "1234567890123" not in caplog.text and "synthetic-secret-token" not in caplog.text
@pytest.mark.parametrize(
"status,retry", [(429, True), (500, True), (503, True), (408, True), (401, False), (403, False)]
)
async def test_error_codes_are_redacted_and_classified(status, retry):
client = MaxClient(
settings(),
transport=httpx.MockTransport(
lambda r: httpx.Response(
status, json={"message": "sensitive provider text"}, headers={"Retry-After": "120"}
)
),
)
with pytest.raises(MaxApiError) as error:
await client.send(100, "neutral", site_url="https://example.org", button="Open")
assert error.value.retryable is retry and error.value.retry_after == 120
assert "sensitive" not in str(error.value)
await client.close()
@pytest.mark.parametrize("body", [{"success": False, "message": "private"}, {}, {"success": True}, []])
async def test_http_200_is_not_enough_to_mark_delivered(body):
client = MaxClient(settings(), transport=httpx.MockTransport(lambda r: httpx.Response(200, json=body)))
with pytest.raises(MaxApiError):
await client.send(100, "neutral", site_url="https://example.org", button="Open")
await client.close()
async def test_timeout_is_retryable_without_request_details():
def handler(request):
raise httpx.ReadTimeout("private URL", request=request)
client = MaxClient(settings(), transport=httpx.MockTransport(handler))
with pytest.raises(MaxApiError, match="^transport$") as error:
await client.request("GET", "/me")
assert error.value.retryable
await client.close()
async def test_subscription_includes_secret_and_exact_event_types():
def handler(request):
assert request.url.path == "/subscriptions"
body = json.loads(request.content)
assert body == {
"url": settings().max_webhook_url,
"update_types": UPDATE_TYPES,
"secret": settings().max_webhook_secret.get_secret_value(),
}
return httpx.Response(200, json={"success": True})
client = MaxClient(settings(), transport=httpx.MockTransport(handler))
await client.subscribe(settings())
await client.close()
+51
View File
@@ -0,0 +1,51 @@
"""Operator tools do not send messages or expose credentials."""
from unittest.mock import AsyncMock
import pytest
from app.channels import max_setup
from app.channels.max_client import MaxApiError
from app.config import Settings
@pytest.fixture
def client(monkeypatch):
settings = Settings(_env_file=None, max_bot_username="test_bot", max_webhook_url="https://api.example.org/hook")
sender = AsyncMock()
sender.request.return_value = {"is_bot": True, "username": "test_bot"}
monkeypatch.setattr(max_setup, "Settings", lambda **kwargs: settings)
monkeypatch.setattr(max_setup, "MaxClient", lambda cfg: sender)
return sender
async def test_check_only_reads_bot_identity(client):
await max_setup.run("check")
client.request.assert_awaited_once_with("GET", "/me")
client.subscribe.assert_not_called()
client.send.assert_not_called()
client.close.assert_awaited_once()
async def test_subscribe_is_explicit_and_checks_bot_first(client):
await max_setup.run("subscribe")
client.subscribe.assert_awaited_once()
client.send.assert_not_called()
async def test_mismatched_bot_does_not_change_webhooks(client):
client.request.return_value = {"is_bot": True, "username": "another_bot"}
with pytest.raises(MaxApiError, match="bot_username_mismatch"):
await max_setup.run("subscribe")
client.subscribe.assert_not_called()
client.close.assert_awaited_once()
async def test_status_checks_exact_configured_webhook(client):
client.request.side_effect = [
{"is_bot": True, "username": "test_bot"},
{"subscriptions": [{"url": "https://elsewhere.example.org"}]},
]
with pytest.raises(MaxApiError, match="webhook_missing"):
await max_setup.run("status")
client.subscribe.assert_not_called()
+8 -3
View File
@@ -172,12 +172,16 @@ def test_unapproved_policy_never_continues_even_a_negative_screen():
def test_immediate_safety_preempts_unfinished_or_invalid_unrelated_scales():
for overrides in [
dict(help_now=True, cssrs={"broken": "data"}),
dict(phq9={"phq9_9": 1}),
dict(phq9={"phq9_9": 1}, cssrs={}), # item 9 before the screener is complete
dict(cssrs={"q4": 1}, alcohol={"bad": 50}),
]:
assert intake(policy=load_policy(), **overrides).status == "crisis"
def test_item9_with_a_negative_screener_is_a_discrepancy_flag_not_a_crisis():
# One crisis rule (ADR-013): the crisis screen opens at a high C-SSRS risk only.
r = intake(phq9={"phq9_9": 1})
assert "phq9_cssrs_discrepancy" in r.flags
assert r.status != "crisis" and {"phq9_item9_positive", "phq9_cssrs_discrepancy"} <= set(r.flags)
def test_physician_must_choose_between_stop_and_graded():
@@ -208,7 +212,8 @@ def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative():
(30, "self_help"),
(31, "psychiatrist_or_psychotherapist"),
(49, "psychiatrist_or_psychotherapist"),
(50, "priority_psychiatrist"),
(50, "psychiatrist_or_psychotherapist"),
(80, "psychiatrist_or_psychotherapist"),
],
)
def test_pdf_pcl_boundaries_are_distinct_from_legacy_33(total, route):
+6 -3
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),
@@ -155,7 +156,9 @@ def test_severe_totals(scale: str, total: int, code: str, fires: bool) -> None:
acts = evaluate(s)
assert (code in codes(acts)) is fires
if fires:
assert next(a for a in acts if a.code == code).level == AlertLevel.HIGH
# PCL-5 alone is a planned specialist review, not a priority (clinical review 2026-10-04).
expected = AlertLevel.MODERATE if scale == "pcl5" else AlertLevel.HIGH
assert next(a for a in acts if a.code == code).level == expected
@pytest.mark.parametrize(
+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]
+88
View File
@@ -0,0 +1,88 @@
"""Garden (#118): deterministic growth from activities, daily caps, never shrinks, ignores answers."""
from collections.abc import AsyncIterator
from datetime import date, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.config import Settings
from app.core import clock
from app.db import session
from app.db.clinical import DailyCheckin, Dose
from app.main import app
from app.services.garden import compute, load_config
from scripts.seed_demo import seed
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 5, 14, 0)
D = date(2026, 10, 5)
def test_points_steps_and_daily_caps() -> None:
cfg = load_config()
one_diary = compute([("diary", D)], D)
assert one_diary.points == cfg.activities["diary"].points
# Five diary entries on one day count as one; on five days — five times.
assert compute([("diary", D)] * 5, D).points == one_diary.points
five_days = compute([("diary", D - timedelta(days=i)) for i in range(5)], D)
assert five_days.points == 5 * one_diary.points
assert five_days.steps == five_days.points // cfg.step_points
assert five_days.points_in_step == five_days.points % cfg.step_points
def test_never_shrinks_and_future_ignored() -> None:
events = [("meds", D), ("diary", D), ("questionnaire", D)]
now = compute(events, D)
# A week without any activity takes nothing away.
assert compute(events, D + timedelta(days=7)).points == now.points
# Rows dated after the (demo) today are not counted yet.
assert compute([*events, ("diary", D + timedelta(days=1))], D).points == now.points
def test_plants_grow_one_after_another() -> None:
cfg = load_config()
per_plant = cfg.stages_per_plant
g = compute([("questionnaire", D - timedelta(days=i)) for i in range(100)], D)
stages = [p.stage for p in g.plants]
assert stages == sorted(stages, reverse=True) and stages[0] == per_plant
assert sum(stages) == min(g.steps, per_plant * cfg.max_plants)
assert compute([], D).title == cfg.titles[0].title
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def test_andrey_has_a_living_garden_that_grows_and_ignores_mood(client: AsyncClient) -> None:
token = (await seed(12)).andrey_token
url = f"/api/v1/me/{token}/garden"
before = (await client.get(url)).json()
assert before["steps"] >= 4 and before["plants"][0]["stage"] > 0
# Mood is an answer: a bad day and a good day grow the garden the same way.
async with session.get_clinical_session() as s:
rows = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for r in rows:
r.mood = 1
assert (await client.get(url)).json()["points"] == before["points"]
# Taking a dose today adds points.
async with session.get_clinical_session() as s:
dose = Dose(token=token, prescription_id=1, planned_at=clock.now(), status="taken", answered_at=clock.now())
s.add(dose)
after = (await client.get(url)).json()
assert after["points"] >= before["points"]
assert any(i["activity"] == "diary" for i in after["today"])
async def test_unknown_patient_404(client: AsyncClient) -> None:
await seed(0)
assert (await client.get("/api/v1/me/nobody/garden")).status_code == 404
+5 -5
View File
@@ -178,6 +178,7 @@ async def test_andrey_13_days(db: MemoryNotifier) -> None:
"sleep_lt4_3n",
"alcohol_3d",
"phq9_item9", # same evaluation: YAML order
"post_crisis_call", # the crisis leaves a callback task for the doctor on duty
"phq9_rise",
]
assert len(crisis_audit) == 1 and crisis_audit[0].token == TOKEN and crisis_audit[0].actor == "system"
@@ -222,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:
@@ -260,7 +260,7 @@ async def test_notifier_failure_keeps_alert(db: MemoryNotifier, monkeypatch: pyt
await scale("pcl5", 55)
await monitoring.RuleMonitoring().after_data_change(TOKEN)
async with session.get_clinical_session() as s:
assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "high")]
assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "moderate")]
assert await color() == "yellow"
+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

Some files were not shown because too many files have changed in this diff Show More