Compare commits

..
Author SHA1 Message Date
ElebobaandClaude Opus 5.5 f19ea33937 feat: SaluteSpeech STT for the pilot + no foreign STT outside the demo (ADR-016)
CI / ruff · pytest (pull_request) Successful in 2m16s
Refs #121

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:11:44 +03:00
Eleboba b70911e182 Merge pull request 'feat: сад — правки по вычитке (честная отметка, тишина после кризиса, тексты)' (#126) from feat/120-garden-review into dev
CD / ruff · pytest (push) Successful in 2m17s
CI / ruff · pytest (push) Successful in 2m28s
CD / deploy dev (push) Successful in 1m10s
2026-10-31 15:50:11 +03:00
ElebobaandClaude Opus 5.5 e2e1283d8e feat: garden review fixes — honest dose answer grows it, quiet after crisis, no-promise texts (ADR-015)
CI / ruff · pytest (pull_request) Successful in 2m21s
Refs #120

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:47:34 +03:00
Eleboba 78312f5076 Merge pull request 'feat(web): сад на экране «Сегодня»' (#125) from feat/119-garden-web into dev
CI / ruff · pytest (push) Successful in 2m23s
CD / ruff · pytest (push) Successful in 2m24s
CD / deploy dev (push) Successful in 1m21s
2026-10-31 12:02:08 +03:00
ElebobaandClaude Opus 5.5 8bf56291c9 feat(web): garden on the «Сегодня» screen — stages, progress, today's checklist
CI / ruff · pytest (pull_request) Successful in 2m34s
SVG garden drawn from GET /me/{token}/garden: plants grow one after another
(sprout → stem → bud → flower), «Новый росток N из 10», today's activities,
«Пропуск — не страшно, сад не вянет». A gentle grow-in and a toast when the
garden grew since the last visit — but not right after a crisis reply.
No streaks, no «missed» marks, no red.

Refs #119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:57:51 +03:00
Eleboba fd9df40c28 Merge pull request 'feat(web): голосовой ввод — запись, проверка текста, отправка' (#124) from feat/117-voice-web into dev
CI / ruff · pytest (push) Successful in 2m37s
CD / ruff · pytest (push) Successful in 2m26s
CD / deploy dev (push) Successful in 1m35s
2026-10-31 11:54:45 +03:00
ElebobaandClaude Opus 5.5 e20da1f21c feat(web): voice input — record, check the recognised text, send
CI / ruff · pytest (pull_request) Successful in 2m24s
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:45: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
Pavel Eliseev 461c3d02ea fix(backend): redact retryable MAX storage failures
CI / ruff · pytest (pull_request) Successful in 2m4s
Refs #82
2026-10-04 16:04:38 +03:00
Pavel Eliseev 64655ad934 feat(backend): add durable MAX linking and notification queue
Refs #82
2026-10-04 16:04:38 +03:00
Soyavo db3072d759 Merge pull request '[contract] Add opt-in MAX configuration and lifecycle' (#84) from feat/81-max-contract into dev
CI / ruff · pytest (push) Successful in 2m4s
2026-10-04 16:02:52 +03:00
Pavel Eliseev b3e103954f feat(contract): add opt-in MAX configuration and lifecycle
CI / ruff · pytest (pull_request) Successful in 2m24s
Refs #81
2026-10-04 15:58:25 +03:00
Eleboba bde6ec6855 Merge pull request 'chore(devops): деплой на сервер + GigaChat для чат-агента + без 500 при ошибках провайдера' (#80) from chore/server-deploy into dev
CI / ruff · pytest (push) Successful in 2m13s
2026-10-04 15:48:56 +03:00
ElebobaandClaude Opus 5.5 ade93d959b feat(bot): GigaChat function-calling provider for the chat agent; never 500 on provider errors
CI / ruff · pytest (pull_request) Successful in 2m15s
OpenRouter returns 403 «Access denied by security policy» from the RF server, and parsing that string error
crashed the request (500 instead of the fallback answer). Error parsing is robust now and any agent exception
degrades to the extractive answer. GigaChat tool provider (GigaChat-2-Pro by default) converts the agent's
OpenAI-style history to function_call / role=function; auto mode prefers GigaChat when credentials are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:46:21 +03:00
ElebobaandClaude Opus 5.5 bc044650ac chore(devops): deploy script tolerates offline fetch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:27:09 +03:00
ElebobaandClaude Opus 5.5 473b884ed8 chore(devops): server deploy next to vobot — compose override, nginx vhost, deploy script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:27:09 +03:00
Arxip222 b94291e541 Merge pull request 'feat(web): приложение пациента — двухэтапные опросы, результат скрининга, дневник по результату' (#79) from feat/rag-clinical-guidelines into dev
CI / ruff · pytest (push) Successful in 2m2s
2026-10-04 15:25:16 +03:00
Eleboba ebddcae61a Merge pull request 'fix(bot): чат-агент — доработки по живому прогону' (#78) from fix/chat-agent-live into dev
CI / ruff · pytest (push) Successful in 1m59s
2026-10-04 15:22:42 +03:00
Arxip222andClaude Opus 5.5 0106aefc33 Merge origin/dev into feat/rag-clinical-guidelines
CI / ruff · pytest (pull_request) Successful in 2m9s
Conflict in app/api/routes.py: /demo/personas keeps the dev version (demo console, DB patients
with scenarios); the patient app gets its own /patient/demo-personas (synthetic ЕМИАС accounts).
Anonymous sign-in now always creates a doctor-less pseudonymous profile, since the dialog API
of #12 serves known profiles only.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:22:29 +03:00
ElebobaandClaude Opus 5.5 83ad5d8b33 fix(bot): chat agent — ГРЛС excerpts in drug check, questions with alcohol answered, retry, clearer logs
CI / ruff · pytest (pull_request) Successful in 2m13s
Live run through the API (Andrey, real OpenRouter): onset questions now answered from the instruction
excerpt, «можно пиво?» reaches the agent although the check-in records alcohol, quit intent keeps the single
rule reaction, findings are mentioned only for intake/combination questions, one retry on a provider error,
provider error message logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:20:18 +03:00
Arxip222andClaude Opus 5.5 290b0d6151 feat(web): two-stage questionnaires, screening result and diary by result
- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
  PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
  is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
  with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
  3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
  flags on weekly means against the personal norm; scheduled full scales with change
  (remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
  goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).

ADR-012.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:14:09 +03:00
Arxip222andClaude Opus 5.5 6e7c89900b fix(infra): check out shell scripts with LF
On Windows git converted infra/postgres/init.sh to CRLF; Postgres then failed to run it
(/bin/bash^M: bad interpreter), so the identity/clinical databases and roles were never
created and every API call returned 500.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:14:08 +03:00
Eleboba 37f119610d Merge pull request 'test: без живых вызовов LLM в тестах' (#76) from fix/tests-no-live-llm into dev
CI / ruff · pytest (push) Successful in 1m53s
2026-10-04 15:08:23 +03:00
ElebobaandClaude Opus 5.5 05b8bc3e7e test: never call a live LLM from tests, whatever the local .env says
CI / ruff · pytest (pull_request) Successful in 1m50s
With OPENROUTER_API_KEY in a developer's .env the chat agent switched on in dialog tests (real API calls,
spent money, flaky results). An autouse fixture turns the agent off; agent tests pass explicit providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:06:16 +03:00
Eleboba aac560ad71 Merge pull request 'feat(dashboard): минималистичная главная — утверждённый дизайн 02' (#74) from feat/73-home-design into dev
CI / ruff · pytest (push) Successful in 1m58s
Reviewed-on: #74
2026-10-04 15:04:10 +03:00
Eleboba 473965ae76 Merge pull request 'feat(bot): чат-агент на Gemini 2.5 Flash-Lite + доработки по живым прогонам' (#75) from feat/chat-agent-model into dev
CI / ruff · pytest (push) Successful in 1m59s
2026-10-04 15:02:07 +03:00
ElebobaandClaude Opus 5.5 9ec61b9a03 feat(bot): chat agent on Gemini 2.5 Flash-Lite — grounded first turn, current-prescription defaults
CI / ruff · pytest (pull_request) Successful in 2m5s
Live-tested on OpenRouter: Flash-Lite answers in 3–8 s with correct findings at ~$0.10/$0.40 per 1M tokens
(qwen3.7-flash and gpt-oss-120b took 26–32 s). Force a tool call on the first turn, give the model the
current prescription, let check_drug_for_patient default to it and return composition, intolerance check
and general interactions (alcohol); retry once on an empty final message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:59:38 +03:00
Pavel Eliseev c01bfc90df feat(dashboard): replace home with approved minimal design
CI / ruff · pytest (pull_request) Successful in 2m1s
Refs #73
2026-10-04 14:54:14 +03:00
Eleboba a2b1b2e7a5 Merge pull request 'feat(bot): чат-агент — RAG + карта ЕМИАС + проверка препаратов' (#72) from feat/chat-agent into dev
CI / ruff · pytest (push) Successful in 2m4s
2026-10-04 14:41:55 +03:00
ElebobaandClaude Opus 5.5 6dda5f9b30 feat(bot): tool-using chat agent over RAG, ЕМИАС record and drug-safety reference
CI / ruff · pytest (pull_request) Successful in 2m10s
Free-text treatment and medication questions go to an LLM agent with read-only tools bound to the current
patient: guideline search (RAG, doses stripped), ЕМИАС record mock (diagnoses, comorbidities, intolerances,
other specialists' drugs, current prescription), drug info (composition, contraindications, interactions)
and a deterministic patient-specific check. Providers: OpenRouter (temporary, synthetic data only) and an
offline mock; GigaChat to follow. Scrubbed input only, forbidden-output filter, any failure falls back to
the existing extractive RAG / instruction answers. Off unless OPENROUTER_API_KEY or CHAT_AGENT=mock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:39:16 +03:00
Eleboba 2f1f8a8f24 Merge pull request 'feat(dashboard): кабинет врача — красные зоны, карточка, живые алерты, подключение пациента' (#70) from feat/16-doctor-dashboard into dev
CI / ruff · pytest (push) Successful in 2m2s
2026-10-04 12:55:23 +03:00
Eleboba 497fb551f5 Merge pull request 'feat(dashboard): демо-консоль, проигрывание сценария, сплит-экран' (#69) from feat/17-demo-console into dev
CI / ruff · pytest (push) Successful in 2m18s
2026-10-04 12:53:14 +03:00
Eleboba 8e62722ec0 Merge pull request 'feat(backend): seed демо — Андрей (дни 0–13) и 7 пациентов кабинета' (#68) from feat/18-seed into dev
CI / ruff · pytest (push) Successful in 1m50s
2026-10-04 12:53:11 +03:00
ElebobaandClaude Opus 5.5 2582c73d12 feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s
- Patient list sorted red -> yellow -> grey -> green with KPI row, trend arrows,
  7-day adherence bars, discontinuation-risk badge with factor tooltip.
- Patient card: scales with severity bands from config/scales (unscheduled marked),
  mood/sleep/alcohol small multiples, dose heat-strip, alerts feed with ack,
  safety plan, AI summary with disclaimer.
- Live alerts: st.fragment(run_every=2.5s) polling, toast + pinned acute banner on
  every page; full rerun on new alert only on doctor pages.
- Connect form -> POST /patients/connect -> code, QR, deep link.
- Thin httpx ApiClient over the frozen schemas with graceful "API недоступен".
- app.py discovers dashboard/pages/*.py automatically (st.navigation); demo pages
  of #17 plug in by adding a file.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:51:07 +03:00
ElebobaandClaude Opus 5.5 8d12aa3d08 feat(dashboard): console fallbacks for the real API (#12)
CI / ruff · pytest (pull_request) Successful in 1m37s
- personas degrade: /demo/personas → doctor's patients (hero first) → env token
- scenario file guessed from the transliterated first name (andrey.jsonl), like the backend
- «Подключить демо-пациента»: connects the synthetic hero via /patients/connect when no seed
- connect code prefilled from ?code= of the console link

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:50:42 +03:00
ElebobaandClaude Opus 5.5 bc91873a13 feat(backend): demo seed — veteran Andrey (day 0..13) and 7 cabinet patients
CI / ruff · pytest (pull_request) Successful in 1m44s
- scripts/seed_demo.py: doctor doc-smirnova (on call), Andrey day 0 (connect code,
  no consent) or day N with history; 7 patients with seeded-random histories.
  Alerts and colours come from the real rules evaluated day by day.
- --reset drops/recreates both DBs and zeroes the demo clock; seed keeps the
  persisted clock offset so dates match the running app.
- data/synthetic/andrey.jsonl: console scenario for days 0..13 generated from
  config button labels (--write-scenario).
- tests: idempotency, modes, colours, scenario format/sync and a full replay of
  the scenario through the real engine, scheduler and monitoring.

Refs #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:50:30 +03:00
ElebobaandClaude Opus 5.5 260422138a feat(dashboard): demo console with web chat, demo clock, scenario playback and split screen
- pages/2_Демо-консоль.py: persona selector, virtual clock bar (+1 h, to 08:00/20:00,
  next day, reset with confirm), fallback patient web chat (buttons from Reply.buttons,
  kind styling, outbox drained after every action and on a 2 s fragment poll), scenario panel
- pages/3_Сплит-экран.py: patient chat or PATIENT_WEB_URL iframe on the left, compact doctor
  panel (patients, live alerts, red banner on a new acute alert) on the right
- console/playback.py: data/synthetic/<persona>.jsonl runner (advance_to/press/text/
  expect_color/start), date chaining between days, pause on failure with retry/skip/manual
- console/api.py: own HTTP client over the frozen API contract, ApiError only

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:46:14 +03:00
Eleboba 178cab5b1b Merge pull request 'feat(integrations): адаптеры МИС, код подключения, выдержки ГРЛС, уведомления врача' (#67) from feat/14-integrations into dev
CI / ruff · pytest (push) Successful in 1m29s
2026-10-04 12:38:18 +03:00
Eleboba c4eab1ef66 Merge pull request 'feat(backend): API этапа 1 на реальных данных (#12)' (#66) from feat/12-api into dev
CI / ruff · pytest (push) Successful in 1m26s
2026-10-04 12:38:14 +03:00
ElebobaandClaude Opus 5.5 9362eb6ecc feat(integrations): demo adapters for МИС, connect-code identity, ГРЛС excerpts, doctor notifier
CI / ruff · pytest (pull_request) Successful in 1m26s
- setup() at package import (idempotent, replaces only base.py defaults): MisMock, ConnectCodeIdentity,
  GrlsExcerpts, CompositeNotifier; outbox stays MemoryOutbox
- MisMock: clinical DB prescriptions, fallback data/meds/mis_prescriptions.json (synthetic, by token)
- ConnectCodeIdentity: one-time connect code via identity_repo, 15 min grace for the double check in one
  connect flow (Telegram /start + engine `new` state)
- GrlsExcerpts: synthetic paraphrased instruction excerpts for sertraline, paroxetine, escitalopram,
  fluoxetine, mirtazapine, prazosin
- CompositeNotifier: in-memory feed (recent(since_id)) + Telegram doctor chat, PII-free, never raises
- test_llm_med_qa: default-registry test now patches NoDrugInfo explicitly

Refs #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:36:07 +03:00
ElebobaandClaude Opus 5.5 25728c4b31 feat(backend): API stage 1 on real data instead of fixtures
CI / ruff · pytest (pull_request) Successful in 1m25s
Doctor list/card/alerts/ack/connect, dialog events/outbox/history and demo
controls now read the identity and clinical DBs. Rule-based discontinuation
risk index, trend, adherence; every doctor read is audited.

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:34:40 +03:00
Eleboba 53818ac76c Merge pull request 'feat(bot): ответы на вопросы о лечении из базы знаний КР (#64)' (#65) from feat/64-rag-dialog into dev
CI / ruff · pytest (push) Successful in 1m32s
2026-10-04 11:29:49 +03:00
ElebobaandClaude Opus 5.5 b72af71e66 feat(bot): answer treatment questions from the clinical-guideline knowledge base
CI / ruff · pytest (pull_request) Successful in 1m30s
- menu button «Вопрос о лечении» -> kb.question -> rag.answer (KR quote + source)
  or «уточните у врача»; dose questions get a fixed doctor redirect
- free text in idle that looks like a question (no check-in signals, not a drug
  question) -> knowledge base; no answer -> the usual support phrase
- routing order unchanged: consent/safety first, side effects/quit/alcohol -> check-in,
  own drug -> med_qa (instruction only, SPEC §7 p.5)
- question cues, small talk and texts in config/texts/knowledge.yaml; scrub placeholders
  before "что такое X" no longer hide the definition question

Refs #64

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:27:42 +03:00
Eleboba 7272203178 Merge pull request 'fix(llm): RAG — страницы в цитатах, метки scrub, выбор КР по заболеванию (#62)' (#63) from fix/62-rag-fixes into dev
CI / ruff · pytest (push) Successful in 1m21s
2026-10-04 11:14:20 +03:00
Arxip222andClaude Opus 5.5 0195468edc docs(product): mockups brief and the design mockups of the patient app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:12:05 +03:00
Arxip222andClaude Opus 5.5 464b474abd feat(web): questionnaires, diary and medication module in the patient app
- Focus screen renders versioned questionnaire items (iq: buttons): section chip, position
  (adaptive forms without "of N"), recall date, pause / help-now actions, optional skip,
  sleep-hours input, 0-10 scale anchors, RUS-AUDIT item 2 standard-drink table.
- Paused / held-for-review questionnaires get their own screen.
- Today: intake and diary tasks, review notice, therapy-day card on days 1/7/14.
- Drug card /meds/:ref from data/meds/meds_reference.json (draft built from the Minzdrav
  guidelines: depression 2024, PTSD 2023) — expected effect, course, side effects, red flags;
  no doses shown. "Report a side effect" always available (med:side_effects:<ref>).
- API: /instruments/catalog, /meds/reference; today gains questionnaire state, reference id,
  indication and therapy day; demo persona seeds the dashboard dose history.
- INTAKE_DEMO=1 (demo clock only) opens the questionnaires on synthetic data without touching
  the clinical release gate in config/intake_runtime.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:53:13 +03:00
Arxip222andClaude Opus 5.5 40c242bb1a feat(web): full desktop layout instead of a stretched phone view
- focus screens: question and context on the left, answers on the right in
  one wide card; consent text and the 0–10 scale in one wide column
- today: dashboard with tasks, this week's doses and quick actions side by
  side; meds / doctor / profile in two-column grids; welcome gets «Как это
  работает» steps
- fix: 0–10 scale no longer overflows narrow containers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:25:17 +03:00
Arxip222andClaude Opus 5.5 f233c1e5ca fix(web): proxy the dev API to 127.0.0.1 (uvicorn listens on IPv4 only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:18:26 +03:00
Arxip222andClaude Opus 5.5 b4b1bc51c8 feat(web): screens from docs/mock — focus flow, today, meds, doctor, profile
- /flow renders the dialog engine's current step as a mock screen (consent,
  age, onboarding and scales with progress, mood 0–10, free text, dose
  reminder, crisis with hotlines and breathing, safety plan), so every
  engine scenario gets the design without duplicating its logic
- today / meds / doctor / profile / messages tabs; navy sidebar and
  emergency rail on desktop, emergency block and tab bar on phones
- API: GET /me/{token}/today (today's doses, week marks, next visit, demo
  clock time); demo ЕМИАС sign-in seeds the persona's synthetic profile and
  prescription from the dashboard fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:11:45 +03:00
Arxip222andClaude Opus 5.5 b8a93ce9d4 feat(web): patient web app with ЕМИАС (demo) and anonymous sign-in
A second channel to the same dialog engine as the Telegram bot: consent,
onboarding, scales and check-ins come from app/dialog; the bot is unchanged.

- API: /auth/emias (doctor's one-time connect code via EmiasMock),
  /auth/emias/demo (synthetic personas), /auth/anonymous (random token,
  nothing stored in the identity DB), /crisis/resources from crisis.yaml
- web/: React + TS + Vite, mobile-first, safe areas, hash routing,
  tokens and texts isolated for a Capacitor mobile build

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:57:00 +03:00
Arxip222andClaude Opus 5.5 4d331e4c05 docs(rag): describe RAG approach for Minzdrav clinical guidelines
Structure-aware units with audience/evidence metadata, manual review,
hybrid BM25 search, citation-checked answers and an eval set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:57:00 +03:00
ElebobaandClaude Opus 5.5 fe29ebd629 fix(llm): RAG citation pages, scrub placeholders, disease-aware document choice
CI / ruff · pytest (pull_request) Successful in 1m2s
- converter marks a paragraph glued across a page break with ⟦стр. N⟧; the chunker
  strips the marker and extends the chunk's page range (wrong-page citations 24 -> 3,
  the rest are scale-form table rows split by a page)
- [NAME]/[PHONE] placeholders from privacy.scrub() no longer count as unknown words
  (a scrubbed question used to fall below the coverage gate)
- a question naming one disease boosts its guideline in retrieval and passage choice
  ("ТМС при депрессии" was answered from the PTSD guideline); "антидепрессант" is not a cue

Refs #62

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:31:59 +03:00
Eleboba 6c212d560c Merge pull request 'feat(llm): RAG — база знаний по КР с цитатами (#60)' (#61) from feat/60-rag into dev
CI / ruff · pytest (push) Successful in 1m9s
2026-10-04 04:14:52 +03:00
ElebobaandClaude Opus 5.5 6a77831603 feat(llm): RAG knowledge base over clinical guidelines with cited extractive answers
CI / ruff · pytest (pull_request) Successful in 1m30s
- docs/source/rag: PDF sources (KR PTSD 2023, KR depression 2024, meds module spec),
  pdf->txt converter (tables as markdown, page markers, hand-transcribed figures),
  word-level check against pdftotext; fixes HAM-D/MADRS fake headers and split rows
- config/rag: corpus txt + sources.yaml (documents, audiences, retrieval params, synonyms)
- app/llm/rag: Snowball stemmer, section-aware chunker with page citations,
  BM25 + synonym expansion + densest-passage reranking, extractive answer()
  with coverage gate, patient dose/prescribing filter, optional LLM rephrasing
  rejected when it adds content; CLI python -m app.llm.rag
- tests: gold retrieval set (hit@3 >= 15/18), off-topic, safety, faithfulness
- ADR-011

Refs #60

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:11:06 +03:00
Eleboba 66986255e2 Merge pull request 'feat(backend): шедулер на демо-часах — дозы, missed, чек-ины, шкалы, молчание' (#59) from feat/13-scheduler into dev
CI / ruff · pytest (push) Successful in 1m9s
Reviewed-on: #59
2026-10-04 03:29:25 +03:00
ElebobaandClaude Opus 5.5 251f946691 feat(backend): demo scheduler on the virtual clock — doses, missed, check-ins, scales, silence
CI / ruff · pytest (pull_request) Successful in 1m3s
- app/scheduler/demo.py: run_until(now) replays due and fresh events in time order: dose rows +
  med_reminder, missed after 2 h without an answer → monitoring, daily_checkin, scale_due by the
  config/modules.yaml schedules, daily monitoring pass (silence, missed doses) → reach_out/scale_due
- idempotent via scheduler_events (unique token+key); stale events are not sent; no contact without
  consent or during an open crisis
- config/scheduler.yaml: times and windows; runs while the questionnaire release is off
- POST /demo/clock/advance replays the scheduler up to the new time

Refs #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:25:44 +03:00
Eleboba 8c620419dd Merge pull request 'feat(backend): сервис мониторинга — снимок, правила, алерты, цвет, персистентность часов' (#58) from feat/38-monitoring into dev
CI / ruff · pytest (push) Successful in 1m6s
Reviewed-on: #58
2026-10-04 03:15:56 +03:00
250 changed files with 36788 additions and 542 deletions
+30
View File
@@ -6,6 +6,21 @@ GIGACHAT_CREDENTIALS=
GIGACHAT_SCOPE=GIGACHAT_API_PERS
GIGACHAT_MODEL=GigaChat
# --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only.
CHAT_AGENT=auto
OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.5-flash-lite
# --- Voice input (speech-to-text): auto | off | mock | openrouter | salute.
# OpenRouter = synthetic data only (ADR-014), the app refuses to start with it when DEMO_CLOCK=false.
# Pilot / real patients: salute (SaluteSpeech, RF contour, ADR-016). auto picks salute when credentials are set.
STT_PROVIDER=auto
STT_MODEL=google/gemini-2.5-flash
SALUTE_SPEECH_CREDENTIALS=
SALUTE_SPEECH_SCOPE=SALUTE_SPEECH_PERS
# Path to the Russian Trusted Root CA (Минцифры) — not in certifi, needed to verify Sber's TLS.
SALUTE_SPEECH_CA=
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY=
@@ -18,10 +33,15 @@ 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)
DEMO_CLOCK=true
# Questionnaires of the intake (#53) on synthetic data, demo only (needs DEMO_CLOCK). The clinical release gate in
# config/intake_runtime.yaml stays closed; without a signed routing policy every finished intake goes to review.
INTAKE_DEMO=false
TZ=Europe/Moscow
# --- Gitea (for Claude Code / team workflow, not used by the app)
@@ -29,3 +49,13 @@ GITEA_URL=https://gitea.24studbank.ru
GITEA_REPO=MedITron/PTSRaiAgent
GITEA_USER=
GITEA_TOKEN=
# MAX notifications: keep disabled until the backend/channel PRs and a real bot are installed.
MAX_ENABLED=false
MAX_BOT_TOKEN=
MAX_BOT_USERNAME=
MAX_WEBHOOK_SECRET=
MAX_WEBHOOK_URL=
MAX_SITE_URL=
# Optional path to a trusted PEM CA bundle; never disable TLS verification.
MAX_CA_BUNDLE=
MAX_LINK_TTL_SECONDS=600
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts run inside Linux containers: never check them out with CRLF (bash fails on the carriage return).
*.sh text eol=lf
+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
+18
View File
@@ -0,0 +1,18 @@
# Dashboard-only Streamlit settings (doctor dashboard + demo console).
[theme]
base = "light"
primaryColor = "#285E4E"
backgroundColor = "#F6F7F3"
secondaryBackgroundColor = "#FFFFFF"
textColor = "#213B38"
borderColor = "#E1E7E3"
baseRadius = "medium"
# Falls back to the system sans-serif when offline (ADR-004).
font = "sans-serif"
headingFontWeights = 600
[client]
toolbarMode = "minimal"
[browser]
gatherUsageStats = false
+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).
+89
View File
@@ -0,0 +1,89 @@
"""Demo-console helpers (#12, ADR-005): scripted personas, reset, connect links, outbox access.
Synthetic data only. Scenario files live in `data/synthetic/<slug>.jsonl` (#18); a patient is a scripted persona
when the transliterated first word of the display name equals the slug ("Андрей С." → `andrey.jsonl`).
"""
import inspect
import logging
from importlib import import_module, util
from pathlib import Path
from app.config import get_settings
from app.integrations.base import MemoryOutbox, Outbox, integrations
log = logging.getLogger(__name__)
SEED_MODULE = "scripts.seed_demo"
CONSOLE_PATH = "/console"
_TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i",
"й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t",
"у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "",
"э": "e", "ю": "yu", "я": "ya",
} # fmt: skip
def persona_slug(display_name: str) -> str:
first = (display_name.split() or [""])[0].lower()
return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first)
def scenario_files(data_dir: Path | None = None) -> dict[str, str]:
"""slug → scenario path relative to the project root, e.g. {"andrey": "data/synthetic/andrey.jsonl"}."""
root = data_dir or get_settings().data_dir
folder = root / "synthetic"
if not folder.is_dir():
return {}
return {f.stem: f"data/synthetic/{f.name}" for f in sorted(folder.glob("*.jsonl"))}
async def bot_username() -> str | None:
"""Username of the running Telegram bot (deep links); None when the bot is not started (no TG_BOT_TOKEN)."""
from app.channels import telegram
bot = getattr(telegram, "_bot", None)
if bot is None:
return None
try:
me = await bot.me() # aiogram caches getMe
except Exception as exc: # noqa: BLE001 — network / auth errors: fall back to the web console link
log.warning("bot username unavailable error=%s", type(exc).__name__)
return None
return me.username or None
async def deep_link(code: str) -> str:
username = await bot_username()
return f"https://t.me/{username}?start={code}" if username else f"{CONSOLE_PATH}?code={code}"
def memory_outbox() -> MemoryOutbox | None:
"""The in-memory outbox polled by the web console, also when wrapped (Telegram keeps it as `fallback`)."""
box: Outbox | None = integrations.outbox
for _ in range(5):
if isinstance(box, MemoryOutbox):
return box
box = getattr(box, "fallback", None)
return None
async def reset_demo() -> bool:
"""Clock reset is done by the caller; re-seed via `scripts.seed_demo.reset()` (#18) when it exists."""
box = memory_outbox()
if box is not None:
box._q.clear()
try:
spec = util.find_spec(SEED_MODULE)
except ModuleNotFoundError: # no `scripts` package on the path (e.g. installed app without scripts/)
spec = None
if spec is None:
return False
reset = getattr(import_module(SEED_MODULE), "reset", None)
if reset is None:
return False
result = reset()
if inspect.isawaitable(result):
await result
return True
+378
View File
@@ -0,0 +1,378 @@
"""Doctor dashboard read models (#12): clinical DB rows → frozen API schemas (`app/api/schemas.py`).
Everything shown to the doctor is derived from stored structured data — no LLM decides colours, trends or the
discontinuation risk (ADR-007). Time comes in as `now` (from `app.core.clock`, ADR-005).
Identity data (guardrail #5): the API is the doctor's display boundary, so it reads exactly two identity fields —
`display_name` (minimal identification for the treating doctor) and `birth_year` (age). SNILS, phone and chat
id are never selected into a response model.
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any, Literal, cast, get_args
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas import (
AlertOut,
Color,
DiscontinuationRisk,
DoseOut,
LevelName,
Metric,
Module,
PatientCard,
PatientListItem,
SeriesPoint,
)
from app.core.rules import color, patient_status
from app.core.types import AlertLevel
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))
_METRICS = frozenset(get_args(Metric))
_SCALE_LABELS = {"phq9": "PHQ-9", "pcl5": "PCL-5", "gad7": "GAD-7", "auditc": "AUDIT-C", "dar5": "DAR-5"}
# Daily check-in buckets → numbers for charts. Sleep: bucket midpoints in hours ("<4" plotted as 3, ">8" as 9).
SLEEP_HOURS = {"<4": 3.0, "4-6": 5.0, "6-8": 7.0, ">8": 9.0}
# Alcohol: 0 = none, 1 = 1–2 drinks, 2 = more (same coding as app/llm/summary.py expects).
ALCOHOL_LEVEL = {"none": 0.0, "1-2": 1.0, "more": 2.0}
# Trend: the last two complete results of one scale. A change smaller than about half of the minimal clinically
# important difference (PHQ-9: 5 points, PCL-5: 10 points) is reported as "flat" so noise does not flip arrows.
TREND_MIN_CHANGE = {"phq9": 3, "pcl5": 5}
CARD_DAYS = 60 # how far back the card series / dose log reach
# --------------------------------------------------------------------------- discontinuation risk index
#
# Rule-based index (NOT a trained model) of the risk that the patient drops the therapy, 0–100, for sorting the
# list and for the "talk about adherence" hint. Each factor is a deterministic check over the clinical DB with a
# fixed weight; the score is the capped sum. Weights reflect the known predictors of early antidepressant
# discontinuation (non-adherence, side effects in the first weeks, expressed intent to stop, low mood, alcohol):
#
# factor weight label for the doctor
# missed/skipped doses on >= 3 of the last 7 days 30 "пропуски приёма N/7 дн."
# ... on 1–2 of the last 7 days 15 "пропуски приёма N/7 дн."
# side effects reported >= 3 days in a row 20 "побочные эффекты N дн. подряд"
# ... 1–2 days in a row 10 "побочные эффекты N дн. подряд"
# intent to stop medication voiced in last 14 days 25 "хотел бросить (день N)"
# open, unacknowledged quit_intent alert +15 "не разобран сигнал «хочет бросить»"
# mood <= 3 for >= 3 days in a row 15 "настроение ≤ 3 N дн. подряд"
# alcohol on >= 3 of the last 7 days 15 "алкоголь N дн. из 7"
# ... on 1–2 of the last 7 days 5 "алкоголь N дн. из 7"
#
# Level: >= 60 high, >= 30 medium, otherwise low. "Day" = calendar day in the clinic time zone; streaks end today
# if today's check-in exists, otherwise yesterday (same anchor as app.core.rules), and a day without a check-in
# breaks a streak — we never assume a symptom the patient did not report.
RISK_WINDOW_DAYS = 7
QUIT_WINDOW_DAYS = 14
MOOD_LOW = 3
W_MISSED_MANY, W_MISSED_SOME = 30, 15
W_SIDE_EFFECTS_LONG, W_SIDE_EFFECTS_SHORT = 20, 10
W_QUIT_INTENT, W_QUIT_ALERT_OPEN = 25, 15
W_MOOD_LOW = 15
W_ALCOHOL_MANY, W_ALCOHOL_SOME = 15, 5
STREAK_DAYS = 3
RISK_HIGH, RISK_MEDIUM = 60, 30
@dataclass
class PatientData:
"""Everything the list item and the card are computed from (one patient, loaded once)."""
profile: PatientProfile
checkins: Sequence[DailyCheckin]
results: Sequence[ScaleResult]
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:
token = profile.token
doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
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),
results=await repo.list_scale_results(session, token),
doses=doses,
prescriptions=rx,
alerts=await repo.list_alerts(session, token=token),
contact_requested_at=contact_at,
)
def _local(ts: datetime, now: datetime) -> date:
return ts.astimezone(now.tzinfo).date()
def _streak_days(checkins: Iterable[DailyCheckin], today: date, pred: Any) -> int:
"""Length of the run of consecutive check-in days satisfying `pred`, ending at the anchor day."""
by_day = {c.day: c for c in checkins if c.day <= today}
day = today if today in by_day else today - timedelta(days=1)
n = 0
while (c := by_day.get(day)) is not None and pred(c):
n += 1
day -= timedelta(days=1)
return n
def _therapy_day(profile: PatientProfile, day: date) -> int:
return (day - profile.therapy_start).days if profile.therapy_start else 0
def _missed_days(d: PatientData, now: datetime, first: date) -> set[date]:
# Same sources as rule missed_doses_7d: dose records and the check-in answer "did not take".
days = {_local(x.planned_at, now) for x in d.doses if x.status in ("missed", "skipped") and x.planned_at <= now}
days |= {c.day for c in d.checkins if c.med_taken is False}
return {x for x in days if first <= x <= now.date()}
def discontinuation_risk(d: PatientData, now: datetime) -> DiscontinuationRisk:
"""Deterministic discontinuation-risk index; see the weight table above."""
today = now.date()
first = today - timedelta(days=RISK_WINDOW_DAYS - 1)
score = 0
factors: list[str] = []
missed = len(_missed_days(d, now, first))
if missed:
score += W_MISSED_MANY if missed >= STREAK_DAYS else W_MISSED_SOME
factors.append(f"пропуски приёма {missed}/{RISK_WINDOW_DAYS} дн.")
side = _streak_days(d.checkins, today, lambda c: bool(c.side_effects))
if side:
score += W_SIDE_EFFECTS_LONG if side >= STREAK_DAYS else W_SIDE_EFFECTS_SHORT
factors.append(f"побочные эффекты {side} дн. подряд")
quit_since = today - timedelta(days=QUIT_WINDOW_DAYS - 1)
quit_days = [c.day for c in d.checkins if c.quit_intent and quit_since <= c.day <= today]
if quit_days:
score += W_QUIT_INTENT
factors.append(f"хотел бросить (день {_therapy_day(d.profile, max(quit_days))})")
if any(a.code == "quit_intent" and a.acked_at is None for a in d.alerts):
score += W_QUIT_ALERT_OPEN
factors.append("не разобран сигнал «хочет бросить»")
low = _streak_days(d.checkins, today, lambda c: c.mood is not None and c.mood <= MOOD_LOW)
if low >= STREAK_DAYS:
score += W_MOOD_LOW
factors.append(f"настроение ≤ {MOOD_LOW} {low} дн. подряд")
drinking = sum(1 for c in d.checkins if first <= c.day <= today and c.alcohol_bucket not in (None, "none"))
if drinking:
score += W_ALCOHOL_MANY if drinking >= STREAK_DAYS else W_ALCOHOL_SOME
factors.append(f"алкоголь {drinking} дн. из {RISK_WINDOW_DAYS}")
score = min(score, 100)
level: Literal["low", "medium", "high"] = (
"high" if score >= RISK_HIGH else "medium" if score >= RISK_MEDIUM else "low"
)
return DiscontinuationRisk(score=score, level=level, factors=factors)
# --------------------------------------------------------------------------- list item
def _complete(d: PatientData, scale: str) -> list[ScaleResult]:
return [r for r in d.results if r.scale == scale and r.complete] # repo returns them oldest first
def trend(d: PatientData) -> Literal["up", "down", "flat", "unknown"]:
"""Direction of symptom scores (up = worse) from the scale with the most recent pair of results."""
pairs = [(s, rs[-2:]) for s in TREND_MIN_CHANGE if len(rs := _complete(d, s)) >= 2]
if not pairs:
return "unknown"
scale, (prev, last) = max(pairs, key=lambda p: p[1][-1].ts)
delta = last.total - prev.total
if abs(delta) < TREND_MIN_CHANGE[scale]:
return "flat"
return "up" if delta > 0 else "down"
def adherence_7d(d: PatientData, now: datetime) -> float | None:
"""Taken / planned doses of the last 7 days; doses still `pending` (not resolved yet) are not counted."""
since = now - timedelta(days=RISK_WINDOW_DAYS)
resolved = [x for x in d.doses if since < x.planned_at <= now and x.status != "pending"]
if not resolved:
return None
return round(sum(1 for x in resolved if x.status == "taken") / len(resolved), 2)
def _drug_label(rx: Prescription) -> str:
return f"{rx.drug} {rx.dose}".strip()
def _active_rx(d: PatientData, today: date) -> Prescription | None:
active = [
r
for r in d.prescriptions
if (r.start_date is None or r.start_date <= today) and (r.end_date is None or r.end_date >= today)
]
return (active or list(d.prescriptions) or [None])[-1]
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})"
return alert.reason if alert is not None else ""
def _age(patient: Patient | None, profile: PatientProfile, today: date) -> int:
birth_year = (patient.birth_year if patient else None) or profile.birth_year
return today.year - birth_year if birth_year else 0
def list_item(d: PatientData, patient: Patient | None, now: datetime) -> PatientListItem:
p = d.profile
today = now.date()
open_alerts = [a for a in d.alerts if a.acked_at is None]
# ADR-006: the colour is derived from open alert levels (the source of truth), not from the cached column.
status = patient_status(AlertLevel[a.level.upper()] for a in open_alerts)
phq9, pcl5 = _complete(d, "phq9"), _complete(d, "pcl5")
rx = _active_rx(d, today)
return PatientListItem(
token=p.token,
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,
therapy_day=_therapy_day(p, today),
next_visit=p.next_visit,
status_color=status.color,
top_alert_level=cast(LevelName, status.top_level.name.lower()) if status.top_level is not None else None,
open_alerts=len(open_alerts),
phq9_last=phq9[-1].total if phq9 else None,
pcl5_last=pcl5[-1].total if pcl5 else None,
trend=trend(d),
adherence_7d=adherence_7d(d, now),
discontinuation_risk=discontinuation_risk(d, now),
last_event=_last_event(d, now),
)
def sort_key(item: PatientListItem) -> tuple[int, int, str]:
"""red → yellow → grey → green, then discontinuation risk (desc), then name for a stable order."""
return (COLOR_ORDER[item.status_color], -item.discontinuation_risk.score, item.display_name)
# --------------------------------------------------------------------------- card
def alert_out(a: Alert, patient_name: str) -> AlertOut:
level = cast(LevelName, a.level)
return AlertOut(
id=a.id,
token=a.token,
patient_name=patient_name,
ts=a.ts,
level=level,
color=cast(Color, color([AlertLevel[a.level.upper()]])),
code=a.code,
reason=a.reason,
acked_by=a.acked_by,
acked_at=a.acked_at,
)
def series(d: PatientData, now: datetime) -> list[SeriesPoint]:
since = now.date() - timedelta(days=CARD_DAYS)
out: list[SeriesPoint] = []
for c in d.checkins:
if not since <= c.day <= now.date():
continue
if c.mood is not None:
out.append(SeriesPoint(day=c.day, metric="mood", value=float(c.mood)))
if c.sleep_bucket in SLEEP_HOURS:
out.append(SeriesPoint(day=c.day, metric="sleep_hours", value=SLEEP_HOURS[c.sleep_bucket]))
if c.alcohol_bucket in ALCOHOL_LEVEL:
out.append(SeriesPoint(day=c.day, metric="alcohol", value=ALCOHOL_LEVEL[c.alcohol_bucket]))
for r in d.results:
day = _local(r.ts, now)
if r.complete and r.scale in _METRICS and since <= day and r.ts <= now:
out.append(SeriesPoint(day=day, metric=cast(Metric, r.scale), value=float(r.total), scheduled=r.scheduled))
return sorted(out, key=lambda p: (p.day, p.metric))
def doses(d: PatientData, now: datetime) -> list[DoseOut]:
labels = {r.id: _drug_label(r) for r in d.prescriptions}
since = now - timedelta(days=CARD_DAYS)
return [
DoseOut(planned_at=x.planned_at, drug=labels.get(x.prescription_id, ""), status=x.status) # type: ignore[arg-type]
for x in d.doses
if since <= x.planned_at <= now + timedelta(days=1)
]
def prescriptions(d: PatientData) -> list[PrescriptionOut]:
out = []
for r in d.prescriptions:
schedule = dict(r.schedule or {})
out.append(
PrescriptionOut(
drug=r.drug,
dose=r.dose,
schedule={"times": [str(t) for t in schedule.get("times", [])]},
instructions=" ".join(str(t) for t in _as_list(schedule.get("instructions"))),
start_date=r.start_date,
end_date=r.end_date,
)
)
return out
def _as_list(value: Any) -> list[Any]:
if value is None:
return []
return list(value) if isinstance(value, list) else [value]
def _plan_line(step: Any) -> str:
# Same rendering as the dialog's safety-plan reply (app/dialog/actions.py).
if isinstance(step, dict):
return ": ".join(str(v) for v in step.values() if v)
return str(step)
async def safety_plan(session: AsyncSession, token: str) -> list[str] | None:
row = await session.get(SafetyPlan, token)
return [_plan_line(s) for s in row.steps] if row is not None and row.steps else None
async def build_card(session: AsyncSession, d: PatientData, patient: Patient | None, now: datetime) -> PatientCard:
"""Card without the summary (the caller adds it: the LLM call must not hold the DB session)."""
item = list_item(d, patient, now)
return PatientCard(
patient=item,
prescriptions=prescriptions(d),
series=series(d, now),
doses=doses(d, now),
alerts=[alert_out(a, item.display_name) for a in d.alerts],
safety_plan=await safety_plan(session, d.profile.token),
)
+9 -1
View File
@@ -9,18 +9,26 @@ from zoneinfo import ZoneInfo
from app.api.schemas import (
AlertOut,
DemoPersona,
DiscontinuationRisk,
DoseOut,
PatientCard,
PatientListItem,
SeriesPoint,
)
from app.core.safety import fill_phones
from app.integrations.base import Prescription
TZ = ZoneInfo("Europe/Moscow")
TODAY = date(2026, 10, 3)
DOCTOR_ID = "doc-smirnova"
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
DEMO_PERSONAS: list[DemoPersona] = [
# ЕМИАС 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"),
]
def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
level = "high" if score >= 60 else "medium" if score >= 30 else "low"
@@ -256,7 +264,7 @@ def andrey_card() -> PatientCard:
"Что помогает: прогулка с собакой, душ",
"Люди и места: брат Олег, спортзал",
"Кому звонить: жена, сослуживец Миша",
"Специалисты: д-р Смирнова, 8-800-2000-122, 112",
fill_phones("Специалисты: д-р Смирнова, {phone:helpline}, 112"),
"Безопасная среда: ружьё хранится у брата",
],
summary="За 13 дней: приверженность 12/13, эпизод намерения бросить на 5-й день (снят после звонка врача). "
+115
View File
@@ -0,0 +1,115 @@
"""Server API for a future MAX connect UI; webhook processing is supplied by the channel adapter."""
import hmac
import json
import re
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings
from app.core import clock
from app.db import repo
from app.db.max_notifications import MaxBinding, MaxDelivery
from app.db.session import get_clinical_session, get_identity_session
from app.integrations import max_store
router = APIRouter(tags=["max-notifications"])
bearer = HTTPBearer(auto_error=False)
async def patient_token(auth: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> str:
if auth is None or auth.scheme.lower() != "bearer" or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", auth.credentials):
raise HTTPException(401, "patient authentication required", headers={"WWW-Authenticate": "Bearer"})
async with get_clinical_session() as session:
if await repo.get_profile(session, auth.credentials) is None:
raise HTTPException(401, "patient authentication required")
return auth.credentials
PatientToken = Annotated[str, Depends(patient_token)]
class MaxStatus(BaseModel):
available: bool
connected: bool
last_delivery: str | None = None
class LinkConsent(BaseModel):
model_config = ConfigDict(extra="forbid")
consent: Literal[True]
class MaxLink(BaseModel):
url: str
expires_at: datetime
@router.get("/me/notifications/max", response_model=MaxStatus)
async def status(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
binding = await session.get(MaxBinding, token)
last = await session.scalar(
select(MaxDelivery.state)
.where(MaxDelivery.token == token)
.order_by(MaxDelivery.created_at.desc(), MaxDelivery.id.desc())
.limit(1)
)
return MaxStatus(
available=get_settings().max_enabled, connected=bool(binding and binding.enabled), last_delivery=last
)
@router.post("/me/notifications/max/link", response_model=MaxLink)
async def link(body: LinkConsent, token: PatientToken) -> MaxLink:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
if not await max_store.active_patient(token):
raise HTTPException(403, "active consent and adult confirmation required")
async with get_identity_session() as session:
raw, expires = await max_store.issue_code(session, token, clock.now(), settings.max_link_ttl_seconds)
return MaxLink(url=f"https://max.ru/{settings.max_bot_username}?start={raw}", expires_at=expires)
@router.delete("/me/notifications/max", response_model=MaxStatus)
async def unlink(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
await max_store.disconnect(session, token)
return MaxStatus(available=get_settings().max_enabled, connected=False)
@router.post("/webhooks/max")
async def webhook(request: Request) -> dict[str, bool]:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
expected = settings.max_webhook_secret.get_secret_value().encode()
supplied = request.headers.get("X-Max-Bot-Api-Secret", "").encode()
if not expected or not hmac.compare_digest(supplied, expected):
raise HTTPException(403, "invalid webhook secret")
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 65536:
raise HTTPException(413, "event is too large")
try:
event = json.loads(raw)
except (ValueError, UnicodeError) as exc:
raise HTTPException(400, "invalid JSON") from exc
if not isinstance(event, dict):
raise HTTPException(422, "event must be an object")
from app.channels.max_bot import handle_update
try:
await handle_update(event)
except SQLAlchemyError:
# Let MAX retry; do not log DB exception parameters containing identity data.
raise HTTPException(503, "notification storage unavailable") from None
return {"success": True}
+542
View File
@@ -0,0 +1,542 @@
"""Patient web app: sign-in, the patient's own plan and public reference data (`/api/v1/auth/*`, `/me/*`,
`/crisis/resources`, `/instruments/catalog`, `/meds/reference`).
The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
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, 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.
"""
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, 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
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 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, 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__)
router = APIRouter(tags=["patient-web"])
_emias = EmiasMock()
class AuthOut(BaseModel):
token: str
mode: Literal["emias", "anonymous"]
display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design
class EmiasCodeIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
class AnonymousIn(BaseModel):
model_config = ConfigDict(extra="forbid")
focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module
# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010).
_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"}
class EmiasDemoIn(BaseModel):
model_config = ConfigDict(extra="forbid")
persona_token: str = Field(min_length=1, max_length=64)
async def _attach_doctor(token: str, doctor_id: str) -> None:
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
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(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()
today = now.date()
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
await repo.upsert_profile(
s,
token=token,
doctor_id=fixtures.DOCTOR_ID,
modules=list(item.modules),
therapy_start=min((p.start_date for p in card.prescriptions if p.start_date), default=today),
next_visit=today + timedelta(days=15),
)
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
return
for p in card.prescriptions:
rx = Prescription(
token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=p.start_date or today
)
s.add(rx)
await s.flush()
# The dose history of the dashboard story (days before today), so the week marks match the doctor's card.
for d in card.doses:
if d.drug == p.drug and d.planned_at.astimezone(now.tzinfo).date() < today:
s.add(Dose(token=token, prescription_id=rx.id, planned_at=d.planned_at, status=d.status))
for t in p.schedule.get("times", []):
hh, mm = (int(x) for x in t.split(":"))
s.add(
Dose(
token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo)
)
)
@router.post("/auth/emias", response_model=AuthOut)
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
person = await _emias.verify(body.code)
if person is None:
log.info("web auth: emias code rejected")
raise HTTPException(401, "code not accepted")
await _attach_doctor(person.token, person.doctor_id)
log.info("web auth: emias tok=%s", person.token[:6])
return AuthOut(token=person.token, mode="emias")
@router.get("/patient/demo-personas", response_model=list[DemoPersona])
async def patient_demo_personas() -> list[DemoPersona]:
"""Synthetic ЕМИАС accounts for the patient app sign-in (the demo console has its own `/demo/personas`)."""
return fixtures.DEMO_PERSONAS
@router.post("/auth/emias/demo", response_model=AuthOut)
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/patient/demo-personas`)."""
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
if persona is None:
raise HTTPException(404, "persona not found")
if persona.token == DEMO_SOURCE:
# Every visitor gets a private copy (never the shared account); this entry starts from consent and the
# initial questionnaires (subject to the screening switch), unlike the one-click chat demo.
return await _fresh_demo_copy(start_in_chat=False)
await _seed_demo_persona(persona.token)
log.info("web auth: emias demo tok=%s", persona.token[:6])
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
DEMO_SOURCE = "tok-andrey"
ONE_CLICK_NAME = "Андрей С." # «Демо: войти как Андрей»; the ЕМИАС demo persona is named in fixtures (Никита)
@router.post("/auth/demo/fresh", response_model=AuthOut)
async def auth_demo_fresh() -> AuthOut:
return await _fresh_demo_copy(start_in_chat=True)
async def _fresh_demo_copy(*, start_in_chat: bool) -> AuthOut:
"""Presentation entry: a fresh private copy of veteran Andrey for every sign-in — consent given, prescription
and ЕМИАС record in place, dialog resting in the chat. Nobody's crisis or half-done questionnaire leaks into
another viewer's demo. Synthetic data only."""
token = f"{DEMO_SOURCE}-{secrets.token_hex(4)}"
now = clock.now()
persona = next(p for p in fixtures.DEMO_PERSONAS if p.token == DEMO_SOURCE)
name = persona.display_name if not start_in_chat else ONE_CLICK_NAME
snils = f"000-{secrets.randbelow(10**3):03d}-{secrets.randbelow(10**3):03d} {secrets.randbelow(100):02d}"
async with get_identity_session() as s:
await identity_repo.create_patient(
s,
token=token,
display_name=f"{name} (демо)",
snils=snils,
clinic_id="demo",
doctor_id=fixtures.DOCTOR_ID,
now=now,
birth_year=now.year - 34,
sex="m",
)
if start_in_chat:
await identity_repo.record_consent(s, token=token, version="demo", now=now)
await _seed_demo_persona(token, source=DEMO_SOURCE)
async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, sex="m", birth_year=now.year - 34)
if start_in_chat:
consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None}
await repo.set_dialog_state(s, token=token, state="idle", data={**consent, "adult": True}, now=now)
log.info("web auth: demo fresh tok=%s", token[:14])
return AuthOut(token=token, mode="emias", display_name=name)
@router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
"""Anonymous sign-in: a fresh random token; care without an identity record.
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=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 InvalidAudio as exc:
raise HTTPException(415, "expected 16-bit mono PCM WAV") from exc
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
dose: str
time: str # HH:MM, patient's local time
status: Literal["taken", "missed", "skipped", "pending"]
class WeekDay(BaseModel):
day: date
status: Literal["taken", "missed", "pending", "none"]
class PrescriptionItem(BaseModel):
drug: str
dose: str
times: list[str]
week: list[WeekDay] # Mon..Sun of the current week
ref_id: str | None = None # entry of /meds/reference, if the drug is covered
indication: Literal["depression", "ptsd"] | None = None
start_date: date | None = None
therapy_day: int | None = None # 1 on the start date (spec: M5, «ожидаемый эффект» cards on days 1/7/14)
class Questionnaires(BaseModel):
"""Questionnaire state for the home screen. Answers and scores are not exposed here."""
enabled: bool
intake: Literal["none", "active", "paused", "complete", "review"]
diary_done: bool # both parts of today's diary
diary_next: Literal["morning", "evening"] | None = None # the part still open today
class TodayOut(BaseModel):
today: date
now: str # HH:MM on the (demo) clock — the client never uses its own clock
has_doctor: bool
checkin_done: bool
next_visit: date | None
doses: list[DoseItem]
prescriptions: list[PrescriptionItem]
questionnaires: Questionnaires
def _intake_state(records: list[QuestionnaireRecord]) -> Literal["none", "active", "paused", "complete", "review"]:
intake = [r for r in records if r.kind == "intake"]
if any(r.status == "complete" for r in intake):
return "complete"
if not intake:
return "none"
last = max(intake, key=lambda r: r.started_at).status
if last in ("active", "paused"):
return last
return "review" if last in ("review_required", "interrupted") else "none"
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
if not statuses:
return "none"
if any(s in ("missed", "skipped") for s in statuses):
return "missed"
return "taken" if all(s == "taken" for s in statuses) else "pending"
@router.get("/me/{token}/today", response_model=TodayOut)
async def my_today(token: str) -> TodayOut:
"""The patient's own plan for today (home screen) and this week's dose marks (medication screen).
Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan.
"""
now = clock.now()
today = now.date()
week_start = today - timedelta(days=today.weekday())
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all()
doses = (
await s.scalars(
select(Dose)
.where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo))
.order_by(Dose.planned_at)
)
).all()
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
records = list((await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all())
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
today_doses: list[DoseItem] = []
marks: dict[tuple[int, date], list[str]] = defaultdict(list)
for d in doses:
local = d.planned_at.astimezone(now.tzinfo)
marks[(d.prescription_id, local.date())].append(d.status)
rx = by_rx.get(d.prescription_id)
if local.date() == today and rx is not None:
today_doses.append(
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
)
week = [week_start + timedelta(days=i) for i in range(7)]
modules = list(profile.modules or []) if profile else []
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"}
open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts]
diary_done = not open_parts
def item(p: Prescription) -> PrescriptionItem:
ref = find_drug(p.drug)
return PrescriptionItem(
drug=p.drug,
dose=p.dose,
times=list((p.schedule or {}).get("times", [])),
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
ref_id=ref["id"] if ref else None,
indication=indication_for(modules, ref) if ref else None,
start_date=p.start_date,
therapy_day=(today - p.start_date).days + 1 if p.start_date else None,
)
return TodayOut(
today=today,
now=now.strftime("%H:%M"),
has_doctor=profile is not None and bool(profile.doctor_id),
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
next_visit=profile.next_visit if profile else None,
doses=today_doses,
prescriptions=[
item(p)
for p in prescriptions
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
],
questionnaires=Questionnaires(
# Stage 1 needs no profile: the screening creates it (anonymous people have none before it).
enabled=intake_enabled(),
intake=_intake_state(records),
diary_done=diary_done,
diary_next=open_parts[0] if open_parts else None,
),
)
@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
class Conclusion(BaseModel):
"""Preliminary conclusion of stage 1 — a screening result, not a diagnosis."""
day: date
title: str
lines: list[str]
findings: list[ResultFinding]
modules: list[str]
disclaimer: str
class HistoryItem(BaseModel):
id: str
kind: str
kind_title: str
status: str
status_title: str
day: date
started_at: datetime
completed_at: datetime | None
title: str | None # interpretation shown to the patient when the form was finished
lines: list[str]
class ResultsOut(BaseModel):
conclusion: Conclusion | None
history: list[HistoryItem]
@router.get("/me/{token}/results", response_model=ResultsOut)
async def my_results(token: str) -> ResultsOut:
"""The patient's own questionnaire log: every administration with its date, status and the interpretation that
was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers."""
async with get_clinical_session() as s:
records = list(
(
await s.scalars(
select(QuestionnaireRecord)
.where(QuestionnaireRecord.token == token)
.order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id)
)
).all()
)
t = results_texts()
intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None)
conclusion = None
if intake and intake.details.get("interpretation"):
i = intake.details["interpretation"]
conclusion = Conclusion(
day=intake.day,
title=i["title"],
lines=i["lines"],
findings=[ResultFinding(**f) for f in i.get("findings", [])],
modules=i.get("modules", []),
disclaimer=i.get("disclaimer", t["disclaimer"]),
)
history = [
HistoryItem(
id=r.id,
kind=r.kind,
kind_title=t["kinds"].get(r.kind, r.kind),
status=r.status,
status_title=t["status"].get(r.status, r.status),
day=r.day,
started_at=r.started_at,
completed_at=r.completed_at,
title=(r.details.get("interpretation") or {}).get("title"),
lines=(r.details.get("interpretation") or {}).get("lines", []),
)
for r in records
]
return ResultsOut(conclusion=conclusion, history=history)
@router.get("/crisis/resources", response_model=list[CrisisResource])
async def crisis_resources() -> list[CrisisResource]:
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
return crisis_reply([], "patient").resources
@router.get("/instruments/catalog", response_model=Catalog)
async def instruments_catalog() -> Catalog:
"""Form, position and recall period of every questionnaire item, for the question screen header."""
return catalog()
@router.get("/meds/reference")
async def meds_reference() -> dict[str, Any]:
"""Patient drug reference (draft from the Minzdrav guidelines): expected effect, course, side effects, red flags.
The internal rule parameters stay on the server.
"""
ref = load_reference()
return {k: v for k, v in ref.items() if k != "rules"}
+265 -25
View File
@@ -1,12 +1,27 @@
"""API routes `/api/v1` (#34 stubs → #12 implementation).
"""API routes `/api/v1` (#34 contract → #12 implementation).
Stubs return deterministic fixtures (`app/api/fixtures.py`) so the dashboard can be built immediately.
Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract).
Paths and schemas are the frozen contract (`app/api/schemas.py`); bodies read the identity and clinical DBs.
Read models live in `app/api/doctor_views.py`, demo-console helpers in `app/api/demo.py`.
Every doctor access to patient data is written to `audit_log` (guardrail #6): `view_patient_list`,
`view_patient` (also a denied attempt: `view_patient_denied`), `view_alerts` (only when new alerts are
disclosed — the dashboard polls every 2–3 s), `ack_alert` (in `repo.ack_alert`), `connect_patient`.
`app/api/fixtures.py` is kept for tests and as the demo cabinet reference (doctor id).
"""
from fastapi import APIRouter, HTTPException
import asyncio
import json
import logging
import secrets
from typing import Any
from app.api import fixtures
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from app.api import demo as console
from app.api import doctor_views as views
from app.api import fixtures, max_notifications
from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import (
AckIn,
@@ -23,68 +38,251 @@ from app.api.schemas import (
PatientListItem,
)
from app.core import clock
from app.dialog.contracts import get_engine
from app.integrations.base import MemoryOutbox, integrations
from app.core.privacy import pseudo_id
from app.db import identity_repo, repo
from app.db.audit import audit
from app.db.clinical import Alert, Message, PatientProfile
from app.db.clinical import Prescription as PrescriptionRow
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)
router.include_router(patient_web_router)
DEFAULT_CLINIC = "clinic-demo"
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand
CODE_LEN = 8
async def _patients(tokens: list[str]) -> dict[str, Patient]:
"""Identity rows for display name / age only (never put SNILS or phone into a response)."""
if not tokens:
return {}
async with get_identity_session() as s:
rows = (await s.scalars(select(Patient).where(Patient.token.in_(tokens)))).all()
return {p.token: p for p in rows}
def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token)
return p.display_name if p else anonymous_name(token)
# ------------------------------------------------------------------ doctor dashboard
@router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"])
async def list_patients(doctor_id: str) -> list[PatientListItem]:
"""Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk."""
order = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score))
now = clock.now()
async with get_clinical_session() as s:
profiles = await repo.patients_of_doctor(s, doctor_id)
data = [await views.load_patient_data(s, p) for p in profiles]
await audit(s, actor=doctor_id, action="view_patient_list", token=None, details={"count": len(data)}, ts=now)
patients = await _patients([p.token for p in profiles])
items = [views.list_item(d, patients.get(d.profile.token), now) for d in data]
return sorted(items, key=views.sort_key)
@router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"])
async def patient_card(token: str, doctor_id: str) -> PatientCard:
"""Patient card. Every call is written to audit_log as `view_patient` (#12)."""
card = fixtures.card(token)
if card is None:
"""Patient card. Every call is written to audit_log as `view_patient`; only the attending doctor may read it."""
now = clock.now()
patient = (await _patients([token])).get(token)
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 profile.doctor_id != doctor_id:
await audit(s, actor=doctor_id, action="view_patient_denied", token=token, ts=now)
await s.commit()
raise HTTPException(403, "not your patient")
data = await views.load_patient_data(s, profile)
card = await views.build_card(s, data, patient, now)
await audit(s, actor=doctor_id, action="view_patient", token=token, ts=now)
# Summary after the commit: the (possibly slow) LLM call must not hold the DB transaction.
from app.llm.summary import build_summary # summary imports app.api.schemas; avoid an import cycle
card.summary = await build_summary(card, today=now.date())
return card
@router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"])
async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]:
"""Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first."""
alerts = [a for a in fixtures.all_alerts() if a.id > since_id]
return [a for a in alerts if a.acked_at is None] if open_only else alerts
async with get_clinical_session() as s:
tokens = list(await s.scalars(select(PatientProfile.token).where(PatientProfile.doctor_id == doctor_id)))
stmt = select(Alert).where(Alert.token.in_(tokens), Alert.id > since_id)
if open_only:
stmt = stmt.where(Alert.acked_at.is_(None))
alerts = (await s.scalars(stmt.order_by(Alert.ts.desc(), Alert.id.desc()))).all()
if alerts:
await audit(
s,
actor=doctor_id,
action="view_alerts",
token=None,
details={"alert_ids": [a.id for a in alerts][:50], "count": len(alerts)},
ts=clock.now(),
)
patients = await _patients(sorted({a.token for a in alerts}))
return [views.alert_out(a, _name(patients, a.token)) for a in alerts]
@router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"])
async def ack_alert(alert_id: int, body: AckIn) -> AlertOut:
"""Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis."""
alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None)
now = clock.now()
async with get_clinical_session() as s:
alert = await s.get(Alert, alert_id)
if alert is None:
raise HTTPException(404, "alert not found")
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()})
profile = await repo.get_profile(s, alert.token)
if profile is None or profile.doctor_id != body.doctor_id:
raise HTTPException(403, "not your patient")
await repo.ack_alert(s, alert_id=alert_id, doctor_id=body.doctor_id, ts=now)
await refresh_status(s, alert.token)
open_acute = [a for a in await repo.list_alerts(s, token=alert.token, open_only=True) if a.level == "acute"]
out = views.alert_out(alert, "")
# The crisis ends only when no acute alert is left open (another acute signal keeps the patient in crisis).
if alert.level == "acute" and not open_acute:
replies = await get_engine().handle(alert.token, Event(type="system", system="doctor_ack"))
if replies:
await integrations.outbox.push(alert.token, replies)
return out.model_copy(update={"patient_name": _name(await _patients([alert.token]), alert.token)})
@router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"])
async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut:
"""Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR."""
return ConnectPatientOut(token="tok-new", code="DEMO-0001", deep_link="https://t.me/ptsr_demo_bot?start=DEMO-0001")
now = clock.now()
try:
token = pseudo_id(body.snils)
except ValueError as exc:
raise HTTPException(422, "SNILS must contain 11 digits") from exc
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
async with get_identity_session() as ids:
if await identity_repo.get_patient(ids, token) is not None:
raise HTTPException(409, "patient already connected")
doctor = await identity_repo.get_doctor(ids, body.doctor_id)
await identity_repo.create_patient(
ids,
token=token,
display_name=body.display_name,
snils=body.snils,
clinic_id=doctor.clinic_id if doctor else DEFAULT_CLINIC,
doctor_id=body.doctor_id,
now=now,
birth_year=body.birth_year,
sex=body.sex,
)
await identity_repo.create_connect_code(ids, code=code, token=token, doctor_id=body.doctor_id, now=now)
# Clinical writes nested inside the identity unit of work: if they fail, the identity row rolls back too.
async with get_clinical_session() as s:
await repo.upsert_profile(
s,
token=token,
doctor_id=body.doctor_id,
modules=list(body.modules),
therapy_start=now.date(),
next_visit=body.next_visit,
birth_year=body.birth_year,
sex=body.sex,
)
schedule: dict[str, list[str]] = {"times": list(body.times)}
if body.instructions:
schedule["instructions"] = [body.instructions]
s.add(
PrescriptionRow(token=token, drug=body.drug, dose=body.dose, schedule=schedule, start_date=now.date())
)
await audit(
s,
actor=body.doctor_id,
action="connect_patient",
token=token,
details={"modules": list(body.modules), "drug": body.drug},
ts=now,
)
return ConnectPatientOut(token=token, code=code, deep_link=await console.deep_link(code))
# ------------------------------------------------------------------ patient dialog (web console / any channel)
async def _require_patient(token: str) -> None:
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
@router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"])
async def dialog_event(token: str, body: DialogIn) -> DialogOut:
await _require_patient(token)
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."""
box = integrations.outbox
return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else [])
box = console.memory_outbox()
return DialogOut(replies=box.drain(token) if box is not None else [])
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]:
return []
"""Stored dialog (texts are scrubbed before storage, ADR-003), oldest first, the last `limit` messages."""
async with get_clinical_session() as s:
stmt = select(Message).where(Message.token == token).order_by(Message.ts.desc(), Message.id.desc())
rows = (await s.scalars(stmt.limit(max(1, min(limit, 1000))))).all()
return [MessageOut(ts=m.ts, direction=m.direction, text=m.text_scrubbed) for m in reversed(rows)] # type: ignore[arg-type]
# ------------------------------------------------------------------ demo controls (ADR-005)
@@ -99,21 +297,63 @@ async def get_clock() -> ClockOut:
@router.post("/demo/clock/advance", response_model=ClockOut, tags=["demo"])
async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
"""Moves the virtual clock; #13 makes the scheduler replay all events up to the new time."""
"""Moves the virtual clock and replays the scheduler up to the new time (#13)."""
clock.advance(days=body.days, hours=body.hours)
await scheduler.run_until_now()
return _clock_out()
@router.post("/demo/reset", tags=["demo"])
async def demo_reset() -> dict[str, str]:
"""Clock back to the wall clock, console outbox emptied, demo data re-seeded by `scripts.seed_demo` (#18)."""
clock.reset()
return {"status": "ok"}
seeded = await console.reset_demo()
return {"status": "ok", "seed": "reset" if seeded else "skipped"}
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
async def demo_personas() -> list[DemoPersona]:
return [
"""Patients with a scripted scenario `data/synthetic/<slug>.jsonl`; the demo cabinet's doctor first."""
scenarios = console.scenario_files()
if not scenarios:
return []
async with get_identity_session() as ids:
patients = (await ids.scalars(select(Patient))).all()
scripted = [(p, scenarios[slug]) for p in patients if (slug := console.persona_slug(p.display_name)) in scenarios]
if not scripted:
return []
today = clock.today()
async with get_clinical_session() as s:
profiles = {p.token: await repo.get_profile(s, p.token) for p, _ in scripted}
out = []
for p, path in sorted(scripted, key=lambda x: (x[0].doctor_id != fixtures.DOCTOR_ID, x[0].display_name)):
profile = profiles.get(p.token)
start = profile.therapy_start if profile else None
out.append(
DemoPersona(
token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"
token=p.token,
display_name=p.display_name,
therapy_day=(today - start).days if start else 0,
scenario=path,
)
]
)
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))
+77 -2
View File
@@ -1,10 +1,12 @@
"""Typed application settings. The only place that reads environment variables."""
import re
from functools import lru_cache
from pathlib import Path
from typing import Literal
from urllib.parse import urlsplit
from pydantic import Field
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT_DIR = Path(__file__).resolve().parent.parent
@@ -12,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"
@@ -28,12 +30,85 @@ class Settings(BaseSettings):
gigachat_model: str = "GigaChat"
gigachat_verify_ssl: bool = False
# Chat agent (tool-using LLM over RAG + patient record). `auto` = OpenRouter if a key is set, else off
# (extractive RAG / instruction answers stay). OpenRouter is a TEMPORARY prototype provider on synthetic
# data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII).
chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto"
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, #121): speech-to-text for the patient's messages. `auto` = SaluteSpeech if credentials are
# set; otherwise, in the demo only, 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 — forbidden with DEMO_CLOCK=false (validator below).
stt_provider: Literal["auto", "off", "mock", "openrouter", "salute"] = "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)
# SaluteSpeech (Sber, RF contour) — the pilot provider (#121, ADR-016). Credentials = the base64 authorization key.
salute_speech_credentials: SecretStr = SecretStr("")
salute_speech_scope: str = "SALUTE_SPEECH_PERS"
# Russian Trusted Root CA (Минцифры) is not in certifi: point to the .crt/.pem to verify Sber's TLS chain.
salute_speech_ca: Path | None = None
# 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.
max_enabled: bool = False
max_bot_token: SecretStr = SecretStr("")
max_bot_username: str = ""
max_webhook_secret: SecretStr = SecretStr("")
max_webhook_url: str = ""
max_site_url: str = ""
max_ca_bundle: Path | None = None
max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800)
@model_validator(mode="after")
def validate_stt(self) -> "Settings":
if self.stt_provider == "openrouter" and not self.demo_clock:
raise ValueError(
"STT_PROVIDER=openrouter is for synthetic data only (ADR-014) and is forbidden with DEMO_CLOCK=false: "
"use STT_PROVIDER=salute"
)
if self.salute_speech_ca is not None and not self.salute_speech_ca.is_file():
raise ValueError("SALUTE_SPEECH_CA must point to an existing CA certificate file")
return self
@model_validator(mode="after")
def validate_max(self) -> "Settings":
if not self.max_enabled:
return self
if not self.max_bot_token.get_secret_value().strip():
raise ValueError("MAX_BOT_TOKEN is required when MAX_ENABLED=true")
if not re.fullmatch(r"[A-Za-z0-9_]{1,128}", self.max_bot_username):
raise ValueError("MAX_BOT_USERNAME must be the bot nickname without @ or a URL")
if not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", self.max_webhook_secret.get_secret_value()):
raise ValueError("MAX_WEBHOOK_SECRET must contain 32..256 URL-safe characters")
for name, value in (("MAX_SITE_URL", self.max_site_url), ("MAX_WEBHOOK_URL", self.max_webhook_url)):
url = urlsplit(value)
if url.scheme != "https" or not url.hostname or url.username or url.password or url.port not in (None, 443):
raise ValueError(f"{name} must use HTTPS on port 443 without credentials")
if url.query or (name == "MAX_WEBHOOK_URL" and url.fragment):
raise ValueError(f"{name} must not contain a query string (or a fragment for webhook)")
if self.max_ca_bundle is not None and not self.max_ca_bundle.is_file():
raise ValueError("MAX_CA_BUNDLE must point to an existing trusted CA bundle")
return self
# 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()
+142
View File
@@ -0,0 +1,142 @@
"""Read-only catalog of questionnaire items for patient clients (web app).
The dialog engine sends a questionnaire question as plain text plus buttons; the button payload carries the item id
(`iq:<nonce>:<item>:<i>`, `scale:<scale>:<item>:<v>`). A client looks the item up here to show which form it belongs
to, its position and recall period. Nothing here changes what is asked or how it is scored — the texts stay those of
the versioned sources in config/.
"""
from functools import lru_cache
from typing import Any, Literal
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, red_flags, texts
from app.core.scales import load_scale
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
# Forms whose item order depends on earlier answers: a client shows the position without «из N».
ADAPTIVE = {"cssrs", "rus_audit", "diary", "context"}
class FormInfo(BaseModel):
id: str
title: str
section: Section
recall_period: str | None = None
instruction: str | None = None
class ItemInfo(BaseModel):
form: str
index: int # 1-based position in the form
total: int
adaptive: bool
optional: bool = False
numeric: bool = False
class DrinksTable(BaseModel):
"""RUS-AUDIT item 2 standard-drink table, exactly as in config/texts/intake.yaml (the approved form)."""
title: str
header: list[str]
rows: list[tuple[str, list[str]]]
note: str
class Catalog(BaseModel):
forms: dict[str, FormInfo]
items: dict[str, ItemInfo]
standard_drinks: DrinksTable | None
def _drinks() -> DrinksTable | None:
raw = texts().get("standard_drinks")
if not raw:
return None
lines = [line.strip() for line in raw.splitlines() if line.strip()]
title, header_line, *rest = lines
rows = [line for line in rest if ":" in line]
note = " ".join(line for line in rest if ":" not in line)
return DrinksTable(
title=title,
header=[h.strip() for h in header_line.split("·")],
rows=[
(label.strip(), [c.strip() for c in cells.split("·")]) for label, cells in (r.split(":", 1) for r in rows)
],
note=note,
)
@lru_cache(maxsize=1)
def catalog() -> Catalog:
forms: dict[str, FormInfo] = {}
items: dict[str, ItemInfo] = {}
def add(form: FormInfo, ids: list[str], *, optional: set[str] = frozenset(), numeric: set[str] = frozenset()):
forms[form.id] = form
for i, item in enumerate(ids, 1):
items.setdefault(
item,
ItemInfo(
form=form.id,
index=i,
total=len(ids),
adaptive=form.id in ADAPTIVE,
optional=item in optional,
numeric=item in numeric,
),
)
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])
intake_forms: dict[str, Any] = content()["forms"]
for sid, section in (("phq9", "mood"), ("tsq", "trauma"), ("pcl5", "trauma"), ("rus_audit", "alcohol")):
raw = intake_forms.get(sid) or {}
scale = load_scale(sid)
add(
FormInfo(
id=sid,
title=raw.get("title") or scale.title,
section=section,
recall_period=raw.get("recall_period") or scale.recall_period,
instruction=raw.get("instruction") or scale.instruction,
),
list(scale.item_ids),
)
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
diary = diary_config()
core = [item for part in DIARY_PARTS for item in diary[part]]
depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]]
ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]]
add(
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
[i["id"] for i in core] + depression + ptsd,
optional={i["id"] for i in core if i.get("optional")},
numeric={i["id"] for i in core if i.get("numeric")},
)
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
for path in sorted((get_settings().config_dir / "scales").glob("*.yaml")):
scale = load_scale(path.stem)
forms.setdefault(
scale.id,
FormInfo(
id=scale.id,
title=scale.title,
section="scale",
recall_period=scale.recall_period,
instruction=scale.instruction,
),
)
return Catalog(forms=forms, items=items, standard_drinks=_drinks())
+102 -34
View File
@@ -18,6 +18,7 @@ class Question:
period: str = "context"
numeric: bool = False
optional: bool = False
maximum: float = 24
def content() -> dict:
@@ -52,19 +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:
c = score_cssrs_recent(cssrs_answers(answers))
if c.missing:
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == c.missing[0])
period = texts()["periods"][item.recall_period]
# Guidance is interviewer-facing; retained in the source and record version.
return Question(
"cssrs_" + item.id,
period + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
item.recall_period,
)
"""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)
@@ -81,6 +93,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
for item in load_scale("rus_audit_s").item_ids:
if item not in answers:
return scale_question("rus_audit_s", item)
c = score_cssrs_recent(cssrs_answers(answers))
if c.missing:
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == c.missing[0])
period = texts()["periods"][item.recall_period]
# Guidance is interviewer-facing; retained in the source and record version.
return Question(
"cssrs_" + item.id,
period + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
item.recall_period,
)
if sex is None and "sex" not in answers:
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
for key in ("tbi", "psychotropics"):
@@ -108,34 +132,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
return None
def diary_questions(
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
) -> tuple[Question, ...]:
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
selected = ["core"]
detailed = [name for name in ("veteran", "depression") if name in modules]
if len(detailed) == 2:
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
if "veteran" in detailed:
selected.append("ptsd")
if "depression" in detailed:
selected.append("depression")
if suicide_monitoring or "depression" in modules:
selected.append("suicide")
questions = []
for section in selected:
for item in config[section]:
if item.get("weekly") and weekday != 0:
continue
questions.append(
def cssrs_since_contact_question(answers: dict) -> Question | None:
"""Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact».
The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the
items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact
is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies.
"""
c = score_cssrs_recent(cssrs_answers(answers))
missing = [k for k in c.missing if k != "q6_recent"]
if not missing:
return None
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == missing[0])
return Question(
"cssrs_" + item.id,
texts()["periods"]["since_last_contact"] + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
"since_last_contact",
)
DIARY_PARTS = ("morning", "evening")
DIARY_KINDS = ("diary_morning", "diary_evening")
def diary_config() -> dict:
return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]:
"""Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile.
PTSD («veteran») and depression run side by side when both are present. The depression module asks three
PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`)
so diary answers never mix with the full scales.
"""
if part not in DIARY_PARTS:
raise ValueError("unknown diary part")
config = diary_config()
questions = [
Question(
item["id"],
item["text"],
tuple((o["value"], o["label"]) for o in item.get("options", [])),
"past_week" if item.get("weekly") else "past_day",
"past_day",
item.get("numeric", False),
item.get("optional", False),
item.get("max", 24),
)
for item in config[part]
]
if part == "evening":
forms = content()["forms"]
if "depression" in modules:
module = config["depression"]
items = forms[module["form"]]["items"]
per_day = module["items_per_day"]
start = (day_index % (len(items) // per_day)) * per_day
options = tuple((o["value"], o["label"]) for o in module["options"])
for item in items[start : start + per_day]:
number = item["id"].rsplit("_", 1)[1]
questions.append(
Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today")
)
if "veteran" in modules:
module = config["ptsd"]
form = forms[module["form"]]
by_id = {i["id"]: i["text"] for i in form["items"]}
options = tuple((o["value"], o["label"]) for o in form["options"])
for item_id in module["items"]:
number = item_id.rsplit("_", 1)[1]
questions.append(
Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today")
)
questions.sort(key=lambda q: q.id != "death_thoughts")
return tuple(questions)
+81 -29
View File
@@ -10,7 +10,7 @@ import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.core.questionnaire import Approval, load_policy
from app.core.questionnaire import Approval, demo_mode, load_policy
class RuntimePolicy(BaseModel):
@@ -21,13 +21,20 @@ class RuntimePolicy(BaseModel):
content_review: Approval | None = None
contact_review: Approval | None = None
timezone: str
daily_hour: int = Field(ge=0, le=23)
mood_baseline_days: int = Field(ge=2)
mood_bad_days: int = Field(ge=1)
mood_drop: int = Field(ge=1, le=10)
morning_hour: int = Field(ge=0, le=23)
evening_hour: int = Field(ge=0, le=23)
norm_days: int = Field(ge=2)
week_days: int = Field(ge=2)
min_norm_values: int = Field(ge=1)
min_week_values: int = Field(ge=1)
mood_drop: float = Field(gt=0, le=10)
depression_item_rise: float = Field(gt=0, le=3)
ptsd_sum_rise: float = Field(gt=0, le=16)
sleep_bad_days: int = Field(ge=1)
sleep_hours_below: float = Field(gt=0, le=24)
nightmares_at: int = Field(ge=0, le=4)
alcohol_heavy_portions: float = Field(gt=0)
alcohol_heavy_days: int = Field(ge=1)
adherence_below: float = Field(gt=0, le=1)
missed_medication_days: int = Field(ge=1)
high_risk_silence_days: int = Field(ge=1)
medication_days: tuple[int, ...]
@@ -44,6 +51,8 @@ class RuntimePolicy(BaseModel):
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
):
raise ValueError("all route and scale intervals must be defined")
if self.morning_hour >= self.evening_hour:
raise ValueError("the morning diary must come before the evening one")
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
raise ValueError("medication days must be nonempty and strictly increasing")
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
@@ -64,8 +73,17 @@ def load_runtime(path: Path | None = None) -> RuntimePolicy:
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text()))
def demo_intake() -> bool:
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): shows the questionnaires on synthetic data.
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
until the approvals are recorded there.
"""
return demo_mode()
def intake_enabled() -> bool:
return load_runtime().released and load_policy().approval is not None
return (load_runtime().released and load_policy().approval is not None) or demo_intake()
def month_before(day: date, months: int = 1) -> date:
@@ -115,39 +133,73 @@ def due_scales(
return tuple(sorted(due))
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
"""Consecutive calendar dates; missing data never become zero or missed doses.
def _mean(values: list[float]) -> float:
return sum(values) / len(values)
Baseline is the seven dates BEFORE the three-day deterioration window;
every baseline value is required. Nightmares OR short sleep must hold on
each of three nights. These candidate definitions require signed review.
def diary_flags(
history: dict[date, dict],
today: date,
policy: RuntimePolicy | None = None,
*,
medication_active: bool = False,
) -> tuple[str, ...]:
"""Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days.
`history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of
observation; trends are checked only after it. Missing data never become zero or missed doses.
"""
p = policy or load_runtime()
flags = []
flags: list[str] = []
current = history.get(today, {})
if current.get("death_thoughts") == 1:
flags.append("suicide_assessment_now")
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
baseline = [
history.get(today - timedelta(days=i), {}).get("mood")
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
]
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
mean = sum(baseline) / len(baseline)
if all(mean - x["mood"] >= p.mood_drop for x in recent):
start = min(history) if history else today
norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
week_start = today - timedelta(days=p.week_days - 1)
week = [history[d] for d in sorted(history) if week_start <= d <= today]
trend = (week_start - start).days >= p.norm_days
def values(rows: list[dict], key) -> list[float]:
return [v for r in rows if (v := key(r)) is not None]
def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
before, now = values(norm, key), values(week, key)
if not trend or len(before) < min_norm or len(now) < min_week:
return False
change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before)
return change >= by
if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True):
flags.append("phq9_due_mood")
# Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item.
for item in range(1, 10):
if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2):
flags.append("phq9_due_depression")
break
# PTSD module: only the sum of the four items is interpreted, never single clusters.
ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18")
def ptsd_sum(r):
return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None
if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values):
flags.append("pcl5_due_ptsd")
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
if all(
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
(r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
for r in nights
):
flags.append("sleep_review")
heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions]
if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions:
flags.append("alcohol_on_medication")
if len(heavy) >= p.alcohol_heavy_days:
flags.append("rus_audit_due_alcohol")
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
if all(r.get("medication") == "none" for r in doses):
if all(r.get("medication") == "no" for r in doses):
flags.append("medication_contact")
if current.get("side_effects") == 1:
taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
flags.append("adherence_review")
if current.get("medication_reason") == "side_effects":
flags.append("side_effects_review")
if current.get("medication") == "partial":
flags.append("partial_medication_review")
return tuple(flags)
+166
View File
@@ -0,0 +1,166 @@
"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml.
Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2.
Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags.
Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3).
"""
from typing import Any
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"}
# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022).
PHQ9_REMISSION_BELOW = 5
PHQ9_IMPROVED_BY = 5
PCL5_RESPONSE_BY = 10
PCL5_IMPROVED_BELOW = 28
def texts() -> dict[str, Any]:
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:
"""Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any)."""
parts = [interpretation["title"], *interpretation.get("lines", [])]
if interpretation.get("disclaimer"):
parts.append(interpretation["disclaimer"])
return "\n\n".join(p for p in parts if p)
def _alcohol_zone(totals: dict) -> int | None:
full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {}
return full.get("zone") if full.get("complete") else short.get("zone")
def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]:
"""Screening findings of stage 1, in the order they are shown."""
found = []
phq = totals.get("phq9") or {}
if phq.get("complete"):
if phq["total"] >= policy.phq9_specialist_at:
found.append("depression_suspected")
elif phq["total"] >= policy.phq9_psychologist_at:
found.append("depression_mild")
pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {}
if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at:
found.append("ptsd_suspected")
elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at:
found.append("ptsd_signs")
zone = _alcohol_zone(totals)
if zone == 4:
found.append("alcohol_high")
elif zone in (2, 3):
found.append("alcohol_risky")
if (totals.get("cssrs_recent") or {}).get("risk") == "low":
found.append("suicide_low")
if answers.get("tbi") == 1:
found.append("tbi")
return found
def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict:
t = texts()
found = intake_findings(totals, answers, policy)
modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE]
head = t["intake"]["attention" if found else "ok"]
lines = [head["text"]]
lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found]
if decision.primary_route in t["routes"]:
lines.append(t["routes"][decision.primary_route])
names = ", ".join(t["modules"]["names"][m] for m in modules)
lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else ""))
return {
"kind": "intake",
"title": head["title"],
"lines": lines,
"codes": found or ["ok"],
"findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found],
"route": decision.primary_route,
"modules": modules,
"disclaimer": t["disclaimer"],
}
def _phq9_level(total: int) -> str:
band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"])
return band["code"]
def scale_interpretation(
kind: str,
totals: dict,
*,
baseline: int | None,
previous: int | None,
policy: IntakePolicy,
retriage_delta: int,
) -> dict:
"""A scheduled full scale: level, then the change against the start of observation and the last measurement."""
t = texts()["scales"]
if kind == "rus_audit_s":
zone = _alcohol_zone(totals)
result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"]
level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"]
line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level)
return {
"kind": kind,
"title": t[kind]["title"],
"lines": [line],
"codes": [f"zone_{zone}" if zone else "unknown"],
}
total = totals[kind]["total"]
pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below"
code = _phq9_level(total) if kind == "phq9" else pcl5_level
lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])]
change = None
if previous is not None and total - previous >= retriage_delta:
change = "worse"
elif baseline is not None:
if kind == "phq9":
if total < PHQ9_REMISSION_BELOW:
change = "remission"
elif baseline > 0 and total <= baseline / 2:
change = "response"
elif baseline - total >= PHQ9_IMPROVED_BY:
change = "improved"
else:
change = "stable"
else:
if baseline - total >= PCL5_RESPONSE_BY:
change = "response"
elif total < PCL5_IMPROVED_BELOW <= baseline:
change = "improved"
else:
change = "stable"
if change:
lines.append(t["change"][change])
return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]}
def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict:
t = texts()["diary"]
shown = [f for f in flags if f in t["flags"]]
return {
"kind": f"diary_{part}",
"title": t["saved"].format(part=t["parts"][part]),
"lines": [t["flags"][f] for f in shown],
"codes": list(flags),
}
def cssrs_interpretation(risk: str | None) -> dict:
t = texts()["cssrs"]
return {
"kind": "cssrs_since_contact",
"title": t["low" if risk == "low" else "none"],
"lines": [],
"codes": [risk or "none"],
}
+35
View File
@@ -0,0 +1,35 @@
"""Drug reference of the medication module (data/meds/meds_reference.json, built by scripts/build_meds_reference.py).
Read-only patient information collected from the Minzdrav clinical guidelines: when to expect the effect, how long
the course lasts, common side effects, red flags. It never suggests a drug or a dose — the app only follows the
doctor's prescription. Draft until reviewed by a psychiatrist (`status` in the file).
"""
import json
from functools import lru_cache
from typing import Any, Literal
from app.config import get_settings
Indication = Literal["depression", "ptsd"]
@lru_cache(maxsize=1)
def load_reference() -> dict[str, Any]:
path = get_settings().data_dir / "meds" / "meds_reference.json"
return json.loads(path.read_text(encoding="utf-8"))
def find_drug(name: str) -> dict[str, Any] | None:
"""Reference entry for a prescription's drug (INN in Russian or the latin id), or None if it is not covered."""
key = name.strip().split()[0].casefold() if name.strip() else ""
for drug in load_reference()["drugs"]:
if key in (drug["id"], drug["name_ru"].casefold()):
return drug
return None
def indication_for(modules: list[str], drug: dict[str, Any]) -> Indication | None:
"""The indication the patient is followed for, among those the drug has in the guidelines."""
preferred: list[Indication] = ["ptsd", "depression"] if "veteran" in modules else ["depression", "ptsd"]
return next((i for i in preferred if i in drug["indications"]), None)
+17 -6
View File
@@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@@ -49,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")
@@ -64,13 +64,20 @@ 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
def demo_mode() -> bool:
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data.
It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the
real policy and the release gate in config/intake_runtime.yaml are never touched.
"""
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
def load_policy(path: Path | None = None) -> IntakePolicy:
path = path or get_settings().config_dir / "questionnaire.yaml"
path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml")
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
@@ -266,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)
@@ -317,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:
+2
View File
@@ -5,6 +5,8 @@ The only module (besides registration code) that touches both schemas — and on
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
from app.db import intake as _intake # noqa: F401
from app.db import max_notifications as _max_notifications # noqa: F401
from app.db import scheduler as _scheduler # noqa: F401
from app.db.clinical import ClinicalBase
from app.db.identity import IdentityBase
from app.db.session import clinical_engine, identity_engine
+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)
+50
View File
@@ -0,0 +1,50 @@
"""MAX identifiers and delivery metadata live only in the identity DB; no medical message bodies."""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.identity import IdentityBase
from app.db.types import TZDateTime
class MaxBinding(IdentityBase):
__tablename__ = "max_bindings"
# No FK: anonymous web profiles intentionally have no patients row in identity.
token: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, unique=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
connected_at: Mapped[datetime] = mapped_column(TZDateTime)
class MaxLinkCode(IdentityBase):
__tablename__ = "max_link_codes"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
used_at: Mapped[datetime | None] = mapped_column(TZDateTime)
class MaxReceipt(IdentityBase):
__tablename__ = "max_webhook_receipts"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
received_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
class MaxDelivery(IdentityBase):
__tablename__ = "max_deliveries"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int] = mapped_column(BigInteger)
kind: Mapped[str] = mapped_column(String(24))
state: Mapped[str] = mapped_column(String(16), default="queued", index=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(TZDateTime)
next_attempt_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
lease: Mapped[str | None] = mapped_column(String(32))
error_code: Mapped[str | None] = mapped_column(String(32))
+24
View File
@@ -0,0 +1,24 @@
"""Delivery log of the demo scheduler (#13): one row per delivered event, the unique key makes runs idempotent.
Clinical DB, keyed by token only (guardrail #5). Created by `init_db` (`app.db.bootstrap`).
"""
from datetime import datetime
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.clinical import TOKEN_LEN, BigId, ClinicalBase
from app.db.types import TZDateTime
class SchedulerEvent(ClinicalBase):
__tablename__ = "scheduler_events"
__table_args__ = (UniqueConstraint("token", "key", name="uq_scheduler_events_token_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(TOKEN_LEN))
key: Mapped[str] = mapped_column(String(96)) # e.g. "dose:3:2026-10-06T08:00", "checkin:2026-10-06"
kind: Mapped[str] = mapped_column(String(32)) # med_reminder | missed | daily_checkin | scale_due | evaluation
due_at: Mapped[datetime] = mapped_column(TZDateTime)
done_at: Mapped[datetime] = mapped_column(TZDateTime)
+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
+10 -3
View File
@@ -39,6 +39,7 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
buttons = [
[Button(label=b.checkin, data="menu:checkin")],
[Button(label=b.med_question, data="menu:med_question")],
[Button(label=b.kb_question, data="menu:kb_question")],
[Button(label=b.contact_doctor, data="menu:contact_doctor")],
[Button(label=b.revoke, data="consent:revoke")],
]
@@ -65,11 +66,17 @@ 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."""
profile = await ctx.profile()
modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.demo_settings import screening_enabled
from app.core.intake_runtime import intake_enabled
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
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 []
# 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.
if after is None and intake_enabled():
return "intake.start"
if after in modules:
modules = modules[modules.index(after) + 1 :]
+229 -51
View File
@@ -4,12 +4,19 @@ 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,
Question,
content,
cssrs_answers,
cssrs_since_contact_question,
diary_questions,
group_answers,
next_intake,
red_flag_category,
red_flag_triggers,
scale_question,
texts,
)
@@ -17,32 +24,36 @@ from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale
from app.core.types import AlertLevel
from app.db import repo
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
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):
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
def side_effects_question():
"""FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3)."""
return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week")
def next_question(row):
a = row.answers
if row.kind == "intake":
return next_intake(a, load_policy(), row.details.get("sex"))
if row.kind == "diary":
for q in diary_questions(
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday()
):
if row.kind == "cssrs_since_contact":
return cssrs_since_contact_question(a)
if row.kind in DIARY_KINDS:
part = row.kind.removeprefix("diary_")
for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)):
if q.id in a:
continue
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"):
continue
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
continue
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
if q.id == "medication_reason" and a.get("medication") != "no":
continue
return q
return None
@@ -63,11 +74,7 @@ def next_question(row):
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
return q
if row.details.get("medication_active") and "side_effects" not in a:
return next(
q
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
if q.id == "side_effects"
)
return side_effects_question()
return None
@@ -89,7 +96,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
if profile:
profile.enhanced_monitoring = True
flags = list(service.decision(row).flags) if row else []
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0):
if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0):
flags.append("cssrs_since_contact_required")
if row:
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
@@ -102,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
@@ -110,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(
@@ -129,11 +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()
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high":
# 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:
@@ -153,9 +254,6 @@ async def advance(ctx, row):
ctx.now,
params={"record_id": row.id},
)
if row.kind == "cssrs_since_contact":
# Recent and Since Last Contact are different instruments. No silent substitution.
return await manual_review(ctx, row, "cssrs_since_contact_required")
try:
q = next_question(row)
# Validate preserved short answers against a newly answered full-form item 1.
@@ -164,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))
@@ -187,6 +285,8 @@ async def advance(ctx, row):
extra = {"decision": asdict(result), "totals": service.summary(row)}
if a.get("recent_trauma") == 1:
extra["recent_trauma"] = True
extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy)
await apply_modules(ctx, extra["interpretation"]["modules"])
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
await service.alert(
ctx.session,
@@ -196,54 +296,71 @@ async def advance(ctx, row):
ctx.now,
params={"record_id": row.id, **extra},
)
elif row.kind == "diary":
history = {
r.day: r.answers
for r in await service.records(ctx.session, ctx.token)
if r.kind == "diary" and r.status == "complete"
}
history[row.day] = a
flags = diary_flags(history, row.day)
elif row.kind == "cssrs_since_contact":
extra["totals"] = {"cssrs_recent": asdict(c)}
extra["interpretation"] = interpretation.cssrs_interpretation(c.risk)
elif row.kind in DIARY_KINDS:
# Morning and evening answers of one date form one diary day.
history: dict = {}
for r in await service.records(ctx.session, ctx.token):
if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id:
history.setdefault(r.day, {}).update(r.answers)
history.setdefault(row.day, {}).update(a)
flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False))
extra["flags"] = flags
for flag in flags:
await service.alert(
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
)
if flag == "phq9_due_mood":
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now)
if flag in DIARY_SCALE_TASKS:
sid = DIARY_SCALE_TASKS[flag]
await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now)
extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags)
else:
extra["totals"] = service.summary(row)
runtime = load_runtime()
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")
)
prior = next(
(
earlier = [
r
for r in reversed(await service.records(ctx.session, ctx.token))
for r in await service.records(ctx.session, ctx.token)
if r.id != row.id
and r.status == "complete"
and r.version == row.version
and r.details.get("totals", {}).get(sid, {}).get("complete")
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
),
None,
)
]
prior = earlier[-1] if earlier else None
if prior:
change = total - prior.details["totals"][sid]["total"]
extra["delta"] = {
"baseline_record": prior.id,
"value": change,
"retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"),
"retriage": change >= getattr(runtime, sid + "_retriage_delta"),
}
extra["interpretation"] = interpretation.scale_interpretation(
sid,
extra["totals"],
baseline=earlier[0].details["totals"][sid]["total"] if earlier else None,
previous=prior.details["totals"][sid]["total"] if prior else None,
policy=policy,
retriage_delta=getattr(runtime, sid + "_retriage_delta"),
)
if row.kind == "rus_audit_s" and not row.details.get("sex"):
return await manual_review(ctx, row, "alcohol_sex_unknown")
if row.kind == "rus_audit_s":
extra["interpretation"] = interpretation.scale_interpretation(
"rus_audit_s", extra["totals"], baseline=None, previous=None, policy=policy, retriage_delta=0
)
if extra["totals"]["rus_audit"].get("zone") == 4:
extra["parallel_routes"] = ["narcologist"]
retriage = extra.get("delta", {}).get("retriage", False)
@@ -257,15 +374,56 @@ async def advance(ctx, row):
params={"record_id": row.id, **extra},
)
await service.finish(ctx, row, "complete", extra=extra)
shown = interpretation.render(extra["interpretation"]) if extra.get("interpretation") else texts()["done"]
if row.kind == "cssrs_since_contact" and not await done_today(ctx, "diary_evening"):
# The risk questions open the evening diary; continue straight into it.
step = await start(ctx, "diary_evening")
step.replies.insert(0, Reply(text=shown, kind="info"))
return step
from app.dialog.flows.core import menu_reply
replies = [menu_reply(ctx, texts()["done"])]
alcohol_total = extra.get("totals", {}).get("rus_audit", {})
if alcohol_total.get("zone") in (2, 3):
replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info"))
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).
DIARY_SCALE_TASKS = {
"phq9_due_mood": "phq9",
"phq9_due_depression": "phq9",
"pcl5_due_ptsd": "pcl5",
"rus_audit_due_alcohol": "rus_audit_s",
}
async def apply_modules(ctx, modules):
"""Stage 1 result → diary modules of stage 2. Without a doctor the screening defines them; a doctor's modules
(diagnosis from ЕМИАС) are kept and only extended."""
profile = await ctx.profile()
if profile is None:
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id="", modules=list(modules))
return
current = list(profile.modules or [])
merged = [*current, *(m for m in modules if m not in current)] if profile.doctor_id else list(modules)
await repo.upsert_profile(ctx.session, token=ctx.token, modules=merged)
def local_day(ctx):
return ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
async def done_today(ctx, kind):
day = local_day(ctx)
return any(
r.kind == kind and r.day == day and r.status == "complete"
for r in await service.records(ctx.session, ctx.token)
)
async def start(ctx, kind="intake", task=None):
if not allowed(ctx) or crisis_open(ctx.data):
if task:
@@ -288,6 +446,8 @@ async def start(ctx, kind="intake", task=None):
if task:
return Step()
return Step(replies=[Reply(text=texts()["review"], kind="info")])
# A manual start takes over the scheduled task of the same form, so the task does not stay outstanding.
task = task or await service.outstanding_task(ctx.session, ctx.token, kind)
row = await service.create_record(ctx, kind, task=task)
return await advance(ctx, row)
@@ -316,14 +476,28 @@ async def checkin(ctx, event):
existing = await service.records(ctx.session, ctx.token)
if not any(r.kind == "intake" and r.status == "complete" for r in existing):
return await start(ctx)
day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing):
return Step(replies=[Reply(text=texts()["done"], kind="info")])
return await next_diary(ctx)
async def next_diary(ctx):
"""The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS
«since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk)."""
day = local_day(ctx)
done = {r.kind for r in await service.records(ctx.session, ctx.token) if r.day == day and r.status == "complete"}
for kind in DIARY_KINDS:
if kind in done:
continue
profile = await ctx.profile()
if kind == "diary_evening" and profile and profile.enhanced_monitoring and "cssrs_since_contact" not in done:
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
if risk_task.status in ("pending", "active"):
await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now)
return await start(ctx, "cssrs_since_contact", risk_task)
return await start(ctx, "diary")
return await start(ctx, kind)
return Step(replies=[Reply(text=texts()["diary_done"], kind="info")])
@trigger("button:diary:next")
async def diary_button(ctx, event):
return await next_diary(ctx)
@state("intake")
@@ -365,12 +539,16 @@ async def collect(ctx, event: Event):
elif event.type == "text" and q.numeric:
try:
value = float(str(ctx.text).replace(",", "."))
valid = math.isfinite(value) and 0 <= value <= 24
valid = math.isfinite(value) and 0 <= value <= q.maximum
except ValueError:
pass
elif event.type == "text" and q.optional:
value, valid = str(ctx.text)[:2000], True
if not valid:
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)])
hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric")
return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)])
service.save_answer(row, q, value, ctx.now)
if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime":
# Behaviour since the last contact is recent by definition (see cssrs_since_contact_question).
service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True)
return await advance(ctx, row)
+107
View File
@@ -0,0 +1,107 @@
"""Questions about the condition and its treatment → knowledge base of clinical guidelines (#64, RAG #60).
menu «Вопрос о лечении» ──▶ kb.question (free text) ──▶ rag.answer: KR quote + source, or «уточните у врача»
free text in idle that looks like a question (veteran_daily `text:idle`, after the check-in signals and the
drug question) ──▶ rag.answer, or the usual support phrase when the KB has no answer
The engine has already scrubbed the text, checked consent and run the safety check (ACUTE never gets here).
Questions about the patient's own drug stay with `med_qa` (instruction only, SPEC §7 п.5); this flow answers
general questions from the guidelines, extractively and without doses (`app.llm.rag.qa`). Logs carry codes only.
"""
import logging
import re
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict
from app.config import get_settings
from app.dialog.contracts import Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, handler_for, state, trigger
from app.llm import agent, rag
from app.llm.med_qa import DOSE_QUESTION
log = logging.getLogger(__name__)
QUESTION_STATE = "kb.question"
# `scrub()` placeholders and the punctuation around them: "[NAME], что такое ПТСР?" starts with "что такое".
_PLACEHOLDERS = re.compile(r"^(?:\s*\[[A-Z_]+\][\s,.:;!-]*)+")
class KnowledgeTexts(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
prompt: str
dose_redirect: str
question_starts: list[str]
small_talk: list[str]
@lru_cache
def load_knowledge_texts(path: Path | None = None) -> KnowledgeTexts:
path = path or get_settings().config_dir / "texts" / "knowledge.yaml"
return KnowledgeTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
@lru_cache
def _question_start() -> re.Pattern[str]:
starts = sorted(load_knowledge_texts().question_starts, key=len, reverse=True)
return re.compile(r"^\s*(?:" + "|".join(re.escape(s) for s in starts) + r")\b", re.IGNORECASE)
@lru_cache
def _small_talk() -> re.Pattern[str]:
return re.compile(r"\b(?:" + "|".join(re.escape(s) for s in load_knowledge_texts().small_talk) + r")\b", re.I)
def looks_like_question(text: str) -> bool:
"""A "?" anywhere or a question word at the start ("что такое флэшбек", "можно ли ..."); small talk
("как дела?") is not a question for the knowledge base."""
text = _PLACEHOLDERS.sub("", text)
if _small_talk().search(text):
return False
return "?" in text or bool(_question_start().search(text))
async def knowledge_answer(ctx: Ctx, *, explicit: bool) -> str | None:
"""KB answer for `ctx.text`. No answer → «уточните у врача» if the patient asked via the menu (`explicit`),
`None` otherwise (the caller keeps its own reply, e.g. the support phrase)."""
if ctx.text is None:
return None
if DOSE_QUESTION.search(ctx.text.lower()):
log.info("kb: dose question redirected to doctor")
return load_knowledge_texts().dose_redirect
# Tool-using agent first (RAG + ЕМИАС record + drug-safety check); disabled/failed → extractive RAG below.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
log.info("kb: agent answered provider=%s tools=%s", smart.provider, ",".join(smart.tools))
return agent.render(smart)
reply = await rag.answer(ctx.text, audience="patient")
if reply is not None:
log.info("kb: answered docs=%s", ",".join(c.doc for c in reply.citations))
return reply.text
log.info("kb: no answer explicit=%s", explicit)
return rag.fallback_text("patient") if explicit else None
@trigger("button:menu:kb_question")
async def _menu_question(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=load_knowledge_texts().prompt)], next_state=QUESTION_STATE)
@state(QUESTION_STATE)
async def _question(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
# A menu button or a reminder instead of the question: drop the question and let idle handle it.
step = Step(next_state="idle")
idle = handler_for("idle")
return step.then(await idle(ctx, event)) if idle is not None else step
text = await knowledge_answer(ctx, explicit=True)
return Step(replies=[menu_reply(ctx, text)], next_state="idle")
# A question asked yesterday must not swallow today's message.
abandonable("kb")
+16 -4
View File
@@ -42,10 +42,13 @@ from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
from app.integrations.base import integrations
from app.llm.med_qa import fallback_text, med_qa
from app.llm import agent
from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa
QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
# A client sends this button when the person submits a free-text step without writing anything.
NO_COMMENT = "text:skip"
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
@@ -378,7 +381,8 @@ def _side_effect_code(code: str) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if code == "other":
return Step(
replies=[Reply(text=load_meds_texts().side_effects.describe)],
# kind="question": the engine waits for free text here (clients show an input, optional).
replies=[Reply(text=load_meds_texts().side_effects.describe, kind="question")],
next_state=SIDE_EFFECT_TEXT_STATE,
data={"med_se_ref": ref},
)
@@ -393,12 +397,15 @@ for _code in SIDE_EFFECT_CODES:
@state(SIDE_EFFECT_TEXT_STATE)
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
ref = ctx.data.get("med_se_ref")
if event.type == "button" and event.button == NO_COMMENT:
# The description is optional: «other» side effect without words.
return await _after_side_effects(ctx, ["other"], str(ref) if ref else None)
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event, {"med_se_ref": None})
facts = await ctx.gateway.extract_checkin(ctx.text)
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
ref = ctx.data.get("med_se_ref")
return await _after_side_effects(ctx, codes, str(ref) if ref else None)
@@ -428,13 +435,18 @@ async def _menu_question(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().question
if await current_prescription(ctx) is None:
return Step(replies=[menu_reply(ctx, t.no_prescription)])
return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE)
return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE)
async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
if rx is None or ctx.text is None:
return load_meds_texts().question.no_prescription
if DOSE_QUESTION.search(ctx.text.lower()) is None:
# Agent: instruction + ЕМИАС record (other drugs, conditions, intolerances) + guidelines; else excerpt only.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return agent.render(smart)
answer = await med_qa(rx.drug, ctx.text)
return answer.text if answer is not None else fallback_text()
+21 -2
View File
@@ -47,6 +47,7 @@ from app.db import repo
from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows import intake
from app.dialog.flows.core import menu_reply
from app.dialog.flows.knowledge import knowledge_answer, looks_like_question
from app.dialog.flows.meds import (
answer_question,
checkin_of,
@@ -61,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")
@@ -423,11 +425,16 @@ async def _visit(ctx: Ctx, event: Event) -> Step:
@trigger("text:idle", replace=True)
async def _idle_text(ctx: Ctx, event: Event) -> Step:
"""Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol
→ today's check-in (the rules react); otherwise the neutral support phrase with the menu."""
→ today's check-in (the rules react); another question → clinical guidelines (knowledge base); otherwise
the neutral support phrase with the menu."""
assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text)
if facts is None:
return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))])
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] = {}
if facts.quit_intent:
fields["quit_intent"] = True
@@ -443,8 +450,20 @@ 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).
# Quit intent gets the rule reaction (delayed-onset explanation + doctor notified) — no second answer.
if text is None and not facts.quit_intent and looks_like_question(ctx.text):
text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64)
if text is None:
text = await ctx.gateway.phrase("support")
return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects))
+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)
+5 -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):
@@ -47,6 +48,7 @@ class AgeGateTexts(_Strict):
class IdleButtons(_Strict):
checkin: str
med_question: str
kb_question: str
contact_doctor: str
revoke: str
@@ -90,6 +92,7 @@ class ReachOutTexts(_Strict):
silence: str
missed_doses: str
default: str
after_crisis: str
class VisitTexts(_Strict):
@@ -110,6 +113,7 @@ class DialogTexts(_Strict):
reach_out: ReachOutTexts
visit: VisitTexts
unavailable: str
side_answer_return: str = "Вернёмся к вопросу:"
def dialog_texts_path() -> Path:
@@ -119,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"))))
+51
View File
@@ -0,0 +1,51 @@
"""Demo adapters behind the frozen integration Protocols (SPEC §9, #14).
`setup()` runs when the package is first imported. Every consumer imports `app.integrations.base`, which
initialises this package first, and `app/main.py` is a frozen contract — so the registry is wired before
any request or job without a startup hook. Setup only constructs objects: data files and DB sessions are
opened lazily on the first call, so importing the package never fails on I/O.
Registry after setup:
- `prescriptions` — `MisMock`: clinical DB `prescriptions`, then `data/meds/mis_prescriptions.json`;
- `identity` — `ConnectCodeIdentity`: one-time connect code from the doctor (ЕСИА stand-in);
- `drug_info` — `GrlsExcerpts`: `data/meds/grls/*.yaml`;
- `notifier` — `CompositeNotifier`: in-memory dashboard feed + optional Telegram doctor chat;
- `outbox` — unchanged `MemoryOutbox` (the web console drains it; the Telegram channel wraps it at start).
"""
from app.integrations.base import (
EmptyPrescriptions,
MemoryNotifier,
NoDrugInfo,
RejectIdentity,
integrations,
)
from app.integrations.connect_code import ConnectCodeIdentity
from app.integrations.grls import GrlsExcerpts
from app.integrations.mis_mock import MisMock
from app.integrations.notify import CompositeNotifier, NotificationFeed
__all__ = ["CompositeNotifier", "ConnectCodeIdentity", "GrlsExcerpts", "MisMock", "NotificationFeed", "setup"]
_done = False
def setup(*, force: bool = False) -> None:
"""Register the demo adapters. Idempotent; never replaces an adapter someone already swapped in
(a test fake, the Telegram outbox), only the in-memory defaults from `base.py` — unless `force`."""
global _done
if _done and not force:
return
if force or isinstance(integrations.prescriptions, EmptyPrescriptions):
integrations.prescriptions = MisMock()
if force or isinstance(integrations.identity, RejectIdentity):
integrations.identity = ConnectCodeIdentity()
if force or isinstance(integrations.drug_info, NoDrugInfo):
integrations.drug_info = GrlsExcerpts()
# Exact type check: MemoryNotifier subclasses used as test sinks must stay in place.
if force or type(integrations.notifier) is MemoryNotifier:
integrations.notifier = CompositeNotifier()
_done = True
setup()
+58
View File
@@ -0,0 +1,58 @@
"""ЕСИА stand-in: the patient proves identity with the one-time connect code issued by the doctor.
The doctor creates the code in «Подключить пациента» (identity DB `connect_codes`); the patient opens the
QR deep link / enters the code. In prod this is ЕСИА OAuth; the `IdentityProvider` Protocol stays the same.
"""
import logging
from datetime import UTC, timedelta
from sqlalchemy.exc import SQLAlchemyError
from app.core import clock
from app.integrations.base import VerifiedPerson
log = logging.getLogger(__name__)
# One connect flow verifies the same code twice: the Telegram channel on `/start <code>` (to bind the chat)
# and then the dialog engine's `new` state (to check the code belongs to this token). A short grace window
# lets the second check pass; after it a used code is rejected like an unknown one.
REDEEM_GRACE = timedelta(minutes=15)
class ConnectCodeIdentity:
"""`IdentityProvider` over identity-DB connect codes."""
def __init__(self, grace: timedelta = REDEEM_GRACE) -> None:
self.grace = grace
async def verify(self, auth_code: str) -> VerifiedPerson | None:
code = auth_code.strip()
if not code:
return None
# Imported lazily: the integrations package is imported by clinical-side modules, which must not
# pull the identity schema into their import graph (guardrail #5).
from app.db import identity_repo
from app.db.identity import ConnectCode
from app.db.session import get_identity_session
now = clock.now()
try:
async with get_identity_session() as s:
row = await s.get(ConnectCode, code)
if row is None:
log.info("connect code rejected: unknown")
return None
person = VerifiedPerson(token=row.token, doctor_id=row.doctor_id)
if row.used_at is not None:
used_at = row.used_at if row.used_at.tzinfo else row.used_at.replace(tzinfo=UTC)
if now - used_at <= self.grace:
return person
log.info("connect code rejected: already used")
return None
if await identity_repo.redeem_connect_code(s, code=code, now=now) is None:
return None
return person
except (OSError, SQLAlchemyError) as exc:
log.warning("connect code check failed error=%s", type(exc).__name__)
return None
+35
View File
@@ -0,0 +1,35 @@
"""ЕМИАС sign-in mock for the patient web app (SPEC §9 `esia_mock`: registration by the doctor's code).
Real ЕМИАС / ЕСИА OAuth needs a registered information system, so the prototype accepts the one-time connect
code the doctor hands out after the visit (323-FZ art. 36.2): code → patient token + attending doctor. The
class implements `IdentityProvider`, so the real OAuth client replaces it without touching the web channel.
It is deliberately NOT installed into `integrations.identity`: the Telegram channel verifies a code twice
(on `/start` and in the core flow), which a one-time redeeming provider would break. Lane B wires the
production provider for all channels (#14).
This module may use the identity DB: it is the registration boundary (guardrail #5).
"""
from app.core import clock
from app.db import identity_repo
from app.db.identity import ConnectCode
from app.db.session import get_identity_session
from app.integrations.base import VerifiedPerson
CODE_MAX_LEN = 32
class EmiasMock:
async def verify(self, auth_code: str) -> VerifiedPerson | None:
code = auth_code.strip().upper()
if not code or len(code) > CODE_MAX_LEN:
return None
async with get_identity_session() as s:
row = await s.get(ConnectCode, code)
if row is None:
return None
token = await identity_repo.redeem_connect_code(s, code=code, now=clock.now())
if token is None:
return None
return VerifiedPerson(token=token, doctor_id=row.doctor_id)
+102
View File
@@ -0,0 +1,102 @@
"""ЕМИАС medical record mock: diagnoses, comorbidities, allergies and other specialists' prescriptions.
Prod: the regional МИС / ЕГИСЗ adapter replaces `EmiasRecordSource` (same `get(token)` contract). Demo: synthetic
records from `data/emias/records.yaml` keyed by synthetic SNILS, converted to tokens with `pseudo_id()` at load
time — so the record handed to the chat agent carries no SNILS, name or phone (guardrail #1). The current
psychiatric prescription comes from the МИС mock (`integrations.prescriptions`).
"""
import logging
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.core.privacy import pseudo_id
from app.integrations.base import integrations
log = logging.getLogger(__name__)
class Diagnosis(BaseModel):
code: str
name: str
class Condition(BaseModel):
name: str
tags: list[str] = Field(default_factory=list)
class OtherMedication(BaseModel):
drug: str
how: str = ""
prescribed_by: str = ""
classes: list[str] = Field(default_factory=list)
class CurrentPrescription(BaseModel):
drug: str
dose: str
instructions: str = ""
class PatientRecord(BaseModel):
"""Clinical-only view of the record (no identifiers): what the chat agent may see."""
diagnoses: list[Diagnosis] = Field(default_factory=list)
conditions: list[Condition] = Field(default_factory=list)
allergies: list[Condition] = Field(default_factory=list)
other_medications: list[OtherMedication] = Field(default_factory=list)
pregnancy: dict[str, str] | None = None
current_prescriptions: list[CurrentPrescription] = Field(default_factory=list)
def default_records_path() -> Path:
return get_settings().data_dir / "emias" / "records.yaml"
@lru_cache
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():
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
class EmiasRecordSource:
def __init__(self, path: Path | None = None) -> None:
self._path = path
async def get(self, token: str) -> PatientRecord | None:
try:
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
record = base.model_copy(deep=True) if base is not None else PatientRecord()
try:
rx = await integrations.prescriptions.get_prescriptions(token)
except Exception as exc: # noqa: BLE001 — the МИС being down must not break the chat
log.error("prescriptions unavailable: %s", type(exc).__name__)
rx = []
record.current_prescriptions = [
CurrentPrescription(drug=p.drug, dose=p.dose, instructions=p.instructions) for p in rx
]
if base is None and not rx:
return None
return record
emias_records = EmiasRecordSource()
+80
View File
@@ -0,0 +1,80 @@
"""ГРЛС stand-in: short excerpts of the official drug instruction, per section (SPEC §9, SPEC §7 п.5).
Each `data/meds/grls/<name>.yaml` holds one drug:
inn: сертралин
aliases: [sertraline]
sections:
side_effects: {text: "...", source: "ГРЛС, инструкция по медицинскому применению, раздел «...»"}
Section codes are the ones `app/llm/med_qa.py` understands (side_effects, onset, alcohol, discontinuation,
administration, interactions, overdose). The bot answers drug questions ONLY from these texts, so they are
the single place to verify against the current ГРЛС instruction (see the header of each file).
"""
import logging
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, ValidationError
from app.config import get_settings
from app.integrations.base import DrugSection
log = logging.getLogger(__name__)
class _SectionFile(BaseModel):
text: str
source: str
class _DrugFile(BaseModel):
inn: str
aliases: list[str] = Field(default_factory=list)
sections: dict[str, _SectionFile]
def default_grls_dir() -> Path:
return get_settings().data_dir / "meds" / "grls"
def normalize_inn(name: str) -> str:
"""Lookup key: «Сертралин 50 мг», «сертралин», «Sertraline» -> first word, lower case, ё -> е."""
words = name.strip().lower().replace("ё", "е").split()
return words[0] if words else ""
class GrlsExcerpts:
"""`DrugInfo` over local YAML excerpts. Files are read lazily on the first lookup and cached."""
def __init__(self, directory: Path | None = None) -> None:
self._dir = directory
self._index: dict[str, list[DrugSection]] | None = None
def _load(self) -> dict[str, list[DrugSection]]:
if self._index is not None:
return self._index
directory = self._dir or default_grls_dir()
index: dict[str, list[DrugSection]] = {}
for path in sorted(directory.glob("*.yaml")):
try:
drug = _DrugFile.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
except (OSError, yaml.YAMLError, ValidationError) as exc:
log.warning("grls file skipped file=%s error=%s", path.name, type(exc).__name__)
continue
sections = [
DrugSection(inn=drug.inn, section=code, text=" ".join(s.text.split()), source=s.source)
for code, s in drug.sections.items()
]
for key in (drug.inn, *drug.aliases):
index[normalize_inn(key)] = sections
self._index = index
return index
def known(self) -> list[str]:
"""INNs with excerpts (for the dashboard / tests)."""
return sorted({s[0].inn for s in self._load().values() if s})
async def sections(self, inn: str) -> list[DrugSection]:
return [s.model_copy() for s in self._load().get(normalize_inn(inn), [])]
+201
View File
@@ -0,0 +1,201 @@
"""Registration boundary and durable MAX queue. Callers own the identity transaction.
Only this boundary joins a pseudonymous profile to a MAX recipient. Clinical modules never import it.
Queue rows contain a template name, never replies, scores, prescriptions or patient-auth tokens in URLs.
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import repo
from app.db.clinical import ScaleResult
from app.db.intake import QuestionnaireRecord
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.db.session import get_clinical_session
MAX_ATTEMPTS = 6
LEASE_SECONDS = 60
def digest(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
async def active_patient(token: str) -> bool:
"""Same consent/adult gate used by the scheduler, checked again at delivery time."""
async with get_clinical_session() as session:
state = await repo.get_dialog_state(session, token)
return bool(
await repo.get_profile(session, token)
and state
and state.data.get("consent") is True
and state.data.get("adult") is True
)
async def issue_code(session: AsyncSession, token: str, now: datetime, ttl: int) -> tuple[str, datetime]:
raw = secrets.token_urlsafe(32)
expires = now + timedelta(seconds=ttl)
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
session.add(MaxLinkCode(digest=digest(raw), token=token, expires_at=expires))
await session.flush()
return raw, expires
async def code_token(session: AsyncSession, raw: str, now: datetime) -> str | None:
row = await session.get(MaxLinkCode, digest(raw))
return row.token if row and row.used_at is None and row.expires_at > now else None
async def bind(session: AsyncSession, raw: str, user_id: int, now: datetime) -> str | None:
"""Atomically consume a code. Existing active identities cannot be silently reassigned."""
token = await code_token(session, raw, now)
if token is None:
return None
owner = await session.scalar(select(MaxBinding).where(MaxBinding.user_id == user_id))
binding = await session.get(MaxBinding, token)
if owner and owner.token != token and owner.enabled:
return None
if binding and binding.user_id != user_id and binding.enabled:
return None
used = await session.execute(
update(MaxLinkCode)
.where(MaxLinkCode.digest == digest(raw), MaxLinkCode.used_at.is_(None), MaxLinkCode.expires_at > now)
.values(used_at=now)
.returning(MaxLinkCode.token)
)
if used.scalar_one_or_none() is None:
return None
if owner and owner.token != token:
await session.delete(owner)
await session.flush()
if binding is None:
binding = MaxBinding(token=token, user_id=user_id, connected_at=now)
session.add(binding)
# Cancel stale deliveries even when the same account reconnects later.
await cancel_pending(session, token)
binding.user_id, binding.enabled, binding.connected_at = user_id, True, now
await session.flush()
return token
async def cancel_pending(session: AsyncSession, token: str) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.token == token, MaxDelivery.state.in_(("queued", "sending")))
.values(state="cancelled", lease=None)
)
async def disconnect(session: AsyncSession, token: str) -> None:
binding = await session.get(MaxBinding, token)
if binding:
binding.enabled = False
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
await cancel_pending(session, token)
async def enqueue(
session: AsyncSession, *, key: str, user_id: int, kind: str, now: datetime, token: str | None = None
) -> None:
if await session.get(MaxDelivery, key) is not None:
return
try:
async with session.begin_nested():
session.add(
MaxDelivery(id=key, user_id=user_id, token=token, kind=kind, created_at=now, next_attempt_at=now)
)
await session.flush()
except IntegrityError:
# Another worker may enqueue the same event between SELECT and INSERT.
if await session.get(MaxDelivery, key) is None:
raise
async def collect_completed(session: AsyncSession, now: datetime) -> None:
"""Recover completion events from committed clinical records, including after a restart.
No clinical hooks depend on identity. Read only IDs/dates, then enqueue a generic template.
A binding's activation time bounds history; repeated scans are idempotent.
"""
bindings = (await session.scalars(select(MaxBinding).where(MaxBinding.enabled.is_(True)))).all()
for binding in bindings:
if not await active_patient(binding.token):
continue
async with get_clinical_session() as clinical:
forms = (
await clinical.scalars(
select(QuestionnaireRecord.id).where(
QuestionnaireRecord.token == binding.token,
QuestionnaireRecord.status == "complete",
QuestionnaireRecord.completed_at >= binding.connected_at,
)
)
).all()
scales = (
await clinical.scalars(
select(ScaleResult.id).where(
ScaleResult.token == binding.token,
ScaleResult.complete.is_(True),
ScaleResult.ts >= binding.connected_at,
)
)
).all()
for source, ids in (("form", forms), ("scale", scales)):
for record_id in ids:
key = digest(f"completed:{binding.token}:{binding.connected_at.isoformat()}:{source}:{record_id}")
await enqueue(session, key=key, token=binding.token, user_id=binding.user_id, kind="completed", now=now)
async def claim(session: AsyncSession, now: datetime) -> MaxDelivery | None:
"""One lease per row; recovery after a worker crash is bounded by the lease deadline."""
ready = (MaxDelivery.state.in_(("queued", "sending"))) & (MaxDelivery.next_attempt_at <= now)
row = await session.scalar(
select(MaxDelivery)
.where(ready)
.order_by(MaxDelivery.created_at, MaxDelivery.id)
.with_for_update(skip_locked=True)
.limit(1)
)
if row is None:
return None
if row.attempts >= MAX_ATTEMPTS:
row.state, row.error_code, row.lease = "failed", "attempt_limit", None
return None
lease = secrets.token_hex(16)
result = await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, ready)
.values(
state="sending",
attempts=MaxDelivery.attempts + 1,
lease=lease,
next_attempt_at=now + timedelta(seconds=LEASE_SECONDS),
)
.returning(MaxDelivery.id)
)
if result.scalar_one_or_none() is None:
return None
await session.refresh(row)
return row
async def finish(
session: AsyncSession,
row: MaxDelivery,
*,
state: str,
now: datetime,
error: str | None = None,
retry_seconds: float = 0,
) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, MaxDelivery.lease == row.lease, MaxDelivery.state == "sending")
.values(state=state, lease=None, error_code=error, next_attempt_at=now + timedelta(seconds=retry_seconds))
)
+97
View File
@@ -0,0 +1,97 @@
"""МИС / ЕГИСЗ stand-in: the patient's prescriptions (SPEC §9).
Source order: the clinical DB table `prescriptions` (written when the doctor connects a patient or by the
demo seed), then the synthetic catalogue `data/meds/mis_prescriptions.json` keyed by token. In prod this is
a regional МИС query; the Protocol stays the same.
"""
import json
import logging
from datetime import date
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings
from app.integrations.base import Prescription
log = logging.getLogger(__name__)
class _CatalogPatient(BaseModel):
persona: str
tokens: list[str] = Field(default_factory=list)
prescriptions: list[Prescription]
class _Catalog(BaseModel):
patients: list[_CatalogPatient]
def _from_row(drug: str, dose: str, schedule: dict[str, Any], start: date | None, end: date | None) -> Prescription:
# The table has no instructions column; the doctor's note («утром после еды») may ride in the schedule JSON.
times = {k: [str(x) for x in v] for k, v in schedule.items() if isinstance(v, list)}
return Prescription(
drug=drug,
dose=dose,
schedule=times or {"times": ["08:00"]},
instructions=str(schedule.get("instructions") or ""),
start_date=start,
end_date=end,
)
def default_catalog_path() -> Path:
return get_settings().data_dir / "meds" / "mis_prescriptions.json"
class MisMock:
"""`PrescriptionSource`: clinical DB first, synthetic JSON catalogue as the fallback."""
def __init__(self, catalog_path: Path | None = None, *, use_db: bool = True) -> None:
self._path = catalog_path
self._use_db = use_db
self._by_token: dict[str, list[Prescription]] | None = None
def _catalog(self) -> dict[str, list[Prescription]]:
# Lazy: the package is imported before startup, a missing/broken file must not break the import.
if self._by_token is None:
path = self._path or default_catalog_path()
index: dict[str, list[Prescription]] = {}
try:
catalog = _Catalog.model_validate(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError, ValidationError) as exc:
log.warning("mis_mock catalogue not loaded error=%s", type(exc).__name__)
catalog = _Catalog(patients=[])
for patient in catalog.patients:
for token in patient.tokens:
index[token] = patient.prescriptions
self._by_token = index
return self._by_token
async def _from_db(self, token: str) -> list[Prescription]:
from app.db.clinical import Prescription as PrescriptionRow
from app.db.session import get_clinical_session
async with get_clinical_session() as s:
rows = (
await s.scalars(
select(PrescriptionRow).where(PrescriptionRow.token == token).order_by(PrescriptionRow.id)
)
).all()
return [_from_row(r.drug, r.dose, r.schedule or {}, r.start_date, r.end_date) for r in rows]
async def get_prescriptions(self, token: str) -> list[Prescription]:
if self._use_db:
try:
found = await self._from_db(token)
except (OSError, SQLAlchemyError) as exc:
# DB down or not migrated: the demo still shows the catalogue prescription.
log.warning("mis_mock db lookup failed error=%s", type(exc).__name__)
found = []
if found:
return found
return [p.model_copy(deep=True) for p in self._catalog().get(token, [])]
+141
View File
@@ -0,0 +1,141 @@
"""Doctor notifications (SPEC §9): in-memory feed for the dashboard + optional Telegram doctor chat.
The alert itself is already committed to the clinical DB before `send()` is called (the dashboard reads
alerts from there via the API), so every channel here is best effort: failures are logged, never raised.
Messages carry only the level, the rule reason/code and a short pseudonymous patient id — no PII
(guardrail #8); the reason texts come from `config/rules.yaml`, not from patient messages.
"""
import asyncio
import itertools
import logging
from collections import deque
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from app.config import get_settings
from app.core import clock
from app.integrations.base import AlertLevelName
log = logging.getLogger(__name__)
FEED_SIZE = 500
SHORT_ID_LEN = 6
MAX_REASON_CHARS = 200
TELEGRAM_TIMEOUT_S = 5.0
# Doctor-facing labels (not patient texts); acute alerts are visually distinct in the chat.
LEVEL_LABELS: dict[str, str] = {
"acute": "КРИТИЧНО",
"high": "Высокий",
"moderate": "Умеренный",
"info": "Инфо",
}
ChatSender = Callable[[int, str], Awaitable[Any]]
def short_id(token: str | None) -> str:
return token[:SHORT_ID_LEN] if token else "—"
def format_message(level: str, text: str, token: str | None) -> str:
reason = " ".join(text.split())
if len(reason) > MAX_REASON_CHARS:
reason = reason[: MAX_REASON_CHARS - 1] + "…"
return f"[{LEVEL_LABELS.get(level, level)}] {reason}\nПациент #{short_id(token)}"
class Notification(BaseModel):
id: int
ts: datetime
doctor_id: str
level: str
text: str
token: str | None = None
class NotificationFeed:
"""Bounded in-memory feed; ids increase monotonically so a poller asks only for `recent(since_id)`."""
def __init__(self, size: int = FEED_SIZE) -> None:
self._items: deque[Notification] = deque(maxlen=size)
self._ids = itertools.count(1)
def add(self, doctor_id: str, level: str, text: str, token: str | None, ts: datetime) -> Notification:
item = Notification(id=next(self._ids), ts=ts, doctor_id=doctor_id, level=level, text=text, token=token)
self._items.append(item)
return item
def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]:
return [n for n in self._items if n.id > since_id and (doctor_id is None or n.doctor_id == doctor_id)]
class TelegramDoctorChat:
"""Sends to the doctor chat `DOCTOR_NOTIFY_CHAT_ID` through the bot `TG_BOT_TOKEN`.
The aiogram `Bot` is created on the first message only; with either setting empty the channel is off.
"""
def __init__(self, chat_id: int | None = None, bot_token: str | None = None, sender: ChatSender | None = None):
settings = get_settings()
self.chat_id = chat_id if chat_id is not None else settings.doctor_notify_chat_id
self._bot_token = bot_token if bot_token is not None else settings.tg_bot_token
self._sender = sender
self._bot: Any = None
@property
def enabled(self) -> bool:
return self.chat_id is not None and (self._sender is not None or bool(self._bot_token))
def _get_sender(self) -> ChatSender:
if self._sender is None:
from app.channels.telegram import make_bot
self._bot = make_bot(self._bot_token)
self._sender = self._bot.send_message
return self._sender
async def send(self, level: str, text: str, token: str | None) -> None:
if not self.enabled or self.chat_id is None:
return
try:
await asyncio.wait_for(
self._get_sender()(self.chat_id, format_message(level, text, token)), timeout=TELEGRAM_TIMEOUT_S
)
except Exception as exc: # noqa: BLE001 — any channel error; the alert is already in the DB
err = type(exc).__name__
log.warning("doctor chat notify failed level=%s tok=%s error=%s", level, short_id(token), err)
async def aclose(self) -> None:
if self._bot is not None:
await self._bot.session.close()
self._bot = None
self._sender = None
class CompositeNotifier:
"""`Notifier`: dashboard feed + Telegram doctor chat (when configured)."""
def __init__(self, feed: NotificationFeed | None = None, chat: TelegramDoctorChat | None = None) -> None:
self.feed = feed or NotificationFeed()
self._chat = chat
@property
def chat(self) -> TelegramDoctorChat:
# Lazy: settings are read on the first notification, not when the package is imported.
if self._chat is None:
self._chat = TelegramDoctorChat()
return self._chat
def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]:
return self.feed.recent(since_id, doctor_id)
async def send(self, doctor_id: str, level: AlertLevelName, text: str, token: str | None = None) -> None:
try:
self.feed.add(doctor_id, level, text, token, clock.now())
except Exception as exc: # noqa: BLE001 — never break the caller
log.warning("notify feed failed error=%s", type(exc).__name__)
await self.chat.send(level, text, token)
+200
View File
@@ -0,0 +1,200 @@
"""Chat agent: tool-using LLM over the knowledge base (RAG), the ЕМИАС record and the drug-safety reference.
from app.llm.agent import answer_chat
reply = await answer_chat(token, scrubbed_question) # None → caller falls back to extractive RAG / med_qa
Guarantees (on top of what the dialog engine already did — scrub, consent, safety check; ACUTE never gets here):
* only scrubbed text goes to the provider (`assert_scrubbed`), tool results carry no identifiers;
* tools are bound to the current token server-side; read-only;
* the final text passes the gateway's forbidden-output filter (diagnoses, dose/drug advice); a violation or any
provider error → None, so the patient gets the safe extractive answer instead;
* logs carry tool names and counts only.
"""
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,
MockAgentProvider,
OpenRouterProvider,
ProviderError,
ToolChatProvider,
)
from app.llm.agent.tools import TOOL_SPECS, ToolContext, run_tool
from app.llm.gateway import is_forbidden
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"}
class AgentAnswer(BaseModel):
text: str
sources: list[str]
tools: list[str]
provider: str
def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
s = settings or get_settings()
mode = s.chat_agent
if mode == "auto":
mode = "gigachat" if s.gigachat_credentials else "openrouter" if s.openrouter_api_key else "off"
if mode == "openrouter":
if not s.openrouter_api_key:
log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled")
return None
return OpenRouterProvider(s)
if mode == "mock":
return MockAgentProvider()
if mode == "gigachat":
if not s.gigachat_credentials:
log.warning("chat agent: gigachat selected without GIGACHAT_CREDENTIALS — disabled")
return None
return GigaChatToolProvider(s)
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`
try:
return await provider.chat(wire, TOOL_SPECS)
except ProviderError as exc:
log.warning("chat agent provider=%s retry after %s", provider.name, exc)
return await provider.chat(wire, TOOL_SPECS)
async def _patient_brief(ctx: ToolContext) -> str:
"""One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers)."""
rec = await ctx.record()
if rec is None or not rec.current_prescriptions:
return "\n\nТекущее назначение психиатра: нет данных."
drugs = ", ".join(p.drug for p in rec.current_prescriptions)
return f"\n\nТекущее назначение психиатра: {drugs}. «Мои таблетки» = это назначение."
def _wire(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip internal keys (tool `name`) before sending."""
return [{k: v for k, v in m.items() if k in _ALLOWED_MSG_KEYS} for m in messages]
async def answer_chat(
token: str, question: ScrubbedText, *, provider: ToolChatProvider | None = None, settings: Settings | None = None
) -> AgentAnswer | None:
s = settings or get_settings()
provider = provider or build_provider(s)
if provider is None:
return None
try:
assert_scrubbed(question)
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)},
{"role": "user", "content": str(question)},
]
nudged = False
try:
async with asyncio.timeout(s.chat_agent_timeout_s * 2):
for _ in range(s.chat_agent_max_steps):
turn = await _chat_with_retry(provider, messages)
if not turn.tool_calls:
if not (turn.content or "").strip() and not nudged:
# Some models (Gemini Flash) occasionally end with an empty message after tool results.
nudged = True
messages.append({"role": "user", "content": NUDGE})
continue
return _finish(turn.content or "", ctx, provider.name)
messages.append(
{
"role": "assistant",
"content": turn.content,
"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[:MAX_TOOL_CALLS_PER_TURN]
],
}
)
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))
return None
except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
return None
except Exception: # noqa: BLE001 — the agent is optional: any bug must degrade to the extractive answer, not a 500
log.exception("chat agent provider=%s crashed", provider.name)
return None
def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
text = text.strip()
if not text:
return None
if is_forbidden(text):
log.warning("chat agent: forbidden output filtered provider=%s", provider)
return None
if len(text) > MAX_ANSWER_CHARS:
text = text[:MAX_ANSWER_CHARS].rsplit(" ", 1)[0] + "…"
log.info("chat agent ok provider=%s tools=%s sources=%d", provider, ",".join(ctx.used), len(ctx.sources))
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 (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)
+59
View File
@@ -0,0 +1,59 @@
"""Try the chat agent from the shell (synthetic personas only):
uv run python -m app.llm.agent "Можно мне ибупрофен вместе с моими таблетками?" --persona andrey
CHAT_AGENT=mock uv run python -m app.llm.agent "Есть ли лактоза в сертралине?"
Prescriptions come from the МИС mock (DB if reachable, else data/meds/mis_prescriptions.json); the question is
scrubbed before it reaches any provider.
"""
import argparse
import asyncio
import logging
from app.config import get_settings
from app.core.privacy import pseudo_id, scrub
from app.integrations.base import Prescription, integrations
from app.llm.agent import answer_chat, build_provider, render
PERSONAS = { # synthetic SNILS from scripts/seed_demo.py → current psychiatric prescription
"andrey": ("900-000-013 34", "сертралин"),
"dmitry": ("900-000-041 01", "сертралин"),
"sergey": ("900-000-037 02", "пароксетин"),
"nikolay": ("900-000-052 03", "сертралин"),
"anna": ("900-000-023 04", "флуоксетин"),
"pavel": ("900-000-045 07", "эсциталопрам"),
}
class _FixedRx:
def __init__(self, drug: str) -> None:
self.drug = drug
async def get_prescriptions(self, token: str) -> list[Prescription]:
return [Prescription(drug=self.drug, dose="по назначению врача")]
async def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("question")
p.add_argument("--persona", default="andrey", choices=sorted(PERSONAS))
p.add_argument("-v", "--verbose", action="store_true")
a = p.parse_args()
logging.basicConfig(level=logging.INFO if a.verbose else logging.WARNING)
snils, drug = PERSONAS[a.persona]
integrations.prescriptions = _FixedRx(drug) # CLI runs without the DB
provider = build_provider()
if provider is None:
raise SystemExit(
f"agent disabled (CHAT_AGENT={get_settings().chat_agent}); set OPENROUTER_API_KEY or CHAT_AGENT=mock"
)
ans = await answer_chat(pseudo_id(snils), scrub(a.question), provider=provider)
if ans is None:
raise SystemExit("no answer (provider error, filtered output or nothing found) — chat falls back to RAG")
print(render(ans))
print(f"\n[provider={ans.provider} tools={', '.join(ans.tools)}]")
if __name__ == "__main__":
asyncio.run(main())
+147
View File
@@ -0,0 +1,147 @@
"""Deterministic drug-safety lookup: composition, contraindications, interactions, patient-specific findings.
The LLM never decides whether a drug is safe for the patient (ADR-007 spirit): it calls `check_patient` and
retells the findings. Matching is by tags shared with the ЕМИАС record (`data/emias/records.yaml`):
condition tags → `cautions[].when_condition`, allergy tags → `composition.excipient_tags`, other medication
classes → `interactions[].with_class`. Source data: `data/meds/drug_safety.yaml` (prototype retelling of ГРЛС).
"""
from functools import lru_cache
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.integrations.emias_record import PatientRecord
Severity = Literal["high", "medium", "low"]
class Caution(BaseModel):
when_condition: str
severity: Severity
text: str
class Interaction(BaseModel):
with_class: str
severity: Severity
text: str
class Composition(BaseModel):
form: str
active: str
excipients: list[str] = Field(default_factory=list)
excipient_tags: list[str] = Field(default_factory=list)
note: str = ""
class DrugEntry(BaseModel):
inn: str
aliases: list[str] = Field(default_factory=list)
drug_class: str = ""
classes: list[str] = Field(default_factory=list) # classes this drug belongs to (e.g. nsaid, ssri)
contraindications: list[str] = Field(default_factory=list)
cautions: list[Caution] = Field(default_factory=list)
interactions: list[Interaction] = Field(default_factory=list)
composition: Composition | None = None
pregnancy: str = ""
source: str = ""
class Finding(BaseModel):
severity: Severity
kind: Literal["condition", "allergy", "interaction", "pregnancy", "contraindication"]
text: str
class SafetyDb(BaseModel):
drugs: dict[str, DrugEntry]
allergy_labels: dict[str, str] = Field(default_factory=dict)
def _path() -> Path:
return get_settings().data_dir / "meds" / "drug_safety.yaml"
@lru_cache
def load_db(path: Path | None = None) -> SafetyDb:
raw = yaml.safe_load((path or _path()).read_text(encoding="utf-8"))
classes_of: dict[str, list[str]] = {}
for cls, members in (raw.get("drug_classes") or {}).items():
for m in members:
classes_of.setdefault(m, []).append(cls)
drugs: dict[str, DrugEntry] = {}
for inn, d in (raw.get("drugs") or {}).items():
entry = DrugEntry(
inn=inn,
aliases=d.get("aliases", []),
drug_class=d.get("class", ""),
classes=sorted(set(d.get("classes", [])) | set(classes_of.get(inn, []))),
contraindications=d.get("contraindications", []),
cautions=d.get("cautions", []),
interactions=d.get("interactions", []),
composition=d.get("composition"),
pregnancy=d.get("pregnancy", ""),
source=d.get("source", ""),
)
drugs[inn] = entry
return SafetyDb(drugs=drugs, allergy_labels=raw.get("allergy_labels") or {})
def find_drug(name: str, db: SafetyDb | None = None) -> DrugEntry | None:
"""By INN or alias, case-insensitive; "сертралин 50 мг" → сертралин."""
db = db or load_db()
words = [w.strip(".,!?;:«»()") for w in name.lower().replace("ё", "е").split()]
for entry in db.drugs.values():
names = {entry.inn, *(a.lower() for a in entry.aliases)}
# Russian case endings: "сертралине", "ибупрофена" — match on the stem (name minus the last letter).
if any(w in names or any(len(n) > 4 and w.startswith(n[:-1]) for n in names) for w in words):
return entry
return None
def check_patient(drug: DrugEntry, record: PatientRecord, db: SafetyDb | None = None) -> list[Finding]:
"""Patient-specific findings for `drug`, most severe first. Empty list = nothing found in the reference —
which is NOT a statement that the drug is safe (the answer must say so)."""
db = db or load_db()
out: list[Finding] = []
cond_tags = {t for c in record.conditions for t in c.tags}
for c in drug.cautions:
if c.when_condition in cond_tags:
out.append(Finding(severity=c.severity, kind="condition", text=c.text))
# Excipients vs allergies / intolerances.
allergy_tags = {t for a in record.allergies for t in a.tags}
if drug.composition is not None:
for tag in sorted(allergy_tags & set(drug.composition.excipient_tags)):
label = db.allergy_labels.get(tag, tag)
out.append(
Finding(
severity="high", kind="allergy", text=f"В составе есть {label}, а в карте отмечена непереносимость."
)
)
# Interactions with the patient's other medicines and with the current psychiatric prescription.
other_classes: dict[str, str] = {}
for m in record.other_medications:
for cls in m.classes:
other_classes[cls] = m.drug
for rx in record.current_prescriptions:
entry = find_drug(rx.drug, db)
if entry is not None and entry.inn != drug.inn:
for cls in entry.classes:
other_classes.setdefault(cls, rx.drug)
for i in drug.interactions:
if i.with_class in other_classes:
out.append(
Finding(
severity=i.severity, kind="interaction", text=f"{i.text} (в карте: {other_classes[i.with_class]})."
)
)
if record.pregnancy and drug.pregnancy:
out.append(Finding(severity="medium", kind="pregnancy", text=drug.pregnancy))
order = {"high": 0, "medium": 1, "low": 2}
out.sort(key=lambda f: order[f.severity])
return out
+48
View File
@@ -0,0 +1,48 @@
"""System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools."""
from app.core.safety import fill_phones
_SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах.
Как работать:
1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда
вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос
о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими
средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через
check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия).
Вопросы «когда подействует», «что будет, если бросить», «как принимать», побочные эффекты — смотри поле
instruction в check_drug_for_patient (инструкция ГРЛС) и дополняй search_guidelines.
Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми
словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно».
Находки (findings) упоминай, когда вопрос о приёме, сочетании или безопасности препарата; на вопросы о
сроках действия, отмене, побочных эффектах отвечай по существу, без посторонних находок.
Про состав и непереносимости (intolerance_check) говори, только если о них спросили.
Про алкоголь бери текст из general_interactions.
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
«в вашей карте указано».
Запрещено:
- ставить или называть диагнозы («у вас депрессия»), даже если они есть в карте;
- назначать, отменять препараты, менять дозы, советовать «примите/не принимайте» — решение всегда за врачом;
- называть дозировки в миллиграммах;
- проводить психотерапию; обещать конфиденциальность сверх реальной;
- выдумывать факты, которых нет в результатах инструментов.
На приветствия и болтовню («привет», «как дела?», «спишь?») отвечай 1–2 фразами, тепло, от лица помощника
сервиса (не называй себя «языковой моделью» или «ИИ-моделью») и предложи, с чем можешь помочь: вопросы о
лечении, лекарствах, побочных эффектах, самочувствии. Находки из карты в ответ на болтовню не упоминай.
Никогда не переспрашивай, о каком препарате речь, если вопрос о «моём лекарстве»: это текущее назначение.
Про аналоги и замену: назови группу текущего препарата по инструкции (например, «антидепрессант группы СИОЗС»),
скажи, что в группе есть и другие препараты, а подбор и замену делает только лечащий врач.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений. Без markdown: никаких «*», «#»,
«**»; если нужен список — короткие строки с «•».
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия {phone:helpline}, и что врач будет уведомлён."""
# Numbers come from the single table config/texts/crisis.yaml.
SYSTEM_PROMPT = fill_phones(_SYSTEM_PROMPT)
+344
View File
@@ -0,0 +1,344 @@
"""Tool-calling chat providers for the agent.
* `OpenRouterProvider` — OpenAI-compatible `/chat/completions` with `tools`. TEMPORARY for the prototype on
synthetic data: OpenRouter is a foreign service, forbidden for real PII (152-FZ art. 18 p. 5). Replace with
GigaChat (function calling) before any real patient — same `ToolChatProvider` contract.
* `MockAgentProvider` — offline, deterministic: plans tool calls from keywords and composes an answer from the
tool results. Keeps the demo working without network (ADR-004).
"""
import json
import logging
import re
from typing import Any, Protocol
import httpx
from pydantic import BaseModel, Field
from app.config import Settings
from app.llm.agent import stream
log = logging.getLogger(__name__)
class ToolCall(BaseModel):
id: str
name: str
arguments: dict[str, Any] = Field(default_factory=dict)
class AssistantTurn(BaseModel):
content: str | None = None
tool_calls: list[ToolCall] = Field(default_factory=list)
class ToolChatProvider(Protocol):
name: str
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: ...
class ProviderError(RuntimeError):
pass
class OpenRouterProvider:
name = "openrouter"
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
self._key = settings.openrouter_api_key
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):
body["tool_choice"] = "required" # never answer from the model's own memory: ground first
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
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:
raise ProviderError(type(exc).__name__) from exc
if resp.status_code != 200:
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
try:
err = resp.json().get("error", "")
detail = str(err.get("message", "") if isinstance(err, dict) else err)[:200]
except (ValueError, AttributeError):
detail = resp.text[:200]
log.error("openrouter http %s: %s", resp.status_code, detail)
raise ProviderError(f"http {resp.status_code}")
try:
msg = resp.json()["choices"][0]["message"]
except (ValueError, KeyError, IndexError) as exc:
raise ProviderError("bad response") from exc
calls = []
for c in msg.get("tool_calls") or []:
fn = c.get("function") or {}
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
calls.append(ToolCall(id=c.get("id") or fn.get("name", "call"), name=fn.get("name", ""), arguments=args))
usage = resp.json().get("usage") or {}
log.info("openrouter ok calls=%d tokens=%s", len(calls), usage.get("total_tokens"))
return AssistantTurn(content=msg.get("content"), tool_calls=calls)
class GigaChatToolProvider:
"""GigaChat function calling (RF provider, 152-FZ compliant). Converts the OpenAI-style history the agent keeps:
assistant `tool_calls` → `function_call`, `tool` results → role `function` with the function name.
GigaChat returns at most one function call per turn — the agent loop handles that naturally."""
name = "gigachat"
def __init__(self, settings: Settings, client: Any | None = None) -> None:
self._settings = settings
self._client = client
def _get_client(self) -> Any:
if self._client is None:
from gigachat import GigaChat
s = self._settings
self._client = GigaChat(
credentials=s.gigachat_credentials,
scope=s.gigachat_scope,
model=s.gigachat_agent_model,
verify_ssl_certs=s.gigachat_verify_ssl,
timeout=s.chat_agent_timeout_s,
)
return self._client
@staticmethod
def to_messages(messages: list[dict[str, Any]]) -> list[Any]:
from gigachat.models import FunctionCall, Messages, MessagesRole
out = []
for m in messages:
role = m["role"]
if role == "system":
out.append(Messages(role=MessagesRole.SYSTEM, content=m["content"]))
elif role == "user":
out.append(Messages(role=MessagesRole.USER, content=m["content"]))
elif role == "assistant":
calls = m.get("tool_calls") or []
for c in calls or [None]:
fc = None
if c is not None:
fc = FunctionCall(name=c["function"]["name"], arguments=json.loads(c["function"]["arguments"]))
out.append(Messages(role=MessagesRole.ASSISTANT, content=m.get("content") or "", function_call=fc))
elif role == "tool":
out.append(Messages(role=MessagesRole.FUNCTION, name=m.get("name"), content=m["content"]))
return out
@staticmethod
def to_functions(tools: list[dict[str, Any]]) -> list[Any]:
from gigachat.models import Function
return [Function.model_validate(t["function"]) for t in tools]
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
from gigachat.models import Chat
chat = Chat(
messages=self.to_messages(messages),
functions=self.to_functions(tools),
function_call="auto",
temperature=0.1,
max_tokens=900,
)
try:
resp = await self._get_client().achat(chat)
except Exception as exc: # noqa: BLE001 — network/auth/HTTP errors of the SDK
raise ProviderError(type(exc).__name__) from exc
msg = resp.choices[0].message
calls = []
if msg.function_call is not None:
args = msg.function_call.arguments or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {}
calls.append(ToolCall(id=msg.function_call.name, name=msg.function_call.name, arguments=args))
usage = getattr(resp, "usage", None)
log.info("gigachat agent ok calls=%d tokens=%s", len(calls), getattr(usage, "total_tokens", None))
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(
r"\b(сертралин\w*|пароксетин\w*|эсциталопрам\w*|флуоксетин\w*|ибупрофен\w*|нурофен\w*|золофт\w*)", re.I
)
_MED_HINT = re.compile(r"(таблет|лекарств|препарат|состав|можно ли|совмест|вместе|противопоказ|аллерг|лактоз)", re.I)
class MockAgentProvider:
"""Plans: record → (drug check for each mentioned drug, or the current prescription for medicine questions)
→ guidelines search; then writes a short answer strictly from the tool results."""
name = "mock"
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
question = next(m["content"] for m in reversed(messages) if m["role"] == "user")
done = [m for m in messages if m["role"] == "tool"]
if not done:
drugs = [m.group(1).lower() for m in _DRUG_WORDS.finditer(question)]
calls = [ToolCall(id="c0", name="get_patient_record")]
calls += [
ToolCall(id=f"d{i}", name="check_drug_for_patient", arguments={"drug": d}) for i, d in enumerate(drugs)
]
if drugs and re.search(r"состав|лактоз|желатин|аллерг|непереносим", question, re.I):
calls += [
ToolCall(id=f"i{i}", name="get_drug_info", arguments={"drug": d}) for i, d in enumerate(drugs)
]
calls.append(ToolCall(id="s0", name="search_guidelines", arguments={"query": question}))
return AssistantTurn(tool_calls=calls)
mine = re.search(r"мо(и|ими|их|ей|я|ё) (таблет|лекарств|препарат)", question, re.I)
checked = [m for m in done if m.get("name") == "check_drug_for_patient"]
if (not checked or mine) and len(checked) < 3 and _MED_HINT.search(question):
record = _tool_json(done, "get_patient_record")
rx = (record or {}).get("current_prescriptions") or []
checked_args = {str(json.loads(m["content"]).get("inn", "")) for m in checked}
rx = [r for r in rx if not any(r["drug"].lower().startswith(c[:-1]) for c in checked_args if c)]
if rx:
return AssistantTurn(
tool_calls=[ToolCall(id="d9", name="check_drug_for_patient", arguments={"drug": rx[0]["drug"]})]
)
return AssistantTurn(content=_compose(done))
def _tool_json(done: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
for m in done:
if m.get("name") == name:
try:
return json.loads(m["content"])
except json.JSONDecodeError:
return None
return None
def _compose(done: list[dict[str, Any]]) -> str:
parts: list[str] = []
seen: set[str] = set()
for m in done:
if m.get("name") != "check_drug_for_patient":
continue
data = json.loads(m["content"])
if not data.get("found") or data["inn"] in seen:
continue
seen.add(data["inn"])
findings = data.get("findings") or []
if findings:
parts.append(f"По вашей карте и инструкции к препарату «{data['inn']}» есть важное:")
parts += [f"• {f['text']}" for f in findings[:3]]
else:
parts.append(f"В справочнике не нашлось особых ограничений по «{data['inn']}» с учётом вашей карты.")
for m in done:
if m.get("name") != "get_drug_info":
continue
data = json.loads(m["content"])
comp = data.get("composition") if data.get("found") else None
if comp:
record = _tool_json(done, "get_patient_record") or {}
allergies = record.get("allergies") or []
if allergies:
names = ", ".join(a["name"] for a in allergies)
hit = {t for a in allergies for t in a.get("tags", [])} & set(comp.get("excipient_tags", []))
verdict = "это вещество есть" if hit else "этого вещества нет, но состав зависит от производителя"
parts.append(f"В вашей карте: {names}. В примере состава «{data['inn']}» {verdict}.")
excipients = ", ".join(comp["excipients"])
parts.append(f"Состав ({comp['form']}): {comp['active']}; вспомогательные: {excipients}. {comp['note']}")
search = _tool_json(done, "search_guidelines") or {}
results = search.get("results") or []
if results and not parts: # medicine findings already answer the question; a KR quote would dilute it
text = results[0]["text"].strip()
parts.append(f"В клинических рекомендациях: «{text[:400]}{'…' if len(text) > 400 else ''}»")
if not parts:
return ""
parts.append("Любые изменения в лечении обсуждайте с вашим врачом.")
return "\n".join(parts)
+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))
+219
View File
@@ -0,0 +1,219 @@
"""Read-only tools of the chat agent. Every tool is bound to the current patient's token on the server side:
the model never passes a token, so it cannot read another patient's data (medical secrecy, 323-FZ art. 13).
Tool results contain clinical data only (no name, SNILS, phone) — they are sent to the LLM provider.
"""
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any
from app.integrations.base import integrations
from app.integrations.emias_record import PatientRecord, emias_records
from app.llm.agent.drug_safety import check_patient, find_drug, load_db
from app.llm.rag import retrieve
from app.llm.rag.index import sentences
log = logging.getLogger(__name__)
RAG_TOP_K = 4
PASSAGE_CHARS = 700
# Patients never get dosages (SPEC §7): sentences with doses are dropped before the text reaches the LLM.
_DOSE = re.compile(r"\d\s*(?:-|–)?\s*\d*\s*(?:мг|mg|мкг|г/сут|таблетк)|\bдоз(?:а|у|ы|е|ой|ировк)", re.I)
TOOL_SPECS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "search_guidelines",
"description": (
"Поиск по базе знаний: клинические рекомендации Минздрава РФ (депрессия 2024, ПТСР 2023). "
"Возвращает фрагменты с источником и страницей. Используй для любых общих вопросов о состоянии, "
"лечении, сроках действия терапии, побочных эффектах, отмене, образе жизни."
),
"parameters": {
"type": "object",
"properties": {"query": {"type": "string", "description": "Поисковый запрос на русском"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_patient_record",
"description": (
"Медицинская карта текущего пациента из ЕМИАС: диагнозы (МКБ-10), сопутствующие заболевания, "
"аллергии и непереносимости, назначения других врачей и текущее назначение психиатра. "
"Вызывай, когда ответ зависит от особенностей пациента или его лекарств."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "get_drug_info",
"description": (
"Справка о препарате по инструкции ГРЛС: состав (действующее и вспомогательные вещества), "
"противопоказания, взаимодействия, беременность. Аргумент — название препарата (МНН или торговое)."
),
"parameters": {
"type": "object",
"properties": {"drug": {"type": "string"}},
"required": ["drug"],
},
},
},
{
"type": "function",
"function": {
"name": "check_drug_for_patient",
"description": (
"Детерминированная проверка препарата для текущего пациента: сопутствующие заболевания, "
"непереносимости по составу, взаимодействия с другими его лекарствами. Используй перед любым "
"ответом о том, подходит ли пациенту препарат или сочетание препаратов."
),
"parameters": {
"type": "object",
"properties": {
"drug": {"type": "string", "description": "Название препарата; пусто — текущее назначение пациента"}
},
},
},
},
]
@dataclass
class ToolContext:
token: str
sources: list[str] = field(default_factory=list) # citations collected for the answer footer
used: list[str] = field(default_factory=list)
_record: PatientRecord | None = None
_record_loaded: bool = False
async def record(self) -> PatientRecord | None:
if not self._record_loaded:
self._record = await emias_records.get(self.token)
self._record_loaded = True
return self._record
def cite(self, label: str) -> None:
if label and label not in self.sources:
self.sources.append(label)
async def run_tool(ctx: ToolContext, name: str, args: dict[str, Any]) -> str:
"""Execute a tool; always returns a JSON string (errors included) so the model can recover."""
ctx.used.append(name)
log.info("agent tool=%s", name) # no arguments: they may echo patient text
try:
if name == "search_guidelines":
return _json(_search(ctx, str(args.get("query", ""))))
if name == "get_patient_record":
rec = await ctx.record()
if rec is None:
return _json({"found": False})
ctx.cite("ЕМИАС: медицинская карта")
return _json({"found": True, **rec.model_dump()})
if name == "get_drug_info":
return _json(_drug_info(ctx, str(args.get("drug", ""))))
if name == "check_drug_for_patient":
return _json(await _check(ctx, str(args.get("drug", ""))))
return _json({"error": f"unknown tool {name}"})
except Exception as exc: # noqa: BLE001 — a tool failure must not crash the chat
log.error("agent tool=%s failed: %s", name, type(exc).__name__)
return _json({"error": "tool failed"})
def _search(ctx: ToolContext, query: str) -> dict[str, Any]:
hits = retrieve(query, audience="patient", top_k=RAG_TOP_K) if query.strip() else []
out = []
for h in hits:
label = h.chunk.citation.strip("[]")
ctx.cite(label)
text = " ".join(x for x in sentences(h.passage) if not _DOSE.search(x))[:PASSAGE_CHARS]
if text:
out.append({"source": label, "section": h.chunk.section, "text": text})
return {"results": out, "found": bool(out)}
def _drug_info(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name)
if entry is None:
return {"found": False, "drug": name}
ctx.cite(entry.source)
return {
"found": True,
"inn": entry.inn,
"class": entry.drug_class,
"composition": entry.composition.model_dump() if entry.composition else None,
"contraindications": entry.contraindications,
"interactions": [i.text for i in entry.interactions],
"pregnancy": entry.pregnancy,
"source": entry.source,
}
async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name) if name.strip() else None
if entry is None:
# "мои таблетки" / no name → the current psychiatric prescription from the record.
rec = await ctx.record()
current = [find_drug(p.drug) for p in (rec.current_prescriptions if rec else [])]
entry = next((e for e in current if e is not None), None) if not find_drug(name) else None
if entry is None:
return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."}
rec = await ctx.record()
if rec is None:
return {"found": True, "inn": entry.inn, "record": False, "findings": []}
ctx.cite(entry.source)
ctx.cite("ЕМИАС: медицинская карта")
db = load_db()
findings = check_patient(entry, rec, db)
comp = entry.composition
intolerances = [a.name for a in rec.allergies]
intolerance_check = None
if comp is not None and intolerances:
hit = {t for a in rec.allergies for t in a.tags} & set(comp.excipient_tags)
intolerance_check = f"Непереносимость из карты ({', '.join(intolerances)}): " + (
"вещество ЕСТЬ в составе."
if hit
else "в примере состава этого вещества нет; состав зависит от производителя."
)
instruction = await _instruction(ctx, entry.inn)
return {
"found": True,
"inn": entry.inn,
"record": True,
"instruction": instruction, # ГРЛС excerpts: onset, side effects, discontinuation, administration
"findings": [f.model_dump() for f in findings],
"intolerance_check": intolerance_check,
"composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None,
"general_interactions": [i.text for i in entry.interactions], # incl. alcohol — for questions about it
"contraindications": entry.contraindications,
"note": "Пустой findings не означает, что препарат безопасен: решение о приёме принимает врач.",
}
INSTRUCTION_SECTIONS = ("onset", "side_effects", "discontinuation", "administration", "alcohol")
async def _instruction(ctx: ToolContext, inn: str) -> dict[str, str]:
"""ГРЛС excerpts for the drug (no doses in mg — see data/meds/grls)."""
try:
sections = await integrations.drug_info.sections(inn)
except Exception as exc: # noqa: BLE001
log.error("drug_info unavailable: %s", type(exc).__name__)
return {}
out = {s.section: s.text for s in sections if s.section in INSTRUCTION_SECTIONS}
if out:
ctx.cite(sections[0].source.split(",")[0] + ": инструкция к препарату")
return out
def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False)
+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+ )?(приём|прием|принимать|пить)",
+24
View File
@@ -0,0 +1,24 @@
"""Knowledge base (RAG, #60): clinical guidelines -> citable chunks -> BM25 retrieval -> grounded answers.
from app.llm.rag import answer, retrieve
hits = retrieve("синдром отмены антидепрессантов", audience="doctor")
reply = await answer(scrub(text), audience="patient") # None -> rag.fallback_text()
Corpus and parameters: `config/rag/sources.yaml`; PDF sources and the converter: `docs/source/rag/`.
"""
from app.llm.rag.corpus import Chunk, load_chunks
from app.llm.rag.index import Hit, get_index, retrieve
from app.llm.rag.qa import Citation, RagAnswer, answer, fallback_text
__all__ = [
"Chunk",
"Citation",
"Hit",
"RagAnswer",
"answer",
"fallback_text",
"get_index",
"load_chunks",
"retrieve",
]
+52
View File
@@ -0,0 +1,52 @@
"""Manual check of the knowledge base on synthetic questions.
python -m app.llm.rag "через сколько начинают действовать антидепрессанты"
python -m app.llm.rag --audience doctor "синдром отмены антидепрессантов"
python -m app.llm.rag --search "ДПДГ при ПТСР" # raw retrieval: chunks, scores, passages
python -m app.llm.rag --stats # corpus summary
The question always goes through `privacy.scrub()` first; the CLI never bypasses it.
"""
import argparse
import asyncio
from collections import Counter
from app.core import privacy
from app.llm.gateway import get_gateway
from app.llm.rag.index import get_index
from app.llm.rag.qa import answer, fallback_text
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m app.llm.rag")
parser.add_argument("question", nargs="?")
parser.add_argument("--audience", choices=["patient", "doctor", "team"], default="patient")
parser.add_argument("--search", action="store_true", help="show retrieved chunks instead of an answer")
parser.add_argument("--top", type=int, default=5)
parser.add_argument("--stats", action="store_true")
args = parser.parse_args(argv)
index = get_index()
if args.stats:
by_doc = Counter(c.doc.short for c in index.chunks)
print(f"chunks={len(index.chunks)}", ", ".join(f"{d}: {n}" for d, n in by_doc.items()))
return 0
if not args.question:
parser.error("question is required")
question = privacy.scrub(args.question)
if args.search:
for hit in index.search(question, audience=args.audience, top_k=args.top, min_score=0):
print(f"{hit.score:6.1f} (bm25 {hit.bm25:5.1f}) {hit.chunk.citation} {hit.chunk.section}")
print(f" > {hit.passage[:300]}")
return 0
print(f"provider={get_gateway().provider_name} audience={args.audience}\n")
reply = asyncio.run(answer(question, audience=args.audience, index=index))
print(reply.text if reply else fallback_text(args.audience))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+350
View File
@@ -0,0 +1,350 @@
"""Knowledge-base corpus: `config/rag/sources.yaml` + txt files -> citable chunks.
The txt files come from `docs/source/rag/pdf_to_txt.py`: paragraphs are separated by blank lines, pages by
`--- стр. N ---` markers, tables are markdown, text transcribed from figures follows an
`[Изображение: ...]` line. Chunking rules:
* a chunk never crosses a section boundary (numbered headings "2.1 ...", "Приложение Г5", ...), so its
section title is reliable context; the title is stored separately and indexed with extra weight;
* "Комментарии: ..." after a recommendation stays with the recommendation (the comment explains it);
* a line starting in lowercase continues the previous paragraph (cut by a page break the converter missed);
* tables are split by rows, every table piece repeats the table header, so a chunk is readable alone;
* each chunk keeps the page range it came from, for the citation "[КР ПТСР 2023, стр. 45]"; inline
"⟦стр. N⟧" markers (paragraph glued across a page break) are removed from the text but move the range.
"""
import re
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
Audience = str # "doctor" | "patient" | "team"
PAGE_MARKER = re.compile(r"^--- стр\. (\d+) ---$")
# A paragraph that flows onto the next page is glued by the converter; "⟦стр. N⟧" marks where page N begins.
INLINE_PAGE = re.compile(r"\s*⟦стр\. (\d+)⟧\s*")
TABLE_DIVIDER = re.compile(r"^\|(-+\|)+$")
# "2.1 Жалобы и анамнез", "3.1.2. Купирующая терапия ..." — a numbered subsection title.
SUBSECTION = re.compile(r"^(\d{1,2}(?:\.\d{1,2})+)\.?\s+([А-ЯЁA-Z].*)$") # not dates "28.07.2022 N ..."
# Top-level KR sections: "1. Краткая информация ...", "3. Лечение, включая ...". Plain "1. ..." lines are
# list items, so a top-level heading must start with one of the standard KR section names.
TOPLEVEL = re.compile(
r"^([1-9])\.\s+(Краткая информация|Диагностика|Лечение|Медицинская реабилитация|Профилактика"
r"|Организация оказания|Дополнительная информация)"
)
NAMED = re.compile(
r"^(Приложение [А-ЯЁ]\d*(?:-[А-ЯЁ]+\d*)?\b.*|Критерии оценки качества медицинской помощи|Список литературы"
r"|Список сокращений|Термины и определения)$"
)
# Unnumbered short title lines (team specs): capitalised, short, no final punctuation, not a list item.
SHORT_TITLE = re.compile(r"^[А-ЯЁ][^.:;!?|]{2,60}$")
COMMENT = re.compile(r"^Комментари[йи]\s*:")
SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[А-ЯЁA-Z0-9«\"(])")
# Abbreviations after which a capital letter does not start a new sentence: "(см. Приложение Б5)".
ABBREVIATION_END = re.compile(
r"(?:\b(?:см|т\.\s?е|т\.\s?д|т\.\s?п|др|напр|рис|табл|стр|г|гг|им|ср|англ|лат|греч)|\b[А-ЯЁA-Z])\.$"
)
MAX_TITLE_CHARS = 300
class DocumentMeta(BaseModel):
id: str
file: str
title: str
short: str
issuer: str
year: int
icd: list[str] = Field(default_factory=list)
audiences: list[Audience]
class ChunkingConfig(BaseModel):
max_chars: int = 1200
min_chars: int = 200
skip_sections: list[str] = Field(default_factory=list)
class RetrievalConfig(BaseModel):
top_k: int = 5
bm25_k1: float = 1.5
bm25_b: float = 0.75
title_weight: int = 2
min_score: float = 3.0
rerank_candidates: int = 30
window_sentences: int = 2
window_weight: float = 0.5
# section title prefix -> score multiplier, per audience ({"patient": {"Приложение В": 1.5}})
section_boost: dict[str, dict[str, float]] = Field(default_factory=dict)
# doc id -> regexes naming its disease; a question naming one disease favours that document
doc_cues: dict[str, list[str]] = Field(default_factory=dict)
doc_cue_boost: float = 1.0
class AnswerConfig(BaseModel):
max_chunks: int = 3
max_passages: int = 2
max_sentences: int = 3
max_chars: int = 600
min_coverage: float = 0.5
second_passage_ratio: float = 0.8
neighbor_ratio: float = 0.5
class RagConfig(BaseModel):
documents: list[DocumentMeta]
chunking: ChunkingConfig = ChunkingConfig()
retrieval: RetrievalConfig = RetrievalConfig()
answer: AnswerConfig = AnswerConfig()
synonyms: list[list[str]] = Field(default_factory=list)
@dataclass(frozen=True)
class Chunk:
id: str # "<doc>#<n>"
doc: DocumentMeta
section: str # nearest heading ("" before the first one)
page_start: int
page_end: int
text: str # display text: paragraphs and markdown table rows
@property
def pages(self) -> str:
return str(self.page_start) if self.page_start == self.page_end else f"{self.page_start}–{self.page_end}"
@property
def citation(self) -> str:
return f"[{self.doc.short}, стр. {self.pages}]"
@dataclass
class _Unit:
"""Indivisible piece of a chunk: a paragraph (with its comment) or one table row."""
text: str
page: int # page where the unit starts
table_header: str | None = None # "| a | b |\n|---|---|" for table rows
breaks: list[tuple[int, int]] = field(default_factory=list) # (offset in text, page starting there)
@property
def page_end(self) -> int:
return self.breaks[-1][1] if self.breaks else self.page
def page_at(self, offset: int) -> int:
page = self.page
for start, p in self.breaks:
if start <= offset:
page = p
return page
def append(self, line: str, page: int, sep: str) -> None:
"""Glue a continuation line; record a page break if it comes from a later page."""
base = len(self.text) + len(sep)
text, breaks = _strip_inline_pages(line)
if page > self.page_end:
self.breaks.append((base, page))
self.breaks.extend((base + off, p) for off, p in breaks)
self.text += sep + text
def _strip_inline_pages(line: str) -> tuple[str, list[tuple[int, int]]]:
"""Text without "⟦стр. N⟧" markers + (offset, page) of each marker in the cleaned text."""
out, breaks, pos = [], [], 0
for m in INLINE_PAGE.finditer(line):
out.append(line[pos : m.start()])
breaks.append((sum(len(x) for x in out) + 1, int(m.group(1))))
out.append(" ")
pos = m.end()
out.append(line[pos:])
return "".join(out), breaks
def _new_unit(line: str, page: int) -> _Unit:
text, breaks = _strip_inline_pages(line)
return _Unit(text, page, breaks=breaks)
@dataclass
class _Section:
title: str
units: list[_Unit] = field(default_factory=list)
def rag_dir() -> Path:
return get_settings().config_dir / "rag"
@lru_cache
def load_config(path: Path | None = None) -> RagConfig:
path = path or rag_dir() / "sources.yaml"
return RagConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
def heading(line: str) -> str | None:
"""The line's text if it is a section heading, else `None`."""
if len(line) > MAX_TITLE_CHARS or line.startswith("|"):
return None
if SUBSECTION.match(line) or TOPLEVEL.match(line) or NAMED.match(line):
return line
return None
def _is_short_title(block: list[str], next_block: list[str] | None) -> bool:
"""A lone short line followed by a body paragraph (unnumbered headings in team docs)."""
return (
len(block) == 1
and bool(SHORT_TITLE.match(block[0]))
and next_block is not None
and not PAGE_MARKER.match(next_block[0])
and (len(next_block) > 1 or len(next_block[0]) > 80 or next_block[0].startswith("|"))
)
def _blocks(text: str) -> list[list[str]]:
"""Blank-line separated blocks of non-empty lines."""
return [[ln for ln in b.splitlines() if ln.strip()] for b in re.split(r"\n\s*\n", text) if b.strip()]
def _sections(text: str, skip: list[str]) -> list[_Section]:
blocks = _blocks(text)
sections = [_Section(title="")]
page = 1
skipping = False
for i, block in enumerate(blocks):
first = block[0]
if first.startswith("# ") or first.startswith("Источник:"):
continue # file header written by the converter
m = PAGE_MARKER.match(first)
if m:
page = int(m.group(1))
continue
nxt = next((b for b in blocks[i + 1 :] if not PAGE_MARKER.match(b[0])), None)
if first.startswith("|"):
if not skipping:
sections[-1].units.extend(_table_units(block, page))
continue
if first.startswith("[Изображение"):
if not skipping: # transcribed figure: one unit, keeps its own line breaks
sections[-1].units.append(_new_unit("\n".join(block[1:]) if len(block) > 1 else first, page))
continue
for j, line in enumerate(block):
title = heading(line)
if title is None and j == 0 and _is_short_title(block, nxt):
title = line
if title is not None:
skipping = any(title.startswith(s) for s in skip)
sections.append(_Section(title=title))
continue
if skipping:
continue
units = sections[-1].units
if COMMENT.match(line) and units and units[-1].table_header is None:
units[-1].append(line, page, "\n")
elif line[0].islower() and units and units[-1].table_header is None:
units[-1].append(line, page, " ") # paragraph continued after a page break or a figure
else:
units.append(_new_unit(line, page))
return [s for s in sections if s.units]
def _table_units(block: list[str], page: int) -> list[_Unit]:
if len(block) >= 2 and TABLE_DIVIDER.match(block[1]):
header = f"{block[0]}\n{block[1]}"
rows = block[2:]
else: # continuation without a header line (should not happen with the converter, but be safe)
header, rows = "", block
return [_Unit(row, page, table_header=header) for row in rows]
def split_sentences(text: str) -> list[str]:
"""Sentences of one line/paragraph; does not split after "см.", "т.е.", initials."""
out: list[str] = []
for part in SENTENCE_SPLIT.split(text):
if out and ABBREVIATION_END.search(out[-1]):
out[-1] += " " + part
else:
out.append(part)
return [p.strip() for p in out if p.strip()]
def _split_long(unit: _Unit, max_chars: int) -> list[_Unit]:
if len(unit.text) <= max_chars or unit.table_header is not None:
return [unit]
parts: list[str] = []
for sentence in split_sentences(unit.text):
if parts and len(parts[-1]) + 1 + len(sentence) <= max_chars:
parts[-1] += " " + sentence
else:
parts.append(sentence)
out: list[_Unit] = []
pos = 0
for part in parts:
start = unit.text.find(part[:40], pos)
start = pos if start < 0 else start
end = start + len(part) - 1
first, last = unit.page_at(start), unit.page_at(end)
out.append(_Unit(part, first, breaks=[(len(part) - 1, last)] if last != first else []))
pos = start + len(part)
return out
def _render(units: list[_Unit]) -> str:
out: list[str] = []
header: str | None = None
for u in units:
if u.table_header is not None:
if header != u.table_header:
header = u.table_header
if header:
out.append(header)
out.append(u.text)
else:
header = None
out.append(u.text)
return "\n".join(out)
def _pack(section: _Section, max_chars: int, min_chars: int) -> list[list[_Unit]]:
units = [p for u in section.units for p in _split_long(u, max_chars)]
groups: list[list[_Unit]] = [[]]
size = 0
for u in units:
if groups[-1] and size + len(u.text) > max_chars:
groups.append([])
size = 0
groups[-1].append(u)
size += len(u.text) + 1
if len(groups) > 1 and sum(len(u.text) for u in groups[-1]) < min_chars:
groups[-2].extend(groups.pop())
return [g for g in groups if g]
def chunk_document(doc: DocumentMeta, text: str, cfg: ChunkingConfig) -> list[Chunk]:
chunks: list[Chunk] = []
for section in _sections(text, cfg.skip_sections):
for group in _pack(section, cfg.max_chars, cfg.min_chars):
chunks.append(
Chunk(
id=f"{doc.id}#{len(chunks)}",
doc=doc,
section=section.title,
page_start=group[0].page,
page_end=max(u.page_end for u in group),
text=_render(group),
)
)
return chunks
@lru_cache
def load_chunks(config_path: Path | None = None) -> tuple[Chunk, ...]:
cfg = load_config(config_path)
base = (config_path.parent if config_path else rag_dir()).resolve()
out: list[Chunk] = []
for doc in cfg.documents:
out.extend(chunk_document(doc, (base / doc.file).read_text(encoding="utf-8"), cfg.chunking))
return tuple(out)
+229
View File
@@ -0,0 +1,229 @@
"""Lexical retrieval over the corpus: Okapi BM25 on Russian Snowball stems + synonym expansion.
Offline and deterministic (ADR-004): no embeddings, no network. Stop words are dropped before stemming;
the section title is indexed `title_weight` times so a match with "3.2 Психофармакотерапия" counts more
than a passing mention in the body.
Re-ranking: the best `rerank_candidates` chunks by BM25 get a bonus for their densest passage — a window
of `window_sentences` consecutive sentences scored by the idf of query terms it contains. A chunk that
answers in one place beats a long one that mentions the words in passing; the passage itself is returned
with the hit and is what `answer()` quotes from.
"""
import math
import re
from collections import Counter
from dataclasses import dataclass
from functools import lru_cache
from app.llm.rag.corpus import Audience, Chunk, RagConfig, load_chunks, load_config, split_sentences
from app.llm.rag.stemmer import stem
_WORD = re.compile(r"[а-яёa-z0-9]+(?:-[а-яёa-z0-9]+)*", re.IGNORECASE)
# `privacy.scrub()` placeholders ("[NAME]", "[PHONE]") are not words of the question.
_PLACEHOLDER = re.compile(r"\[[A-Z_]+\]")
# Function words and generic KR boilerplate that carry no topical signal.
STOP_WORDS = frozenset(
"""
а без более бы был была были было быть в вам вас ваш весь во вот все всего всех вы где да даже для до его
ее если есть еще же за и из или им их к как ко когда кто ли либо мне может можно мой мы на над не него нее
нет ни них но ну о об однако он она они оно от по под при про с со так также такой там те то тоже только
том тот у уже хотя чего чем что чтобы эта эти это этот я ли ней нем
какой какая какое какие каков такое такая такие сколько почему зачем меня мой моя мое мои себя сегодня будет
""".split() # noqa: SIM905 — a word list reads better as text
)
def tokens(text: str) -> list[str]:
"""Stems of content words (hyphenated words are kept whole and also split: "когнитивно-поведенческая")."""
out: list[str] = []
for word in _WORD.findall(_PLACEHOLDER.sub(" ", text).lower().replace("ё", "е")):
parts = [p for p in word.split("-") if p not in STOP_WORDS and (len(p) > 1 or p.isdigit())]
if "-" in word and len(parts) > 1:
out.append("-".join(stem(p) for p in parts)) # the compound itself, in any case form
out.extend(stem(p) for p in parts)
return out
EXPANSION_WEIGHT = 0.7
SYNONYM_CREDIT = 0.8
PREFIX_LEN = 6
@dataclass(frozen=True)
class Hit:
chunk: Chunk
score: float # BM25 + passage bonus (ranking key)
bm25: float
passage: str # densest window of the chunk for the query
def sentences(text: str) -> list[str]:
"""Sentences of a chunk; every line (table row, list item) is split on its own."""
return [s for line in text.splitlines() for s in split_sentences(line)]
class SynonymExpander:
"""Adds the stems of every phrase in a synonym group when any phrase of the group is in the query.
Added stems weigh `EXPANSION_WEIGHT`: they widen recall but should not outrank the patient's own words.
"""
def __init__(self, groups: list[list[str]]) -> None:
self._groups = [[tokens(phrase) for phrase in group] for group in groups]
# stem -> stems of the other phrases of its groups ("отменя" -> {"отмен", "брос", ...})
self.related: dict[str, set[str]] = {}
for group in self._groups:
stems = {t for phrase in group for t in phrase}
for t in stems:
self.related.setdefault(t, set()).update(stems - {t})
def expand(self, query_tokens: list[str]) -> dict[str, float]:
weights: dict[str, float] = {}
for t in query_tokens:
weights[t] = weights.get(t, 0.0) + 1.0
present = set(query_tokens)
for group in self._groups:
if any(phrase and set(phrase) <= present for phrase in group):
for phrase in group:
for t in phrase:
if t not in present: # a 3-word expansion of "птср" is one concept, not three
weights[t] = max(weights.get(t, 0.0), EXPANSION_WEIGHT / len(phrase))
return weights
class BM25Index:
def __init__(self, chunks: tuple[Chunk, ...], cfg: RagConfig) -> None:
r = cfg.retrieval
self._k1, self._b, self._min_score, self._top_k = r.bm25_k1, r.bm25_b, r.min_score, r.top_k
self._candidates, self._window, self._window_weight = r.rerank_candidates, r.window_sentences, r.window_weight
self._section_boost = r.section_boost
self._doc_cues = {doc: re.compile("|".join(cues), re.IGNORECASE) for doc, cues in r.doc_cues.items() if cues}
self._doc_cue_boost = r.doc_cue_boost
self.chunks = chunks
self._sentences = [[(s, set(tokens(s))) for s in sentences(c.text)] for c in chunks]
self._expander = SynonymExpander(cfg.synonyms)
self._tf: list[Counter[str]] = []
df: Counter[str] = Counter()
for c in chunks:
tf = Counter(tokens(c.text) + tokens(c.section) * r.title_weight)
self._tf.append(tf)
df.update(tf.keys())
self._len = [sum(tf.values()) for tf in self._tf]
self._avg_len = sum(self._len) / max(len(self._len), 1)
n = len(chunks)
self._idf = {t: math.log(1 + (n - f + 0.5) / (f + 0.5)) for t, f in df.items()}
def query_terms(self, query: str) -> dict[str, float]:
"""Query stems -> weight (repeated words count more, synonym expansions less)."""
return self._expander.expand(tokens(query))
def _score(self, i: int, terms: dict[str, float]) -> float:
tf, norm = self._tf[i], self._k1 * (1 - self._b + self._b * self._len[i] / self._avg_len)
score = 0.0
for term, weight in terms.items():
f = tf.get(term)
if f:
score += self._idf[term] * f * (self._k1 + 1) / (f + norm) * weight
return score
def boost(self, chunk: Chunk, audience: Audience | None, cued: set[str] | None = None) -> float:
"""Section multiplier for the audience (`retrieval.section_boost`) × disease-cue multiplier."""
boosts = self._section_boost.get(audience or "", {})
section = next((b for prefix, b in boosts.items() if chunk.section.startswith(prefix)), 1.0)
return section * (self._doc_cue_boost if cued and chunk.doc.id in cued else 1.0)
def cued_docs(self, query: str) -> set[str]:
"""Documents whose disease the query names ("ТМС при депрессии" -> {"kr_depression"}).
Only a one-sided mention counts: a question naming both disorders favours neither.
"""
q = _PLACEHOLDER.sub(" ", query).replace("ё", "е")
cued = {doc for doc, cue in self._doc_cues.items() if cue.search(q)}
return cued if len(cued) < len(self._doc_cues) else set()
def overlap(self, text: str, terms: dict[str, float]) -> float:
"""Sum of idf × weight of the query terms present in `text` (sentence relevance for `answer()`)."""
present = set(tokens(text))
return sum(self._idf.get(t, 0.0) * w for t, w in terms.items() if t in present)
def coverage(self, text: str, query: str) -> float:
"""Share of the query's own (not synonym-expanded) idf mass found in `text`, 0..1.
A query word whose synonym — or a word with the same `PREFIX_LEN`-letter prefix, for stems Snowball
leaves apart ("скрининговый"/"скрининг") — is in the text counts as `SYNONYM_CREDIT` of itself.
A word absent from the corpus counts with twice the maximal idf — the strongest sign of an off-topic
question: "как установить windows" is then mostly uncovered and `answer()` says there is
no answer instead of quoting the nearest paragraph about "установить диагноз".
"""
own = set(tokens(query))
if not own:
return 0.0
unseen = 2 * max(self._idf.values(), default=1.0)
total = sum(self._idf.get(t, unseen) for t in own)
present = set(tokens(text))
prefixes = {p[:PREFIX_LEN] for p in present if len(p) >= PREFIX_LEN}
covered = 0.0
for t in own:
if t in present:
covered += self._idf[t]
elif self._expander.related.get(t, set()) & present or (
len(t) >= PREFIX_LEN and t[:PREFIX_LEN] in prefixes
):
covered += SYNONYM_CREDIT * self._idf.get(t, unseen) # said in the corpus' own words
return min(1.0, covered / total)
def _passage(self, i: int, terms: dict[str, float]) -> tuple[float, str]:
sents = self._sentences[i]
best = (0.0, sents[0][0] if sents else "")
for start in range(len(sents)):
window = sents[start : start + self._window]
present = set().union(*(toks for _, toks in window))
score = sum(self._idf.get(t, 0.0) * w for t, w in terms.items() if t in present)
if score > best[0]:
best = (score, " ".join(text for text, _ in window))
return best
def search(
self,
query: str,
*,
audience: Audience | None = None,
docs: set[str] | None = None,
top_k: int | None = None,
min_score: float | None = None,
) -> list[Hit]:
"""Best chunks for `query`, filtered by audience/doc ids; empty if nothing passes `min_score` (BM25)."""
terms = self.query_terms(query)
if not terms:
return []
threshold = self._min_score if min_score is None else min_score
scored = [
(s, i)
for i, c in enumerate(self.chunks)
if (audience is None or audience in c.doc.audiences)
and (docs is None or c.doc.id in docs)
and (s := self._score(i, terms)) >= threshold
]
scored.sort(key=lambda x: (-x[0], x[1]))
cued = self.cued_docs(query)
hits = []
for bm25, i in scored[: max(self._candidates, top_k or 0)]:
bonus, passage = self._passage(i, terms)
chunk = self.chunks[i]
hits.append(
Hit(chunk, (bm25 + self._window_weight * bonus) * self.boost(chunk, audience, cued), bm25, passage)
)
hits.sort(key=lambda h: -h.score) # stable: BM25 order breaks ties
return hits[: top_k or self._top_k]
@lru_cache
def get_index() -> BM25Index:
return BM25Index(load_chunks(), load_config())
def retrieve(query: str, **kwargs) -> list[Hit]:
"""Module-level shortcut: `retrieve("когда начинает действовать антидепрессант", audience="patient")`."""
return get_index().search(query, **kwargs)
+320
View File
@@ -0,0 +1,320 @@
"""Grounded answers from the knowledge base: every sentence shown is quoted from a cited chunk.
Pipeline (deterministic except for the optional patient rephrasing):
1. **Dose guard (patient).** Dose questions are never answered — the dose is the doctor's decision.
2. **Retrieve.** Top `answer.max_chunks` chunks for the audience (`team` documents never reach patients
or doctors, see `config/rag/sources.yaml`).
3. **Sentences.** Chunks are split into sentences; KR boilerplate is stripped (literature refs "[1, 15]").
Doctors also get table rows as "column: value; ..." lines and evidence grades. For patients, sentences
about prescribing/dosing or with forbidden content (`QUOTE_FILTER`) are dropped.
4. **Passage.** In each chunk the best sentence (idf-weighted overlap with the query) is extended with
adjacent relevant sentences into one contiguous passage — the answer reads as written in the source,
not as a collage. The best passage, plus a second one if it scores close, is kept.
5. **No-answer gate.** If the passages cover less than `answer.min_coverage` of the question's idf mass
(e.g. "рецепт борща"), there is no answer -> `None`.
6. **Rephrase (patient only).** The excerpt may be simplified via `gateway.phrase("rag_answer")`; a
rephrasing that adds content (`med_qa.adds_content`) is rejected and the excerpt is shown verbatim.
Doctors always get the verbatim text.
7. **Framing.** Intro, citations "[КР ПТСР 2023, стр. 28]" and the doctor note from `config/texts/rag.yaml`
are added outside the LLM, so they are always present.
Safety screening of the incoming message is the caller's job (guardrail #2): `answer()` expects text that
already passed `app.core.safety` and `privacy.scrub()`. Logs carry only codes and counts.
"""
import logging
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel
from app.config import get_settings
from app.core.types import ScrubbedText
from app.llm.gateway import FORBIDDEN_OUTPUT, MAX_REPLY_CHARS, get_gateway
from app.llm.med_qa import DOSE_QUESTION, DOSING_SENTENCE, adds_content
from app.llm.rag.corpus import TABLE_DIVIDER, AnswerConfig, Audience, Chunk, load_config, split_sentences
from app.llm.rag.index import BM25Index, get_index, tokens
log = logging.getLogger(__name__)
PHRASE_INTENT = "rag_answer"
LIT_REFS = re.compile(r"\s*\[\d+(?:\s*[-–,]\s*\d+)*\s*,?\]") # "[1, 15, 16]", "[48]", "[1-3]"
GRADE = re.compile(r"\s*Уровень убедительности рекомендаций\s+[ABCАВС]\b.*$", re.IGNORECASE)
BULLET = re.compile(r"^\s*(?:[-–—●•▪◦*]|\d+[.)])\s+")
COMMENT_LABEL = re.compile(r"^Комментари[йи]\s*[:.]\s*")
# KR sentences about prescribing are for the doctor: a patient reading "рекомендуется назначение
# пароксетина" or "дозировка титруется" out of context takes it as advice (SPEC §7: no drug/dose advice).
PRESCRIBING = re.compile(
r"назнач|дозиров|титр|мг\b|мг/|доз[аеуыо]\b|дозы\b|/сут|в сутки|раза? в день|разово"
r"|\d+\s*(?:[-–]\s*\d+\s*)?таблет",
re.IGNORECASE,
)
# "что такое флэшбек" -> the term; its definition "Флэшбек (flashback – англ.) – ..." is preferred.
DEFINITION_QUESTION = re.compile(r"^\s*что (?:такое|значит|означает|это за)\s+(.+?)[?.!\s]*$", re.IGNORECASE)
LEADING_PLACEHOLDERS = re.compile(r"^(?:\s*\[[A-Z_]+\][\s,.:;!-]*)+") # "[NAME], что такое ПТСР?"
DEFINITION_HEAD = re.compile(r"\s[–—-]\s(?![^(]*\))|\s[–—-]?\s?это\s") # dash outside "(… – англ.)", or "это"
DEFINITION_HEAD_CHARS = 60
DEFINITION_BONUS = 2.0 # multiplier for a sentence that starts with the asked term (picks it within a chunk)
# The gateway's output filter targets the bot's own words. Two of its patterns also hit neutral quotes from
# the KR — the definition "ПТСР – это расстройство, которое..." and "диагноз ставится на основании..." —
# and would cut exactly the patient-information text. Quotes are checked against the rest of the filter
# (dose/drug imperatives, "у вас депрессия", confidentiality promises).
QUOTE_EXEMPT_PREFIXES = ("диагноз", "(это|похоже на|признаки)")
QUOTE_FILTER = tuple(p for p in FORBIDDEN_OUTPUT if not p.pattern.startswith(QUOTE_EXEMPT_PREFIXES))
MIN_SENTENCE_CHARS = 25
LEAD_IN_CHARS = 80
# A best sentence opening with a reference back ("Это помогает...") needs the sentence it refers to.
ANAPHORA = re.compile(
r"^(Это|Эти|Этот|Эта|Он|Она|Оно|Они|Такие|Такой|Данн\w+|При этом|Кроме того|Однако|Поэтому)\b"
) # a best sentence this short ("Что такое ПТСР?") pulls in the next one as its answer
class Citation(BaseModel):
doc: str # document id from sources.yaml
short: str # "КР ПТСР 2023"
title: str
section: str
pages: str # "28" | "28–29"
@property
def label(self) -> str:
return f"{self.short}, стр. {self.pages}"
class RagAnswer(BaseModel):
text: str # framed answer: intro + body + sources (+ doctor note for patients)
excerpt: str # the quoted passages, verbatim
citations: list[Citation]
audience: Audience
coverage: float # share of the question covered by the passages (0..1)
phrased_by_llm: bool
class AudienceTexts(BaseModel):
fallback: str
intro: str
sources_label: str
doctor_note: str = ""
@dataclass(frozen=True)
class Passage:
chunk: Chunk
text: str
score: float
definition: bool = False # starts with the term asked in "что такое X?"
@lru_cache
def load_rag_texts(path: Path | None = None) -> dict[str, AudienceTexts]:
path = path or get_settings().config_dir / "texts" / "rag.yaml"
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
return {k: AudienceTexts.model_validate(v) for k, v in raw.items()}
def fallback_text(audience: Audience = "patient") -> str:
"""What the caller sends when `answer()` returns `None`."""
return load_rag_texts()[audience].fallback
def _clean(sentence: str, *, keep_grade: bool) -> str:
s = BULLET.sub("", sentence)
s = COMMENT_LABEL.sub("", s)
if not keep_grade: # evidence grades matter to doctors, to a patient they are noise
s = GRADE.sub("", s)
s = LIT_REFS.sub("", s)
return " ".join(s.split()).strip(" ;")
def _cells(line: str) -> list[str]:
return [c.strip() for c in line.strip().strip("|").split("|")]
def _row_text(header: list[str], row: list[str]) -> str:
pairs = [f"{h}: {v}" if h else v for h, v in zip(header, row, strict=False) if v]
return LIT_REFS.sub("", "; ".join(pairs))
def chunk_sentences(chunk: Chunk, *, clinical: bool) -> list[str | None]:
"""Readable sentences of a chunk in reading order. `clinical` (doctor/team) keeps table rows as
"column: value" lines and evidence grades. `None` marks a dropped row: passages do not span it."""
out: list[str | None] = []
lines = chunk.text.splitlines()
header: list[str] | None = None
for i, line in enumerate(lines):
if line.startswith("|"):
if TABLE_DIVIDER.match(line):
continue
if i + 1 < len(lines) and TABLE_DIVIDER.match(lines[i + 1]):
header = _cells(line)
continue
out.append(_row_text(header or [], _cells(line)) if clinical else None)
continue
header = None
out.extend(s for s in (_clean(p, keep_grade=clinical) for p in split_sentences(line)) if s)
return out
def _allowed(sentence: str | None, audience: Audience) -> bool:
if sentence is None or len(sentence) < MIN_SENTENCE_CHARS and not sentence.endswith(("?", ":")):
return False
if sentence[0].islower():
return False # tail of a paragraph cut by the chunk boundary: unreadable without its start
if audience == "patient":
return not (
DOSING_SENTENCE.search(sentence)
or PRESCRIBING.search(sentence)
or any(p.search(sentence) for p in QUOTE_FILTER)
)
return True
def _defined_term(question: str) -> set[str] | None:
m = DEFINITION_QUESTION.match(LEADING_PLACEHOLDERS.sub("", question))
return set(tokens(m.group(1))) or None if m else None
def _is_definition(sentence: str, defined: set[str]) -> bool:
""" "Флэшбек (flashback – англ.) – возврат..." / "ПТСР – это расстройство...": the term, then a dash."""
head = DEFINITION_HEAD.split(sentence, maxsplit=1)
return len(head) == 2 and len(head[0]) <= DEFINITION_HEAD_CHARS and bool(set(tokens(head[0])) & defined)
def _sentence_score(index: BM25Index, sentence: str, terms: dict[str, float], defined: set[str] | None) -> float:
score = index.overlap(sentence, terms)
if defined and score > 0 and _is_definition(sentence, defined):
score *= DEFINITION_BONUS
return score
def best_passage(
index: BM25Index,
chunk: Chunk,
terms: dict[str, float],
audience: Audience,
cfg: AnswerConfig,
defined: set[str] | None = None,
cued: set[str] | None = None,
) -> Passage | None:
"""Best sentence of the chunk extended by adjacent relevant sentences (contiguous, within limits)."""
sents = [s if _allowed(s, audience) else None for s in chunk_sentences(chunk, clinical=audience != "patient")]
scores = [_sentence_score(index, s, terms, defined) if s else 0.0 for s in sents]
if not scores or max(scores) <= 0:
return None
best = max(range(len(sents)), key=lambda i: (scores[i], -i))
head = sents[best]
assert head is not None # score > 0 implies an allowed sentence
lo = hi = best
size = len(head)
def fits(j: int) -> bool:
if not 0 <= j < len(sents):
return False
s = sents[j]
return s is not None and size + len(s) + 1 <= cfg.max_chars
# A short lead-in (question-like heading, "Комментарии:"-style intro) needs the sentence after it.
def length(j: int) -> int:
return len(sents[j] or "") + 1
if (size < LEAD_IN_CHARS or head.endswith(("?", ":"))) and fits(best + 1):
hi += 1
size += length(hi)
if ANAPHORA.match(head) and fits(best - 1):
lo -= 1
size += length(lo)
floor = cfg.neighbor_ratio * scores[best]
while hi - lo + 1 < cfg.max_sentences:
nxt = next((j for j in (hi + 1, lo - 1) if fits(j) and scores[j] > 0 and scores[j] >= floor), None)
if nxt is None:
break
lo, hi = min(lo, nxt), max(hi, nxt)
size += length(nxt)
text = " ".join(s for s in sents[lo : hi + 1] if s)
definition = scores[best] > index.overlap(head, terms) # got DEFINITION_BONUS
return Passage(chunk, text, index.overlap(text, terms) * index.boost(chunk, audience, cued), definition)
def select(index: BM25Index, question: str, audience: Audience, docs: set[str] | None = None) -> list[Passage]:
"""Best passage, plus a second one from another chunk if it scores within `second_passage_ratio`."""
cfg = load_config().answer
hits = index.search(question, audience=audience, docs=docs, top_k=cfg.max_chunks)
terms = index.query_terms(question)
defined = _defined_term(question)
cued = index.cued_docs(question)
passages = [p for h in hits if (p := best_passage(index, h.chunk, terms, audience, cfg, defined, cued))]
if not passages:
return []
# A definition answers "что такое X?" better than any passage that merely mentions X often.
passages.sort(key=lambda p: (not p.definition, -p.score)) # stable: retrieval rank breaks ties
top = passages[0]
chosen = [top]
for p in passages[1:]:
if len(chosen) >= cfg.max_passages:
break
if p.score >= cfg.second_passage_ratio * top.score and p.text != top.text:
chosen.append(p)
return chosen
def _citation(chunk: Chunk) -> Citation:
return Citation(
doc=chunk.doc.id, short=chunk.doc.short, title=chunk.doc.title, section=chunk.section, pages=chunk.pages
)
async def _rephrase(excerpt: str, question: str) -> tuple[str, bool]:
if len(excerpt) > MAX_REPLY_CHARS or any(p.search(excerpt) for p in FORBIDDEN_OUTPUT):
return excerpt, False # the gateway would reject any reply this long / with these words anyway
text = await get_gateway().phrase(PHRASE_INTENT, {"excerpt": excerpt, "question": question})
if " ".join(text.split()) == " ".join(excerpt.split()):
return excerpt, False
if adds_content(text, excerpt, question):
log.warning("rag rephrasing rejected: adds content")
return excerpt, False
return text, True
async def answer(
question: ScrubbedText,
*,
audience: Audience = "patient",
docs: set[str] | None = None,
index: BM25Index | None = None,
) -> RagAnswer | None:
"""Answer strictly from the knowledge base, with citations; `None` -> caller sends `fallback_text()`."""
if audience == "patient" and DOSE_QUESTION.search(question.lower()):
log.info("rag: dose question redirected to doctor")
return None
index = index or get_index()
passages = select(index, question, audience, docs)
if not passages:
log.info("rag: no relevant fragment audience=%s", audience)
return None
coverage = index.coverage(" ".join(p.text for p in passages), question)
if coverage < load_config().answer.min_coverage:
log.info("rag: low coverage audience=%s coverage=%.2f", audience, coverage)
return None
citations = [_citation(p.chunk) for p in passages]
texts = load_rag_texts()[audience]
if audience == "patient":
excerpt = " ".join(p.text for p in passages)
body, by_llm = await _rephrase(excerpt, question)
sources = "; ".join(dict.fromkeys(c.label for c in citations))
text = f"{texts.intro} {body}\n\n{texts.sources_label}: {sources}."
else: # doctor/team: verbatim quotes, each with its own citation
excerpt = "\n".join(f"{p.text} [{c.label}]" for p, c in zip(passages, citations, strict=True))
by_llm = False
text = f"{texts.intro}\n{excerpt}"
if texts.doctor_note:
text += f"\n\n{texts.doctor_note}"
log.info("rag: answered audience=%s passages=%d coverage=%.2f llm=%s", audience, len(passages), coverage, by_llm)
return RagAnswer(
text=text, excerpt=excerpt, citations=citations, audience=audience, coverage=coverage, phrased_by_llm=by_llm
)
+112
View File
@@ -0,0 +1,112 @@
"""Russian Snowball stemmer (https://snowballstem.org/algorithms/russian/stemmer.html).
Self-contained because `pyproject.toml` is a frozen contract (#34): no new dependencies for the RAG.
Latin words and digits pass through lowercased; "ё" is folded to "е".
"""
import re
from functools import lru_cache
_VOWELS = set("аеиоуыэюя")
# (suffixes, needs_a_ya): group-1 suffixes count only when preceded by "а"/"я" (which stays in the stem).
_PERFECTIVE_GERUND = ((("вшись", "вши", "в"), True), (("ившись", "ывшись", "ивши", "ывши", "ив", "ыв"), False))
_REFLEXIVE = ((("ся", "сь"), False),)
_ADJECTIVE = (
(
(
"ими", "ыми", "его", "ого", "ему", "ому", "ее", "ие", "ые", "ое", "ей", "ий", "ый", "ой", "ем",
"им", "ым", "ом", "их", "ых", "ую", "юю", "ая", "яя", "ою", "ею",
),
False,
),
) # fmt: skip
_PARTICIPLE = ((("ем", "нн", "вш", "ющ", "щ"), True), (("ивш", "ывш", "ующ"), False))
_VERB = (
(("ла", "на", "ете", "йте", "ли", "й", "л", "ем", "н", "ло", "но", "ет", "ют", "ны", "ть", "ешь", "нно"), True),
(
(
"ила", "ыла", "ена", "ейте", "уйте", "ите", "или", "ыли", "ей", "уй", "ил", "ыл", "им", "ым", "ен",
"ило", "ыло", "ено", "ят", "ует", "уют", "ит", "ыт", "ены", "ить", "ыть", "ишь", "ую", "ю",
),
False,
),
) # fmt: skip
_NOUN = (
(
(
"иями", "ями", "ами", "ией", "иям", "ием", "иях", "ев", "ов", "ие", "ье", "еи", "ии", "ей", "ой",
"ий", "ям", "ем", "ам", "ом", "ах", "ях", "ию", "ью", "ия", "ья", "а", "е", "и", "й", "о", "у",
"ы", "ь", "ю", "я",
),
False,
),
) # fmt: skip
_DERIVATIONAL = ((("ость", "ост"), False),)
_SUPERLATIVE = ((("ейше", "ейш"), False),)
_CYRILLIC_WORD = re.compile(r"^[а-я]+$")
def _strip(word: str, start: int, groups: tuple[tuple[tuple[str, ...], bool], ...]) -> str | None:
"""Remove the longest matching suffix lying in word[start:], or `None` if none matches."""
best: tuple[int, str] | None = None
for suffixes, needs_a_ya in groups:
for suffix in suffixes:
if not word.endswith(suffix) or len(word) - len(suffix) < start:
continue
if needs_a_ya and (len(word) - len(suffix) - 1 < start or word[-len(suffix) - 1] not in "ая"):
continue
if best is None or len(suffix) > best[0]:
best = (len(suffix), word[: -len(suffix)])
return None if best is None else best[1]
def _regions(word: str) -> tuple[int, int]:
"""RV (after the first vowel) and R2 start indices."""
rv = next((i + 1 for i, ch in enumerate(word) if ch in _VOWELS), len(word))
def after_vc(start: int) -> int:
for i in range(start + 1, len(word)):
if word[i] not in _VOWELS and word[i - 1] in _VOWELS:
return i + 1
return len(word)
return rv, after_vc(after_vc(0))
@lru_cache(maxsize=65536)
def stem(word: str) -> str:
word = word.lower().replace("ё", "е")
if not _CYRILLIC_WORD.match(word):
return word
rv, r2 = _regions(word)
# Step 1
out = _strip(word, rv, _PERFECTIVE_GERUND)
if out is None:
word = _strip(word, rv, _REFLEXIVE) or word
out = _strip(word, rv, _ADJECTIVE)
if out is not None:
out = _strip(out, rv, _PARTICIPLE) or out
else:
out = _strip(word, rv, _VERB)
if out is None:
out = _strip(word, rv, _NOUN)
word = out if out is not None else word
# Step 2
if word.endswith("и") and len(word) - 1 >= rv:
word = word[:-1]
# Step 3
word = _strip(word, r2, _DERIVATIONAL) or word
# Step 4
if word.endswith("нн") and len(word) - 1 >= rv:
return word[:-1]
sup = _strip(word, rv, _SUPERLATIVE)
if sup is not None:
word = sup
return word[:-1] if word.endswith("нн") else word
if word.endswith("ь") and len(word) - 1 >= rv:
word = word[:-1]
return word
+327
View File
@@ -0,0 +1,327 @@
"""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.
* `SaluteSTT` — Sber SaluteSpeech, synchronous REST recognition of the whole file (RF contour, #121, ADR-016).
The pilot provider: the only one allowed for real patients.
* `MockSTT` — offline, returns a fixed synthetic phrase (ADR-004: the demo works without network).
"""
import asyncio
import base64
import logging
import ssl
import struct
import time
import uuid
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
class WavInfo(BaseModel):
audio_format: int # 1 = PCM
channels: int
sample_rate: int
byte_rate: int
bits: int
data_offset: int
data_size: int
def wav_info(audio: bytes) -> WavInfo:
"""Header of a 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, fmt = 12, None
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:
fmt = struct.unpack_from("<HHIIHH", audio, pos + 8)
elif chunk_id == b"data":
if not fmt or not fmt[3]:
break
audio_format, channels, rate, byte_rate, _, bits = fmt
return WavInfo(
audio_format=audio_format,
channels=channels,
sample_rate=rate,
byte_rate=byte_rate,
bits=bits,
data_offset=pos + 8,
data_size=min(size, len(audio) - pos - 8),
)
pos += 8 + size + (size & 1)
raise InvalidAudio("malformed WAV")
def wav_seconds(audio: bytes) -> float:
"""Duration of a PCM WAV file; raises `InvalidAudio` for anything else."""
info = wav_info(audio)
return info.data_size / info.byte_rate
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")
SALUTE_OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
SALUTE_RECOGNIZE_URL = "https://smartspeech.sber.ru/rest/v1/speech:recognize"
class _SaluteToken:
"""OAuth access token, cached until a minute before expiry (Sber tokens live 30 min) and shared per process:
a new token per request would burn the OAuth rate limit."""
def __init__(self) -> None:
self.value = ""
self.expires = 0.0 # time.monotonic() deadline
self.lock = asyncio.Lock()
@lru_cache(maxsize=4)
def _salute_token(credentials: str, scope: str) -> _SaluteToken:
return _SaluteToken()
class SaluteSTT:
name = "salute"
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
self._credentials = settings.salute_speech_credentials.get_secret_value()
self._scope = settings.salute_speech_scope
self._timeout = settings.stt_timeout_s
self._ca = settings.salute_speech_ca
self._client = client
self._token = _salute_token(self._credentials, self._scope)
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
info = wav_info(audio)
if info.audio_format != 1 or info.bits != 16 or info.channels != 1:
raise InvalidAudio("SaluteSpeech needs 16-bit mono PCM")
pcm = audio[info.data_offset : info.data_offset + info.data_size]
headers = {"Content-Type": f"audio/x-pcm;bit=16;rate={info.sample_rate}"}
data = await self._post(pcm, headers)
if data.get("status", 200) != 200 or not isinstance(data.get("result"), list):
raise SttError("bad response")
text = " ".join(str(part).strip() for part in data["result"] if str(part).strip())
if not text:
raise SttNoSpeech("no speech")
return text
async def _get_token(self, client: httpx.AsyncClient, *, force: bool = False) -> str:
tok = self._token
if not force and tok.value and time.monotonic() < tok.expires:
return tok.value
async with tok.lock:
if not force and tok.value and time.monotonic() < tok.expires:
return tok.value
headers = {
"Authorization": f"Basic {self._credentials}",
"RqUID": str(uuid.uuid4()),
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
try:
resp = await client.post(SALUTE_OAUTH_URL, headers=headers, data={"scope": self._scope})
except httpx.HTTPError as exc:
raise SttError(f"oauth {type(exc).__name__}") from exc
if resp.status_code != 200:
log.error("stt salute oauth http %s", resp.status_code)
raise SttError(f"oauth http {resp.status_code}")
try:
body = resp.json()
value, expires_at_ms = body["access_token"], float(body["expires_at"])
except (ValueError, KeyError, TypeError) as exc:
raise SttError("oauth bad response") from exc
# expires_at is wall-clock epoch ms from Sber; keep a monotonic deadline with a minute of margin.
ttl = max(0.0, expires_at_ms / 1000 - time.time() - 60)
tok.value, tok.expires = value, time.monotonic() + ttl
log.info("stt salute oauth token refreshed")
return value
def _verify(self) -> ssl.SSLContext:
context = ssl.create_default_context()
if self._ca is not None:
context.load_verify_locations(cafile=str(self._ca))
return context
async def _post(self, pcm: bytes, headers: dict[str, str]) -> dict:
if self._client is not None:
return await self._recognize(self._client, pcm, headers)
async with httpx.AsyncClient(timeout=self._timeout, verify=self._verify()) as client:
return await self._recognize(client, pcm, headers)
async def _recognize(self, client: httpx.AsyncClient, pcm: bytes, headers: dict[str, str]) -> dict:
refreshed = False
for attempt in range(_ATTEMPTS):
token = await self._get_token(client)
try:
resp = await client.post(
SALUTE_RECOGNIZE_URL,
content=pcm,
params={"language": "ru-RU"},
headers={**headers, "Authorization": f"Bearer {token}"},
timeout=self._timeout,
)
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 == 401 and not refreshed:
# The cached token was revoked or expired early: fetch a new one once.
refreshed = True
await self._get_token(client, force=True)
continue
if resp.status_code in _RETRY_STATUSES and attempt + 1 < _ATTEMPTS:
log.warning("stt salute http %s, retry %d", resp.status_code, attempt + 1)
await asyncio.sleep(0.5 * 2**attempt)
continue
if resp.status_code != 200:
log.error("stt salute 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` = SaluteSpeech if credentials are set. Otherwise only in the demo (synthetic data): OpenRouter if a key is
set, else mock. Outside the demo `auto` never picks a foreign service or a fake transcript — voice stays off.
"""
mode = settings.stt_provider
has_salute = bool(settings.salute_speech_credentials.get_secret_value())
if mode == "auto":
if has_salute:
mode = "salute"
elif not settings.demo_clock:
return None
else:
mode = "openrouter" if settings.openrouter_api_key else "mock"
if mode == "salute":
return SaluteSTT(settings) if has_salute else None
if mode == "openrouter":
return OpenRouterSTT(settings) if settings.openrouter_api_key else None
if mode == "mock":
return MockSTT()
return None
+14 -2
View File
@@ -30,12 +30,24 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# In compose Postgres is required and healthy before the app starts; this branch is for running the
# API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable.
log.error("db init failed, continuing without db: %s", type(exc).__name__)
if settings.max_enabled:
raise # MAX requires persistent bindings and a delivery queue.
# Lanes fill these in (#13, #11); main.py itself stays frozen (#34).
await scheduler.start()
max_channel = None
try:
await telegram.start()
if settings.max_enabled:
from app.channels import max_bot
max_channel = max_bot
await max_channel.start()
await scheduler.start()
yield
await telegram.stop()
finally:
await scheduler.stop()
if max_channel is not None:
await max_channel.stop()
await telegram.stop()
await dispose()
+257
View File
@@ -0,0 +1,257 @@
"""Demo scheduler on the virtual clock (#13, SPEC §8, ADR-005).
`run_until(now)` replays, in time order, every event that is due by `now` and still fresh
(`config/scheduler.yaml`):
* med_reminder — a `doses` row (pending) per prescription time, then `system:med_reminder {dose_id, drug, dose}`;
* missed — a reminded dose still pending `missed_after_hours` later → status `missed` → monitoring;
* daily_checkin — `system:daily_checkin` at the check-in time;
* scale_due — scales due by the module schedules (`config/modules.yaml`) → `system:scale_due`;
* evaluation — once a day `MonitoringService.after_data_change()`: catches silence > N days (rule
`no_contact_3d`) and missed doses even when the patient writes nothing.
The rules decide (ADR-007); the scheduler only turns their dialog actions into system events for the engine
(`reach_out` → `system:reach_out`, `schedule_scale` → `system:scale_due`). Replies go to `integrations.outbox`.
Idempotent: a delivered event is stored in `scheduler_events` under a unique key and never sent again; a failed
delivery is not stored and is retried on the next run while it is fresh (at least once). Only patients with
consent (and adult) are contacted; while a crisis is open no reminders, check-ins or scales are sent.
Time comes only from the arguments / `app.core.clock` (guardrail #4).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.modules import due_scales, load_modules
from app.core.rules import Action
from app.db import repo
from app.db.clinical import DialogState, Dose, PatientProfile, Prescription
from app.db.scheduler import SchedulerEvent
from app.db.session import get_clinical_session
from app.dialog.contracts import Event, Reply, get_engine
from app.integrations.base import integrations
from app.services.contracts import get_monitoring
log = logging.getLogger(__name__)
# ------------------------------------------------------------------------------------------------ config
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class DosesConfig(_Strict):
late_hours: float = Field(gt=0)
missed_after_hours: float = Field(gt=0)
class Window(_Strict):
time: time
late_hours: float = Field(gt=0)
class SchedulerConfig(_Strict):
enabled: bool = True
tick_seconds: float = Field(gt=0)
doses: DosesConfig
daily_checkin: Window
scales: Window
evaluation: Window
@lru_cache
def load_config(path: Path | None = None) -> SchedulerConfig:
path = path or get_settings().config_dir / "scheduler.yaml"
return SchedulerConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------------ jobs
# Same due time → this order: the dose before the check-in that asks about it.
_ORDER = {"evaluation": 0, "missed": 1, "med_reminder": 2, "scale_due": 3, "daily_checkin": 4}
@dataclass(order=True)
class Job:
due: datetime
rank: int
token: str
key: str
kind: str = field(compare=False)
params: dict[str, Any] = field(default_factory=dict, compare=False)
def _job(due: datetime, token: str, key: str, kind: str, **params: Any) -> Job:
return Job(due=due, rank=_ORDER[kind], token=token, key=key, kind=kind, params=params)
def _at(day: date, t: time, now: datetime) -> datetime:
return datetime.combine(day, t, tzinfo=now.tzinfo)
def _fresh(due: datetime, now: datetime, late_hours: float) -> bool:
return due <= now < due + timedelta(hours=late_hours)
def _window(day: date, w: Window, now: datetime) -> datetime | None:
due = _at(day, w.time, now)
return due if _fresh(due, now, w.late_hours) else None
async def _dose_jobs(s: AsyncSession, p: PatientProfile, now: datetime, cfg: SchedulerConfig) -> list[Job]:
jobs: list[Job] = []
today = now.date()
for rx in (await s.scalars(select(Prescription).where(Prescription.token == p.token))).all():
start = rx.start_date or p.therapy_start
for day in (today - timedelta(days=1), today): # a fresh dose is never older than a day
if (start and day < start) or (rx.end_date and day > rx.end_date):
continue
for raw in (rx.schedule or {}).get("times", []):
planned = _at(day, time.fromisoformat(raw), now)
if _fresh(planned, now, cfg.doses.late_hours):
key = f"dose:{rx.id}:{planned.isoformat(timespec='minutes')}"
jobs.append(_job(planned, p.token, key, "med_reminder", rx_id=rx.id, planned_at=planned))
return jobs
async def _missed_jobs(s: AsyncSession, token: str, now: datetime, cfg: SchedulerConfig) -> list[Job]:
after = timedelta(hours=cfg.doses.missed_after_hours)
stmt = select(Dose).where(Dose.token == token, Dose.status == "pending", Dose.planned_at <= now - after)
return [_job(d.planned_at + after, token, f"missed:{d.id}", "missed", dose_id=d.id) for d in await s.scalars(stmt)]
async def _scale_jobs(s: AsyncSession, p: PatientProfile, now: datetime, cfg: SchedulerConfig) -> list[Job]:
due_at = _window(now.date(), cfg.scales, now)
if due_at is None:
return []
last: dict[str, date] = {}
for r in await repo.list_scale_results(s, p.token):
if r.complete:
last[r.scale] = r.ts.astimezone(now.tzinfo).date()
if p.therapy_start:
# A scale never taken counts from the visit where therapy started, not as overdue on day 0.
for mod in load_modules().modules.values():
for entry in mod.schedule:
last.setdefault(entry.scale, p.therapy_start)
therapy_day = (now.date() - p.therapy_start).days if p.therapy_start else 0
scales = due_scales(set(p.modules or []), therapy_day, last, now.date())
return [_job(due_at, p.token, f"scale:{sc}:{now.date()}", "scale_due", scale=sc) for sc in scales]
async def collect(now: datetime, cfg: SchedulerConfig | None = None) -> list[Job]:
"""Due and fresh, not yet delivered events of all contactable patients, in time order."""
cfg = cfg or load_config()
jobs: list[Job] = []
async with get_clinical_session() as s:
for p in (await s.scalars(select(PatientProfile).order_by(PatientProfile.token))).all():
state = await s.get(DialogState, p.token)
data = state.data if state is not None else {}
if data.get("consent") is not True or data.get("adult") is not True:
continue
mine = await _missed_jobs(s, p.token, now, cfg)
if (due := _window(now.date(), cfg.evaluation, now)) is not None:
mine.append(_job(due, p.token, f"eval:{now.date()}", "evaluation"))
if not (data.get("crisis") or {}).get("open"):
mine += await _dose_jobs(s, p, now, cfg)
mine += await _scale_jobs(s, p, now, cfg)
if (due := _window(now.date(), cfg.daily_checkin, now)) is not None:
mine.append(_job(due, p.token, f"checkin:{now.date()}", "daily_checkin"))
done = set((await s.scalars(select(SchedulerEvent.key).where(SchedulerEvent.token == p.token))).all())
jobs += [j for j in mine if j.key not in done]
return sorted(jobs)
# ------------------------------------------------------------------------------------------------ delivery
async def _system(token: str, kind: str, **params: Any) -> None:
replies: list[Reply] = await get_engine().handle(
token,
Event(type="system", system=kind, params=params), # type: ignore[arg-type]
)
if replies:
await integrations.outbox.push(token, replies)
async def _after_rules(token: str) -> None:
"""Monitoring pass outside a dialog: hand the rules' dialog actions to the engine as system events."""
actions: list[Action] = await get_monitoring().after_data_change(token)
for a in actions:
if a.type == "reach_out":
await _system(token, "reach_out", **a.params)
elif a.type == "schedule_scale" and a.params.get("scale"):
await _system(token, "scale_due", scale=a.params["scale"], scheduled=bool(a.params.get("scheduled")))
elif a.type == "crisis":
log.warning("scheduler crisis action token=%s code=%s", token, a.code) # alert already persisted
async def _ensure_dose(token: str, rx_id: int, planned_at: datetime) -> tuple[int, Prescription]:
async with get_clinical_session() as s:
rx = await s.get(Prescription, rx_id)
assert rx is not None
dose = await s.scalar(
select(Dose).where(Dose.token == token, Dose.prescription_id == rx_id, Dose.planned_at == planned_at)
)
if dose is None:
dose = Dose(token=token, prescription_id=rx_id, planned_at=planned_at, status="pending")
s.add(dose)
await s.flush()
return dose.id, rx
async def _mark_missed(dose_id: int) -> bool:
async with get_clinical_session() as s:
dose = await s.get(Dose, dose_id)
if dose is None or dose.status != "pending":
return False # answered meanwhile
dose.status = "missed"
return True
async def _run(job: Job, now: datetime) -> None:
if job.kind == "med_reminder":
dose_id, rx = await _ensure_dose(job.token, job.params["rx_id"], job.params["planned_at"])
await _system(job.token, "med_reminder", dose_id=dose_id, drug=rx.drug, dose=rx.dose)
elif job.kind == "missed":
if await _mark_missed(job.params["dose_id"]):
await _after_rules(job.token)
elif job.kind == "daily_checkin":
await _system(job.token, "daily_checkin")
elif job.kind == "scale_due":
await _system(job.token, "scale_due", scale=job.params["scale"], scheduled=True)
elif job.kind == "evaluation":
await _after_rules(job.token)
async def _done(job: Job, now: datetime) -> None:
async with get_clinical_session() as s:
s.add(SchedulerEvent(token=job.token, key=job.key, kind=job.kind, due_at=job.due, done_at=now))
async def run_until(now: datetime, cfg: SchedulerConfig | None = None) -> list[Job]:
"""Deliver every due event up to `now` in time order; returns the delivered jobs."""
delivered: list[Job] = []
for job in await collect(now, cfg):
try:
await _run(job, now)
except Exception as exc: # noqa: BLE001 — one patient's failure must not stop the others
log.error("scheduler job failed token=%s kind=%s error=%s", job.token, job.kind, type(exc).__name__)
continue
await _done(job, now)
delivered.append(job)
if delivered:
log.info("scheduler delivered=%s", [(j.token, j.kind) for j in delivered])
return delivered
+40 -12
View File
@@ -1,4 +1,4 @@
"""Questionnaire jobs on the demo clock; clinical release disabled by default.
"""Scheduler on the demo clock: questionnaire jobs when that release is on, else the demo scheduler (#13).
One scheduler per database. PostgreSQL's advisory transaction lock serializes
workers; an asyncio lock does the same within a SQLite demonstration process.
@@ -15,15 +15,20 @@ from sqlalchemy import select, text
from sqlalchemy.exc import SQLAlchemyError
from app.core import clock
from app.core.intake_questions import DIARY_KINDS
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
from app.core.questionnaire import load_policy
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
@@ -40,20 +45,31 @@ 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:
continue
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day])
diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"]
last_activity = max([*diaries, initial.day])
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
continue
if local.hour >= p.daily_hour and not any(
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows
):
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now)
done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"}
# Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2).
for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)):
if local.hour >= hour and kind not in done:
await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now)
last = {}
for row in rows:
if row.status != "complete":
@@ -81,11 +97,20 @@ async def plan():
after_crisis=crisis_date,
policy=p,
)
if scales or local.hour >= p.daily_hour:
# Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring.
if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done:
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now)
totals = initial.details.get("totals", {})
modules = set(profile.modules or [])
policy = load_policy()
# PHQ-9 only with depression or a positive PHQ-2; PCL-5 only with PTSD or a positive TSQ («Этап 2», p.3).
wanted = {
"phq9": "depression" in modules or bool(totals.get("phq9", {}).get("complete")),
"pcl5": "veteran" in modules or (totals.get("tsq", {}).get("total") or 0) >= policy.tsq_expand_at,
"rus_audit_s": True,
}
for sid in scales:
if sid == "pcl5" and initial.answers.get("trauma") != 1:
continue
if wanted[sid]:
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
@@ -148,6 +173,9 @@ async def run_until_now():
return
await service.dispatch_notices()
if not intake_enabled():
# Questionnaire release off → the demo scheduler (doses, check-ins, scales, silence) runs instead.
if demo.load_config().enabled:
await demo.run_until(clock.now())
return
await plan()
await deliver()
@@ -160,7 +188,7 @@ async def _run():
await run_until_now()
except Exception as exc:
log.error("questionnaire scheduler error=%s", type(exc).__name__)
await asyncio.sleep(30)
await asyncio.sleep(demo.load_config().tick_seconds)
async def start():
@@ -169,7 +197,7 @@ async def start():
await monitoring.init_clock_persistence()
except (OSError, SQLAlchemyError) as exc:
log.error("clock persistence unavailable error=%s", type(exc).__name__)
if _worker is None and intake_enabled():
if _worker is None and (intake_enabled() or demo.load_config().enabled):
_worker = asyncio.create_task(_run())
+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
+173
View File
@@ -0,0 +1,173 @@
"""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.
An honest «Пропущу» on a dose grows the garden like «Принял(а)», so the garden never rewards a false «taken» (#120).
After a recent acute/high alert the garden is `quiet`: growth stays, celebration does not.
"""
from collections import defaultdict
from collections.abc import Iterable
from datetime import date, datetime, timedelta
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 Alert, 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 QuietRule(BaseModel):
alert_levels: list[str]
hours: int
class GardenConfig(BaseModel):
activities: dict[Activity, ActivityRule]
answered_dose_statuses: list[str]
quiet: QuietRule
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]
quiet: bool = False # recent acute/high alert: no «Сад подрос», no grow-in animation
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 in cfg.answered_dose_statuses:
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)
since = now - timedelta(hours=cfg.quiet.hours)
recent_alert = await s.scalar(
select(Alert.id)
.where(Alert.token == token, Alert.level.in_(cfg.quiet.alert_levels), Alert.ts >= since, Alert.ts <= now)
.limit(1)
)
garden.quiet = recent_alert is not None
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:
answered = sum(d.status in cfg.answered_dose_statuses 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=answered > 0 and answered >= due_now,
detail=f"{answered} из {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:
+17 -4
View File
@@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None):
sex = profile.sex if profile else None
if sex is None and prior:
sex = prior.details.get("sex")
day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date()
row = QuestionnaireRecord(
id=uuid4().hex,
token=ctx.token,
@@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None):
version=content()["version"],
policy_version=load_policy().version,
started_at=ctx.now,
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(),
day=day,
status="active",
answers={},
details={
@@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None):
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
"answer_metadata": {},
"task_id": task.id if task else None,
# Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day).
"day_index": (day - prior.day).days if prior else 0,
},
)
ctx.session.add(row)
@@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None):
return row
def save_answer(row, question, value, now):
def save_answer(row, question, value, now, *, derived=False):
"""Anchor recall windows to administration, never to the day of a resumed answer."""
day = row.day
starts = {
@@ -105,8 +108,8 @@ def save_answer(row, question, value, now):
"period": question.period,
"period_start": starts[question.period].isoformat() if question.period in starts else None,
"period_end": day.isoformat(),
"source": "patient",
"derived": False,
"source": "derived" if derived else "patient",
"derived": derived,
},
},
}
@@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now):
return row
async def outstanding_task(session, token, kind):
return await session.scalar(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token,
QuestionnaireTask.kind == kind,
QuestionnaireTask.status.in_(("pending", "active")),
)
)
async def lock_patient(session, token):
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
# use the engine's in-process lock. Consent and answers share this transaction.
+1
View File
@@ -7,3 +7,4 @@
- `modules.yaml` — модули и расписания шкал
- `lexicon/risk_ru.yaml` — лексикон риска
- `texts/` — тексты для пациента (кризисный скрипт, согласие, перефразировки)
- `rag/` — база знаний (ADR-011): `sources.yaml` (документы, параметры поиска, синонимы) и `corpus/*.txt`, собранные из PDF в `docs/source/rag/`
+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
+50
View File
@@ -0,0 +1,50 @@
# Сад (#118, слайд 13): лёгкая геймификация поверх модулей. Расчёт детерминированный, без LLM.
# Принципы «без давления» (вычитка — #120):
# 1. Сад не вянет: очки только прибавляются, пропуск ничего не отнимает.
# 2. Рост не зависит от ответов: считается факт активности, а не баллы шкал или настроение.
# 3. Нет соревнования: только свой сад, никаких сравнений.
# 4. Растит честная отметка, а не «правильный» ответ: «Пропущу» по дозе растит сад так же, как «Принял(а)»,
# иначе сад подталкивал бы отмечать приём, которого не было, и искажал бы данные о приёме для врача.
# 5. После кризиса — тишина: рост сохраняется, но без поздравлений и анимации.
# Очки за активность и дневной лимит по каждому типу (чтобы пять одинаковых действий подряд не давали пятикратный рост).
activities:
meds: # ответ по назначенной дозе: «Принял(а)» или «Пропущу» (см. answered_dose_statuses)
points: 2
daily_cap: 4
diary: # ежедневный чек-ин или запись дневника
points: 3
daily_cap: 1
questionnaire: # завершённый опрос (шкала, этап опроса)
points: 4
daily_cap: 1
# Какие ответы по дозе растят сад. missed (доза осталась без ответа) не растит, но и ничего не отнимает.
answered_dose_statuses: [taken, skipped]
# Тишина после кризиса: если за последние hours часов был алерт одного из этих уровней (в т.ч. по шкале
# или опросу, не только в чате), сад не поздравляет и не анимирует рост.
quiet:
alert_levels: [acute, high]
hours: 72
# Каждые 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: "Назначенный опрос"
+73 -178
View File
@@ -1,189 +1,84 @@
version: diary-v2-2026-10-03
core:
- id: mood
text: 'Какое у Вас сегодня настроение?
# Stage 2 daily diary — «Этап 2: наблюдение после диагноза» (@Andrey, 2026-10-03), p.1–3.
# Every block comes from an instrument checked for frequent use; there are no own questions. No validated thresholds
# exist for daily data: the diary shows a trend and brings a full scale forward, routes come from full scales only.
# Modules follow the screening result of stage 1 (profile modules): depression → «depression», PTSD → «veteran».
version: diary-v3-2026-10-04
0 — самое плохое, 10 — самое хорошее.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
# Core — everyone. Morning block after waking (the sleep diary is built for it), evening block at 20:00.
morning:
- id: sleep_hours
text: Сколько часов Вы спали прошлой ночью?
source: Consensus Sleep Diary
text: Сколько часов Вы спали этой ночью?
numeric: true
- id: rested
text: 'Насколько Вы выспались?
0 — совсем не выспались, 4 — полностью выспались.'
max: 24
- id: sleep_quality
source: Consensus Sleep Diary
text: Как Вы оцениваете качество своего сна?
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- {value: 0, label: Очень плохо}
- {value: 1, label: Плохо}
- {value: 2, label: Удовлетворительно}
- {value: 3, label: Хорошо}
- {value: 4, label: Очень хорошо}
- id: alcohol_portions
source: принцип Timeline Followback; порция по RUS-AUDIT
text: |-
Сколько стандартных порций алкоголя Вы выпили вчера?
Одна порция — 100 мл вина, 250 мл пива или 30 мл крепкого алкоголя. Если не пили — введите 0.
numeric: true
max: 50
evening:
- id: mood
source: ежедневная оценка настроения (недельные средние коррелируют с PHQ-9)
text: |-
Как Вы оцениваете своё настроение сегодня?
0 — самое плохое, 10 — самое хорошее.
options:
- {value: 0, label: '0'}
- {value: 1, label: '1'}
- {value: 2, label: '2'}
- {value: 3, label: '3'}
- {value: 4, label: '4'}
- {value: 5, label: '5'}
- {value: 6, label: '6'}
- {value: 7, label: '7'}
- {value: 8, label: '8'}
- {value: 9, label: '9'}
- {value: 10, label: '10'}
- id: medication
text: Приняли ли Вы сегодня лекарства по схеме?
source: КР ПТСР — дневник приёма лекарств
text: Приняли ли Вы сегодня лекарства, назначенные врачом?
options:
- value: full
label: По схеме
- value: partial
label: Частично
- value: none
label: Нет
- value: not_prescribed
label: Не назначены
- {value: 'yes', label: Да}
- {value: 'no', label: Нет}
- {value: not_prescribed, label: Лекарства не назначены}
- id: medication_reason
text: Почему не приняли лекарства по схеме?
source: КР ПТСР — дневник приёма лекарств
text: Почему не приняли?
options:
- value: forgot
label: Забыл(а)
- value: side_effects
label: Побочные эффекты
- value: declined
label: Не хочу
- value: ran_out
label: Закончились
- value: other
label: Другая причина
- id: side_effects
text: Беспокоили ли Вас сегодня побочные эффекты лекарств?
options: &id001
- value: 1
label: Да
- value: 0
label: Нет
- id: substances
text: Употребляли ли Вы сегодня алкоголь или другие вещества?
options: *id001
- id: comment
text: Что хотите передать врачу?
optional: true
ptsd:
- id: intrusions
text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?'
options: &id002
- value: 0
label: Совсем нет
- value: 1
label: Немного
- value: 2
label: Умеренно
- value: 3
label: Сильно
- value: 4
label: Крайне сильно
- id: nightmares
text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?'
options: *id002
- id: avoidance
text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?'
options: *id002
- id: hypervigilance
text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?'
options: *id002
- id: irritability
text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?'
options: *id002
- {value: forgot, label: Забыл(а)}
- {value: side_effects, label: Побочные эффекты}
- {value: declined, label: Решил(а) не принимать}
- {value: ran_out, label: Закончились}
# Depression module: PHQ-9 items with the official Russian text (config/instruments/intake_forms.yaml), answer
# options adapted to one day. Three items a day in rotation: day 1 — items 1–3, day 2 — 4–6, day 3 — 7–9.
# Item 9 ≥1 → stop and the crisis protocol (stage 1 rule).
depression:
- id: interest
text: 'Насколько Вам сегодня было интересно и приятно что-то делать?
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: depressed
text: Насколько Вы сегодня чувствовали подавленность или безнадёжность?
options: *id002
- id: energy
text: 'Насколько у Вас сегодня были силы и энергия?
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: planned_activity
text: Сделали ли Вы сегодня хотя бы одно запланированное дело?
options: *id001
suicide:
- id: death_thoughts
text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить?
options: *id001
- id: thoughts_intensity
text: 'Если да — насколько сильными они были?
0 — совсем слабые, 10 — максимально сильные.'
form: phq9
items_per_day: 3
question: 'Насколько сегодня Вас беспокоило:'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: hope
text: 'Насколько Вы верите, что станет лучше?
- {value: 0, label: Совсем нет}
- {value: 1, label: Немного}
- {value: 2, label: Умеренно}
- {value: 3, label: Сильно}
0 — совсем не верю, 10 — полностью верю.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: burden
text: Чувствовали ли Вы себя обузой для близких?
options: *id001
weekly: true
# PTSD module: short 4-item PCL-5 (Price et al., 2016), one item per cluster B, C, D, E; «today» instead of «the
# past month», options 0–4 of PCL-5. Item numbers must still be checked against the article (PDF p.7, «Медику»).
ptsd:
form: pcl5
items: [pcl5_1, pcl5_7, pcl5_10, pcl5_18]
question: 'Насколько сильно сегодня Вас беспокоили:'
+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
+17 -7
View File
@@ -1,17 +1,27 @@
version: intake-v2-2026-10-03
version: intake-v3-2026-10-04
enabled: false
approval: null
# Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness.
content_review: null
contact_review: null
timezone: Europe/Moscow
daily_hour: 19
mood_baseline_days: 7
mood_bad_days: 3
mood_drop: 3
sleep_bad_days: 3
# Stage 2 diary («Этап 2: наблюдение после диагноза»): morning block after waking, evening block at 20:00.
morning_hour: 8
evening_hour: 20
# Personal norm = the first `norm_days` of observation; compared with the mean of the last `week_days`.
# Flag numbers are the team's candidates (PDF «Этап 2», p.4) — agree with the doctor.
norm_days: 14
week_days: 7
min_norm_values: 7
min_week_values: 4
mood_drop: 3 # weekly mood mean fell ≥3 below the norm → unscheduled PHQ-9
depression_item_rise: 1.0 # weekly mean of a PHQ-9 diary item rose ≥1 above its norm → PHQ-9 earlier
ptsd_sum_rise: 3.0 # weekly mean of the 4-item PCL-5 sum rose ≥3 above the norm → PCL-5 earlier
sleep_bad_days: 3 # <4 h or «очень плохо» three nights in a row → flag to the doctor
sleep_hours_below: 4
nightmares_at: 3
alcohol_heavy_portions: 6 # ≥6 standard drinks a day = heavy day (volumes of RUS-AUDIT item 3)
alcohol_heavy_days: 2 # heavy days in a week → full RUS-AUDIT
adherence_below: 0.8 # share of days with medication taken over a week
missed_medication_days: 3
high_risk_silence_days: 2
medication_days: [7, 14, 28]
+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
+20
View File
@@ -0,0 +1,20 @@
# DEMO ONLY (INTAKE_DEMO=1 + demo clock, synthetic data). NOT a clinical approval: the real policy is
# config/questionnaire.yaml and stays closed until the doctor's decisions C01–C09 (docs/clinical/README.md).
# Values follow the MVP PDF so the demo can reach a result and stage 2:
# C01 → graded: low C-SSRS risk shows the safety plan and continues (PDF p.11, routing table);
# C02 → full RUS-AUDIT from zone II of RUS-AUDIT-S (WHO zones, Bunova 2022).
version: mvp-v2-demo-2026-10-04
approval:
reviewer: "DEMO — синтетические данные, не клиническое согласование"
decision_reference: "INTAKE_DEMO"
approved_at: "2026-10-04T00:00:00+03:00"
policy_version: mvp-v2-demo-2026-10-04
cssrs_positive_policy: graded
rus_audit_s_expand_from_zone: 2
phq2_expand_at: 2
tsq_expand_at: 6
phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_behavior_flag_at: 3
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
# Модуль «Лекарства»: спецификация для разработки
Источник: meds_module_spec.pdf, страниц: 10. Документ команды от Oct 4, 2026, автор @Andrey. Текст перенесён вручную и сверен с PDF: автоматическое извлечение ломает вёрстку выносок и таблиц. Символ «|» внутри ячеек заменён на «/».
--- стр. 1 ---
Модуль следит не за абстрактными «лекарствами», а за конкретным препаратом пациента. Он напоминает о приёме, узнаёт причины пропусков, спрашивает о побочных эффектах этого препарата и работает по 10 правилам (M1–M10). Данные взяты из двух клинических рекомендаций Минздрава: по депрессии (2024) и по ПТСР (2023).
Что передаём:
- этот документ — логика модуля;
- файл meds_reference.json (прилагается к ссылке) — справочник на 47 препаратов, тексты для пациента и параметры правил;
- вкладка «Справочник препаратов» — тот же справочник в виде таблицы, для проверки медиком.
Как модуль встраивается в этап 2. Вечерний вопрос о лекарствах, опрос о побочных эффектах на 7-й, 14-й и 28-й день и флаги «меньше 80% дней» и «пропуск 3 дня подряд» не меняются. Модуль добавляет к ним данные о препарате и новые правила.
| В MVP | После хакатона |
|---|---|
| Справочник из JSON | Тексты инструкций из ГРЛС в карточке препарата |
| Назначение: ввод в кабинете врача или из имитации ЕМИАС | Назначения из ЕМИАС (электронные рецепты) |
| Напоминание о приёме и отметка в дневнике | Схема постепенной отмены препарата |
| Опрос о побочных эффектах по профилю препарата | Ввод результатов анализов |
| Правила M1–M10 и блок в сводке врачу | Проверка взаимодействий для врача |
--- стр. 2 ---
Границы: бот следит, но не лечит
Препарат и дозу всегда выбирает врач. Если приложение начнёт давать советы о лечении, его могут счесть медицинским изделием, а к ним требования строже.
| Бот делает | Бот не делает никогда |
|---|---|
| Хранит назначение врача: препарат, дозу, время приёма | Не предлагает препарат, дозу, смену или отмену |
| Напоминает о приёме и отмечает, принят ли препарат | На вопросы «можно ли бросить» или «можно ли увеличить дозу» отвечает только «обсудите с врачом» и ставит флаг |
| Спрашивает о побочных эффектах, характерных для этого препарата | Не исправляет и не блокирует дозу вне диапазона — только просит проверить ввод (M10) |
| Показывает карточку препарата из проверенных источников | Не проверяет для пациента взаимодействия лекарств |
| Сигналит врачу по правилам M1–M10 | Не ставит диагноз и не оценивает, «работает ли» лечение, — это делает врач по шкалам |
Модель данных
Шесть сущностей. Drug читается из JSON и в приложении не редактируется, остальные создаёт приложение.
| Сущность | Поля | Откуда берётся |
|---|---|---|
| Drug | id, name_ru, class, indications.{depression/ptsd}.{min_mg, max_mg, start_mg, off_label}, diary_items[], red_flags[], monitoring[], withdrawal_risk, overdose_danger, max_course_days | meds_reference.json |
--- стр. 3 ---
| Сущность | Поля | Откуда берётся |
|---|---|---|
| Prescription | id, patient_id, drug_id, indication (F32/F33/F43.1), dose_mg_per_day, times[] (например, "09:00"), start_date, source (emias / doctor / patient), status (active / stopped), history[] (дата, старая и новая доза) | имитация ЕМИАС или кабинет врача; ввод пациентом — с пометкой «со слов пациента» |
| DoseLog | prescription_id, date, taken (bool), reason (forgot / side_effects / decided_not_to / ran_out), logged_at | вечерний дневник |
| SideEffectReport | date, trigger (scheduled / missed_dose / button), fibser.{freq, intensity, burden}, items[]: {item_id, severity: mild/severe}, red_flags[] | опрос о побочных эффектах |
| MedEvent | prescription_id, type (start / dose_change / stop), date, baseline_scale_ids[] | создаётся при изменении Prescription |
| MedFlag | rule_id (M1…M10), patient_id, prescription_id, created_at, recipient, due_at, status (open / acknowledged), evidence (ответы, по которым сработало правило) | движок правил |
Связь с расписанием этапа 2. Любой MedEvent включает строку «Начало или смена лекарств»: полные шкалы и опрос о побочных эффектах на 7-й, 14-й, 28-й день, далее раз в 4 недели. Исходный замер для M5 — последняя полная шкала за 14 дней до события. Если её нет, шкалу проводят в день события.
Дневник и опросы
В обычный день добавляется только отметка по каждому препарату. Подробно спрашиваем по расписанию или после пропуска.
--- стр. 4 ---
1. Напоминание. Push-уведомление во время из Prescription.times: «Время принять {препарат} {доза}» и кнопка «Принял(а)». Нажатие сразу создаёт DoseLog с taken = true, и вечером этот препарат уже не спрашиваем.
2. Вечерний вопрос (уже есть в ядре этапа 2), теперь по каждому препарату:
1. «Приняли ли Вы сегодня лекарства, назначенные врачом?» — да, все / не все / нет.
2. Если «не все» — выбрать, какие не приняты.
3. По каждому непринятому — причина: забыл(а) · побочные эффекты · решил(а) не принимать · закончились.
Причина напрямую запускает правила: «побочные эффекты» → внеплановый опрос (п. 3) и M8, «решил(а) не принимать» → M2, «закончились» → M3.
3. Опрос о побочных эффектах. Проводится в трёх случаях: по расписанию этапа 2 (7-й, 14-й, 28-й день, далее раз в 4 недели), после пропуска «из-за побочных эффектов» и по кнопке «Сообщить о побочном эффекте», которая доступна всегда.
1. Три вопроса FIBSER: как часто, насколько сильно, насколько мешают жить (из этапа 2).
2. Если частота > 0 — «Что именно беспокоит?»: список из diary_items всех активных препаратов без повторов, плюс «другое» с текстом. У каждого пункта два ответа: немного / сильно мешает.
3. Вопросы о срочных симптомах — только из red_flags активных препаратов, с ответом да / нет. Ответ «да» → M7.
Список пунктов для препарата формируется так. Для 16 антидепрессантов берём эффекты с оценкой «++» и «+++» из таблицы 3 КР по депрессии. Для остальных препаратов — эффекты, которые названы в тексте КР. В JSON список уже собран: например, у сертралина это тошнота, бессонница, сексуальные проблемы и головная боль.
4. Карточки о сроках эффекта. Текст expect_effect_depression показываем в 1-й и 14-й день после начала, expect_effect_ptsd — в 1-й и 7-й. Тексты лежат в JSON, в разделе texts.
--- стр. 5 ---
Контрольные точки после начала лечения
[Изображение: текст перенесён вручную]
Схема «Ответ оценивают к 4–6 неделе, лечение длится месяцы». Подзаголовок: «Фазы после начала или смены препарата: что делает модуль и что говорят КР». Таблица по фазам:
| Строка | Недели 1–2 | Недели 3–6 | Месяцы и годы |
|---|---|---|---|
| Модуль (что делает бот) | Дни 7 и 14: шкалы и опрос о побочных. Карточка «когда ждать эффекта» | День 28: шкалы, побочные и проверка ответа — M5. Бензодиазепин дольше допустимого — M4 | Шкалы и опрос о побочных раз в 4 недели до конца курса |
| Депрессия (КР 2024) | Раннее улучшение (1–2 недели) — благоприятный признак | Эффект через 3–4 недели, у СИОЗС — до 6–8 недель; снижение на 25–50% — ответ недостаточен | Тот же препарат и доза 4–6 мес. после улучшения; при РДР — 3–5 лет |
| ПТСР (КР 2023) | СИОЗС: в 1-ю неделю тревога может усилиться; следить за суицидальным риском | Эффект оценивают через 4–6 недель; бензодиазепины — не дольше 3–4 недель | Курс 6–12 месяцев после стабилизации |
Подпись под схемой: «Фазы после начала лечения · модуль, депрессия, ПТСР».
M5 проверяет ответ на 28-й день при обоих диагнозах. Для ПТСР это ранняя граница окна из КР: окончательно эффект оценивают до 6-й недели, поэтому флаг — повод для врача посмотреть, а не сигнал тревоги.
Правила сигналов M1–M10
К флагам этапа 2 по лекарствам (меньше 80% дней с приёмом, пропуск 3 дня подряд, тяжёлый день алкоголя при приёме лекарств) добавляются десять правил. Пороги и сроки хранятся в rules[].params в JSON и меняются без правки кода. Строки с пометкой «черновик» нужно согласовать с психиатром.
--- стр. 6 ---
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|---|---|---|---|---|
| M1 | У препарата withdrawal_risk = high (венлафаксин, пароксетин, ТЦА, бензодиазепины) и он пропущен 2 дня подряд | Текст withdrawal_missed и вопрос «Стало ли хуже после пропуска?» | Лечащий психиатр: ответ «хуже» — 24 ч, иначе — до 3 рабочих дней. Черновик | КР депрессия 3.6; КР ПТСР 3.2 |
| M2 | Причина пропуска «решил(а) не принимать» | Тексты dont_stop_alone (+ why_continue_depression при F32/F33), флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В; КР ПТСР 6.1 |
| M3 | Причина «закончились» | Текст ran_out; 2 дня подряд → флаг | Лечащий врач, до 3 рабочих дней. Черновик | команда |
| M4 | Бензодиазепин принимается дольше max_course_days | Отметка в кабинете и сводке | Лечащий психиатр, к следующему визиту | КР ПТСР 3.2 |
| M5 | 28-й день после MedEvent: PHQ-9 снизился меньше чем на 50% (F32/F33) или PCL-5 — меньше чем на 10 баллов (F43.1) от исходного | Флаг «нет ответа к 4-й неделе» | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия 3.1–3.2; КР ПТСР 3.2; пороги ответа — этап 2 |
--- стр. 7 ---
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|---|---|---|---|---|
| M6 | Сработал сигнал C-SSRS (умеренный или высокий риск), а у пациента активен препарат с overdose_danger | Пометка «препарат опасен при передозировке» в этом же сигнале, новый сигнал не создаётся | Как у сигнала C-SSRS | КР депрессия 3.1.3 |
| M7 | Пациент отметил срочный симптом из red_flags | Текст red_flag (врач сегодня; при тяжёлом состоянии 103/112), флаг | Лечащий психиатр, 24 ч. Черновик | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
| M8 | FIBSER «насколько мешают» ≥ 5 из 6 или пропуск «из-за побочных» в 2 из 7 дней | Текст side_effects_general, флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В |
| M9 | У активного препарата есть monitoring | Строка «Контроль по КР: …» в сводке врачу, это не сигнал | Лечащий врач, в сводке | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
| M10 | При сохранении назначения доза вне min_mg – max_mg для этого диагноза | «Проверьте ввод» тому, кто вводит; пометка в сводке; сохранение не блокируется | Тот, кто вводит, сразу | Приложения А3 обеих КР |
Все флаги попадают в кабинет врача с датой, правилом и ответами, по которым правило сработало, как в этапе 2.
Карточка препарата, сводка врачу, демо
Карточка препарата построена по списку «что сообщить пациенту» из КР по депрессии (приложение В). Поля с источником «инструкция» в MVP показывают «Уточните у врача или в инструкции». Для 2–3 демо-препаратов их можно заполнить вручную.
--- стр. 8 ---
| Поле | Источник | Пример: сертралин при ПТСР |
|---|---|---|
| Название, доза, время приёма | Prescription | Сертралин, 50 мг в сутки, в 09:00 |
| Когда ждать эффекта | indications.*.expected_response | Эффект оценивают через 4–6 недель |
| Сколько принимать | indications.*.course + решение врача | Обычно 6–12 месяцев после стабилизации |
| Частые побочные эффекты | diary_items | Тошнота, бессонница, сексуальные проблемы, головная боль |
| Когда срочно к врачу | red_flags | Необычные кровотечения или синяки |
| Не прекращать самостоятельно | texts.dont_stop_alone | общий текст |
| Как принимать, связь с едой, что делать при пропуске | инструкция (ГРЛС) | — |
| Алкоголь, вождение, беременность | инструкция (ГРЛС) | — |
Блок «Лекарства» в сводке врачу (дополняет сводку этапа 2), по каждому препарату:
- препарат, доза, дата начала и неделя приёма рядом с ожидаемым сроком ответа (например, «неделя 3 из 4–6»);
- доля дней с приёмом за 4 недели и причины пропусков;
- побочные эффекты: FIBSER и пункты «что именно» по замерам, срочные симптомы;
- ответ на 28-й день (M5), контроль по КР (M9), пометки M4 и M10.
Демо: ветеран Иван, ПТСР. Этот сценарий стыкуется с демо этапа 2: там ухудшение на 3-й неделе, здесь видна его причина.
--- стр. 9 ---
1. День 0. Врач в кабинете назначает сертралин 50 мг в сутки (препарат 1-й линии по КР ПТСР). Включается расписание «Начало или смена лекарств», пациент получает карточку expect_effect_ptsd.
2. День 3. Отметка «не принял — побочные эффекты». Внеплановый опрос: тошнота «немного», бессонница «сильно мешает».
3. День 7. Плановые шкалы и опрос о побочных эффектах.
4. Дни 17–19. Три дня подряд «решил не принимать». На 17-й день срабатывает M2 (карточка и флаг врачу), на 19-й — флаг этапа 2 «пропуск 3 дня».
5. Неделя 3. Ухудшение по дневнику → внеплановый PCL-5 → повторный триаж (сценарий этапа 2). В сводке врач видит, что ухудшение совпало с прекращением приёма из-за бессонницы.
Для питча: КР по ПТСР (раздел 3.2) прямо рекомендуют для соблюдения режима «ведение дневника приёма лекарств или телефонное приложение с напоминанием». В разделе 6.1 сказано, что пациенты часто бросают лекарства после улучшения и состояние ухудшается.
Черновые параметры — согласовать с психиатрами
Код можно писать сейчас: все числа ниже лежат в JSON и заменяются без правки логики. Вопросы к врачам по этапу 2 собраны в документе «Этап 2: вопросы к психиатрам».
| Параметр | Черновое значение | Основание |
|---|---|---|
| M1: сколько дней пропуска препарата с риском отмены | 2 дня подряд | риск — КР; число — команда |
| M4: предел курса бензодиазепинов | 21 день; диазепам 28; алпразолам 90 | КР ПТСР 3.2 |
| M5: день проверки ответа | 28-й день для обоих диагнозов | КР: депрессия 3–4 недели, ПТСР 4–6 недель |
| M5: исходный замер | последняя полная шкала за 14 дней до старта, иначе в день старта | команда |
| M8: «сильно мешают» | FIBSER-бремя ≥ 5 из 6 или 2 пропуска из-за побочных за 7 дней | порог FIBSER сверить с оригиналом шкалы |
--- стр. 10 ---
| Параметр | Черновое значение | Основание |
|---|---|---|
| M7: список срочных симптомов и текст пациенту | 5 симптомов из JSON, текст red_flag | симптомы — КР; текст — команда |
| Сроки реакции | 24 ч / до 3 рабочих дней / к визиту | как в этапе 2 |
| Ввод назначения пациентом | разрешён, с пометкой «со слов пациента» | команда |
| Все тексты для пациента из texts | черновики | вычитать врачу |
Источники
- Клинические рекомендации «Депрессивный эпизод, рекуррентное депрессивное расстройство», Минздрав РФ, 2024, Российское общество психиатров. Использованы разделы 3.1–3.6 и приложения А3 (табл. 2 — дозы, табл. 3 — побочные эффекты), Б5 (синдром отмены), В (информация для пациента).
- Клинические рекомендации «Посттравматическое стрессовое расстройство», Минздрав РФ, 2023, ID 753, Российское общество психиатров. Использованы разделы 3.2 и 6.1, приложения А3 (дозы), Б1 (алгоритм терапии), В. Срок пересмотра — не позднее 2025 года: проверить, не вышла ли новая версия.
- «Этап 2: наблюдение после диагноза» — наш документ о расписании, флагах и сроках реакции.
Расхождения внутри КР отмечены в JSON в поле note. Пример: хлорпротиксен в тексте КР по ПТСР — 15–100 мг, а в таблице — 25–150 мг.
+108
View File
@@ -0,0 +1,108 @@
# База знаний RAG (#60): документы, параметры поиска, синонимы.
# Тексты в corpus/ собраны из PDF конвертером docs/source/rag/pdf_to_txt.py — правьте PDF/figures/manual
# и перегенерируйте, а не редактируйте txt руками.
#
# audiences — кому можно показывать выдержки:
# doctor — врач (дашборд, подсказки по КР);
# patient — пациент (только экстрактивно, без дозировок, с припиской «решение за врачом»);
# team — внутренние документы команды (логика модулей), пациенту и врачу не показываются.
documents:
- id: kr_ptsd
file: corpus/kr_ptsd.txt
title: "Клинические рекомендации «Посттравматическое стрессовое расстройство»"
short: "КР ПТСР 2023"
issuer: "Минздрав РФ, Российское общество психиатров"
year: 2023
icd: [F43.1]
audiences: [doctor, patient]
- id: kr_depression
file: corpus/kr_depression.txt
title: "Клинические рекомендации «Депрессивный эпизод, рекуррентное депрессивное расстройство»"
short: "КР Депрессия 2024"
issuer: "Минздрав РФ, Российское общество психиатров"
year: 2024
icd: [F32, F33]
audiences: [doctor, patient]
- id: meds_module_spec
file: corpus/meds_module_spec.txt
title: "Модуль «Лекарства»: спецификация для разработки"
short: "Спецификация модуля «Лекарства»"
issuer: "команда MedITron"
year: 2026
icd: []
audiences: [team]
chunking:
max_chars: 1200 # целевой размер фрагмента; абзац длиннее режется по предложениям
min_chars: 200 # короткий хвост раздела приклеивается к предыдущему фрагменту того же раздела
# Разделы, которые не индексируются (шум для поиска). Сравнение — по началу заголовка.
skip_sections:
- "Список литературы"
- "Список сокращений" # аббревиатуры раскрываются через synonyms ниже, сам список только засоряет поиск
retrieval:
top_k: 5
bm25_k1: 1.5
bm25_b: 0.75
title_weight: 2 # заголовок раздела повторяется в индексе столько раз — совпадение с ним весомее
min_score: 3.0 # ниже — считаем, что в базе нет ответа
rerank_candidates: 30 # сколько лучших по BM25 фрагментов переранжировать по самому плотному отрывку
window_sentences: 2 # размер отрывка (предложений подряд)
window_weight: 0.5 # вес оценки отрывка относительно BM25
# Множитель оценки для разделов (по началу заголовка) в зависимости от аудитории.
# «Приложение В» в обеих КР — «Информация для пациента»: написано для пациента, ему и показываем в первую очередь.
# «Приложение Г» — бланки шкал и история их валидации, «Критерии оценки качества» — для организаторов
# здравоохранения: пациенту это шум.
section_boost:
patient:
"Приложение В": 1.5
"Приложение Г": 0.5
"Критерии оценки качества": 0.5
# Заболевание в вопросе -> профильная КР (регулярные выражения по вопросу в нижнем регистре).
# Если названы оба — ни одна не получает преимущества. Иначе «ТМС при депрессии» отвечался цитатой из КР ПТСР.
doc_cues:
kr_depression: ["(?<!анти)депресс", "\\bдэ\\b", "\\bрдр\\b"]
kr_ptsd: ["птср", "посттравмат", "травм", "\\bвойн", "боев", "фронт", "ветеран", "\\bсво\\b", "ptsd"]
doc_cue_boost: 2.0
answer:
max_chunks: 5 # в скольких лучших фрагментах ищем отрывок
max_passages: 2 # отрывков в ответе (второй — только если почти так же хорош)
second_passage_ratio: 0.8
max_sentences: 3 # предложений в отрывке (подряд, как в источнике)
neighbor_ratio: 0.5 # соседнее предложение входит в отрывок, если его релевантность >= доли лучшего
max_chars: 600
min_coverage: 0.5 # доля «веса» слов вопроса, найденная в отрывках; ниже — «ответа нет»
# Синонимы и аббревиатуры: запрос расширяется всеми формами группы (сравнение — по основам слов).
synonyms:
- ["птср", "посттравматическое стрессовое расстройство"]
- ["кптср", "комплексное посттравматическое стрессовое расстройство"]
- ["рдр", "рекуррентное депрессивное расстройство"]
- ["дэ", "депрессивный эпизод"]
- ["ад", "антидепрессант"]
- ["сиозс", "селективные ингибиторы обратного захвата серотонина"]
- ["сиозсн", "ингибиторы обратного захвата серотонина и норадреналина"]
- ["тца", "трициклические антидепрессанты"]
- ["кпт", "когнитивно-поведенческая психотерапия"]
- ["дпдг", "emdr", "десенсибилизация и переработка травмы движениями глаз"]
- ["сопа", "синдром отмены антидепрессантов"]
- ["эст", "электросудорожная терапия"]
- ["ртмс", "транскраниальная магнитная стимуляция"]
- ["кошмар", "кошмарные сновидения"]
- ["бессонница", "инсомния", "нарушения сна"]
- ["флешбэк", "флэшбек", "навязчивые воспоминания"]
- ["суицид", "самоубийство", "суицидальные мысли"]
# Разговорная лексика пациента -> термины КР.
- ["таблетки", "препарат", "лекарство"]
- ["бросить", "отменять", "отменить", "отмена", "прекратить", "прекращение приема"]
- ["подействовать", "действовать", "помогать", "эффект", "улучшение"]
- ["антидепрессивный", "тимоаналептический"]
- ["привыкание", "зависимость"]
- ["спорт", "физическая активность", "физические упражнения", "физическая нагрузка"]
- ["побочка", "побочные эффекты", "нежелательные явления"]
- ["тошнит", "тошнота"]
- ["выпить", "спиртное", "алкоголь"]
- ["сколько длится", "продолжительность", "длительность"]
- ["психолог", "психотерапия"]
+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
+25
View File
@@ -0,0 +1,25 @@
# Demo scheduler on the virtual clock (#13, SPEC §8, ADR-005). Local times are in settings.tz.
# Read by app/scheduler/demo.py; used only while the questionnaire release (intake_runtime.yaml) is off.
#
# Every event is delivered once (idempotency key in `scheduler_events`) and only while it is fresh: an event
# older than its late window is not sent at all — after a long jump of the demo clock the patient does not
# receive a pile of yesterday's reminders, and an undelivered reminder never turns into a "missed" dose.
enabled: true
tick_seconds: 30
doses:
late_hours: 2 # reminder not delivered if the dose time is older than this
missed_after_hours: 2 # reminded dose without an answer → status "missed" (SPEC §8)
daily_checkin:
time: "20:00"
late_hours: 4
scales:
time: "10:00" # scales due today (config/modules.yaml schedules) are offered from this hour
late_hours: 10
evaluation:
time: "09:00" # daily monitoring pass: silence > N days, missed doses → caring contact
late_hours: 15
+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"
+11 -5
View File
@@ -53,7 +53,7 @@ age_gate:
unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990."
minor_stop: |-
Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия 8-800-2000-122 (бесплатно, анонимно, круглосуточно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия {phone:child_helpline} (бесплатно, анонимно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если есть угроза жизни прямо сейчас — звоните 112.
idle:
@@ -63,6 +63,7 @@ idle:
buttons:
checkin: "Как я сегодня"
med_question: "Вопрос о лекарстве"
kb_question: "Вопрос о лечении"
contact_doctor: "Связаться с врачом"
revoke: "Отозвать согласие"
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
@@ -78,9 +79,9 @@ revoked:
crisis:
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:"
repeat: "Я рядом. Если вам угрожает опасность прямо сейчас — позвоните:"
# Любое другое сообщение, пока кризис открыт (до подтверждения врача).
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50."
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}."
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
@@ -100,7 +101,7 @@ safety_plan:
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
- "Люди и места, которые отвлекают: к кому можно пойти."
- "Кому позвонить, если тяжело: близкий человек, сослуживец."
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112."
- "Специалисты: ваш врач, телефон доверия {phone:helpline}, экстренные службы 112."
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
@@ -108,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: "Вернёмся к вопросу:"
+4
View File
@@ -14,7 +14,11 @@ sex: "Укажите пол для интерпретации алкогольн
tbi: "Были ли у Вас контузия или черепно-мозговая травма?"
psychotropics: "Принимаете ли Вы сейчас психотропные препараты?"
missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже."
side_effects: "Беспокоили ли Вас за последнее время побочные эффекты лекарств?"
numeric_portions: "Введите число стандартных порций, например 0 или 2."
diary_done: "Дневник на сегодня уже заполнен. Спасибо!"
periods:
since_last_contact: "С момента Вашего последнего ответа на эти вопросы."
past_month: "За последний месяц."
lifetime: "За всю жизнь."
past_3_months: "За последние три месяца."
+66
View File
@@ -0,0 +1,66 @@
# Вопросы пациента о состоянии и лечении → база знаний по клиническим рекомендациям (#64, RAG #60).
# Зона clinical вычитывает формулировки. Сам ответ — только цитата из КР с источником (config/texts/rag.yaml
# задаёт его обрамление); здесь — тексты диалога и признаки того, что свободное сообщение — вопрос.
# Кнопка меню «Вопрос о лечении» → бот просит написать вопрос.
prompt: |-
Напишите ваш вопрос о состоянии или лечении — например, «что такое флэшбек?» или «помогает ли психотерапия?».
Я отвечу цитатой из клинических рекомендаций Минздрава и укажу источник. Вопросы о дозе и о вашем препарате решает врач.
# Вопрос о дозе (увеличить, уменьшить, полтаблетки): бот не отвечает по существу — это решение врача.
dose_redirect: "Дозу и схему приёма подбирает только ваш врач — менять их самостоятельно нельзя. Могу передать ваш вопрос врачу: нажмите «Связаться с врачом»."
# Свободное сообщение считается вопросом, если в нём есть «?» или оно начинается с одного из этих слов.
# Тогда бот ищет ответ в базе знаний; если ответа нет — отвечает обычной фразой поддержки.
question_starts:
- "что такое"
- "что значит"
- "что означает"
- "почему"
- "зачем"
- "как"
- "когда"
- "сколько"
- "можно ли"
- "нужно ли"
- "надо ли"
- "помогает ли"
- "поможет ли"
- "правда ли"
- "бывает ли"
- "что делать"
- "что будет"
- "чем"
- "где"
- "есть ли"
- "что содержится"
- "что содержат"
- "что входит"
- "что в составе"
- "какой состав"
- "какие побочки"
- "какие побочные"
- "состав"
- "какие"
- "можно"
- "из чего"
- "в чём"
- "в чем"
- "расскажи"
- "объясни"
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
small_talk:
- "как дела"
- "как ты"
- "как вы"
- "как жизнь"
- "как поживаете"
- "что нового"
- "кто ты"
- "ты кто"
- "ты бот"
- "ты робот"
- "привет"
- "спасибо"
+8
View File
@@ -0,0 +1,8 @@
connected: "Уведомления подключены. Здесь будут появляться напоминания с сайта. Отключить их можно командой /stop."
not_connected: "Чтобы получать уведомления, откройте сайт и подключите MAX в настройках уведомлений."
invalid: "Не удалось подключить уведомления. Создайте новую ссылку на сайте. Если MAX уже подключён к другому аккаунту, сначала отключите прежнюю привязку."
stopped: "Уведомления отключены. Подключить их снова можно по новой ссылке с сайта."
help: "Этот бот присылает уведомления. Пройти тесты, посмотреть результаты и продолжить общение можно на сайте. Команда /stop отключает уведомления."
notification: "На сайте есть напоминание для вас. Откройте личный кабинет, чтобы продолжить."
completed: "Прохождение теста завершено. Подробности доступны в личном кабинете на сайте."
open_site: "Открыть сайт"
+2
View File
@@ -21,6 +21,8 @@ phrases:
doctor_notified: "Я передал информацию вашему врачу. Он свяжется с вами."
# med_qa: базовый смысл = выдержка из инструкции ГРЛС; LLM только упрощает её, ничего не добавляя.
med_answer: "{excerpt}"
# RAG (#60): базовый смысл = выдержка из клинических рекомендаций; LLM только упрощает её, ничего не добавляя.
rag_answer: "{excerpt}"
# Шаблон саммари для врача (только по структурированным данным).
summary:
+24
View File
@@ -0,0 +1,24 @@
# Тексты ответов из базы знаний (RAG, #60). Зона clinical вычитывает формулировки.
# Сам ответ — ТОЛЬКО предложения из клинических рекомендаций (экстрактивно); здесь — обрамление.
# Правила: без советов по дозам, без рекомендаций препаратов, решение о лечении — за врачом.
patient:
# Ничего не нашлось / вопрос о дозе — честно говорим и отправляем к врачу.
fallback: "В клинических рекомендациях я не нашёл ответа на этот вопрос. Уточните, пожалуйста, у вашего врача."
# Пациент должен понимать, что это выдержка из документа для врачей, а не совет бота.
intro: "В клинических рекомендациях Минздрава сказано:"
sources_label: "Источник"
# Всегда в конце (не проходит через LLM).
doctor_note: "Это общая информация из документа для врачей. Что подходит именно вам, решает ваш лечащий врач."
doctor:
fallback: "В базе знаний (КР ПТСР 2023, КР Депрессия 2024) нет фрагмента по этому запросу."
intro: "По клиническим рекомендациям:"
sources_label: "Источники"
doctor_note: ""
team:
fallback: "В документах команды нет фрагмента по этому запросу."
intro: "По документам команды:"
sources_label: "Источники"
doctor_note: ""
+120
View File
@@ -0,0 +1,120 @@
# Patient-facing interpretation of questionnaire results (ADR-008: texts in config). The rules that pick a text are
# deterministic (app/core/interpretation.py) and use the thresholds of config/questionnaire*.yaml, PDF «Опросники для
# MVP» (stage 1) and «Этап 2» (follow-up). The app never diagnoses: every result is a screening result.
disclaimer: "Это результат скрининга, а не диагноз. Диагноз ставит врач после очной консультации."
intake:
ok:
title: "Признаков депрессии и ПТСР не выявлено"
text: "По Вашим ответам сейчас нет признаков, которые требуют обращения к специалисту. Можно спать спокойно: продолжайте короткий дневник — так Вы заметите изменения раньше."
attention:
title: "Есть признаки, которые стоит обсудить со специалистом"
text: "Это не диагноз, но ответы похожи на те, что бывают при состояниях ниже. Ниже — что именно мы заметили и что делать дальше."
findings:
depression_suspected:
title: "Предположение депрессии"
text: "Ответы указывают на выраженные симптомы депрессии за последние 2 недели. Включаем модуль «Депрессия» в ежедневном дневнике."
depression_mild:
title: "Лёгкие симптомы депрессии"
text: "Есть отдельные симптомы сниженного настроения. Они часто проходят сами, но за ними стоит последить."
ptsd_suspected:
title: "Склонность к ПТСР"
text: "Ответы указывают на выраженные последствия пережитого травматического события за последний месяц. Включаем модуль «ПТСР» в ежедневном дневнике."
ptsd_signs:
title: "Отдельные признаки посттравматического стресса"
text: "После травматического события есть отдельные реакции, но их выраженность ниже порога вероятного ПТСР."
alcohol_risky:
title: "Рискованное употребление алкоголя"
text: "Употребление алкоголя выше безопасного уровня. Стоит сократить его и обсудить это с врачом."
alcohol_high:
title: "Высокий риск зависимости от алкоголя"
text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом."
suicide_low:
title: "Тяжёлые мысли"
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия {phone:helpline}."
tbi:
title: "Контузия или ЧМТ"
text: "Рекомендуем консультацию невролога."
routes:
urgent_psychiatrist: "Срочно свяжитесь с психиатром — ориентир: в течение 24 часов."
priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни."
psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту."
psychologist: "Рекомендуем консультацию психолога."
self_help: "Достаточно самопомощи и короткого ежедневного дневника."
modules:
title: "Что дальше"
diary: "Каждый день — короткий дневник: 3 вопроса утром и 2 вечером{modules}. Полные опросники — по расписанию."
names:
depression: "«Депрессия»"
veteran: "«ПТСР»"
with: ", плюс модуль {names}"
scales:
phq9:
title: "Опросник PHQ-9 (депрессия)"
levels:
minimal: "минимальные симптомы"
mild: "лёгкие симптомы"
moderate: "умеренные симптомы"
moderately_severe: "умеренно тяжёлые симптомы"
severe: "тяжёлые симптомы"
pcl5:
title: "Опросник PCL-5 (ПТСР)"
levels:
below: "ниже порога вероятного ПТСР"
probable: "выше порога вероятного ПТСР"
rus_audit_s:
title: "Опросник RUS-AUDIT-S (алкоголь)"
levels:
zone_1: "относительно низкий риск"
zone_2: "опасное употребление"
zone_3: "пагубное употребление"
zone_4: "риск зависимости"
unknown: "нельзя оценить без указания пола"
score: "{title}: {total} — {level}."
change:
remission: "Это ремиссия по шкале — отличный результат."
response: "По сравнению с началом наблюдения — ответ на лечение."
improved: "По сравнению с началом наблюдения — клинически значимое улучшение."
stable: "По сравнению с началом наблюдения существенных изменений нет."
worse: "По сравнению с прошлым замером состояние ухудшилось. Врач получит сигнал и пересмотрит план."
diary:
saved: "Спасибо, дневник за {part} сохранён."
parts:
morning: "утро"
evening: "вечер"
flags:
phq9_due_mood: "Настроение за неделю заметно ниже Вашей обычной нормы — предложим полный опросник PHQ-9 раньше срока."
phq9_due_depression: "Симптомы депрессии за неделю выше Вашей нормы — предложим полный опросник PHQ-9 раньше срока."
pcl5_due_ptsd: "Симптомы ПТСР за неделю выше Вашей нормы — предложим полный опросник PCL-5 раньше срока."
sleep_review: "Несколько ночей подряд очень плохой или короткий сон. Врач увидит это в сводке."
alcohol_on_medication: "Много алкоголя при приёме лекарств опасно. Врач получит отметку."
rus_audit_due_alcohol: "За неделю было несколько дней с большим количеством алкоголя — предложим опросник об алкоголе."
medication_contact: "Несколько дней подряд без лекарств. Не прекращайте приём без врача — мы сообщим ему."
adherence_review: "За неделю лекарства пропущены чаще, чем в 1 из 5 дней. Обсудите это с врачом."
side_effects_review: "Вы отметили побочные эффекты — врач получит отметку."
cssrs:
none: "Спасибо за ответы."
low: "Спасибо, что ответили честно. Откройте план безопасности, если станет тяжело."
kinds:
intake: "Первичный опрос"
diary_morning: "Дневник — утро"
diary_evening: "Дневник — вечер"
cssrs_since_contact: "Вопросы о безопасности"
phq9: "PHQ-9 по расписанию"
pcl5: "PCL-5 по расписанию"
rus_audit_s: "Алкоголь по расписанию"
diary: "Дневник"
status:
complete: "пройден"
review_required: "остановлен для оценки специалиста"
interrupted: "прерван: открыта экстренная помощь"
active: "в процессе"
paused: "на паузе"
cancelled: "отменён"
+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: |-
+132
View File
@@ -0,0 +1,132 @@
"""Thin HTTP client for the dashboard. The frozen API schemas (`app/api/schemas.py`) are the only contract.
The dashboard never touches the databases: everything goes over HTTP to `API_BASE_URL`. Short timeouts keep the
UI responsive when the backend is down; every failure is turned into `ApiError` with a Russian message that the
page shows as a banner instead of a traceback.
"""
from __future__ import annotations
import logging
import os
from typing import Any, TypeVar
import httpx
from pydantic import BaseModel, TypeAdapter, ValidationError
from app.api.schemas import (
AckIn,
AlertOut,
ClockOut,
ConnectPatientIn,
ConnectPatientOut,
PatientCard,
PatientListItem,
)
log = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://localhost:8000"
DEFAULT_DOCTOR_ID = "doc-smirnova"
API_PREFIX = "/api/v1"
T = TypeVar("T")
class ApiError(Exception):
"""Any API failure, with a message safe to show to the doctor (no PII, no stack traces)."""
def __init__(self, message: str, status: int | None = None) -> None:
super().__init__(message)
self.message = message
self.status = status
class ApiUnavailable(ApiError):
"""Backend not reachable (connection refused, DNS, timeout)."""
class ApiClient:
def __init__(
self,
base_url: str | None = None,
doctor_id: str | None = None,
*,
timeout: float = 3.0,
transport: httpx.BaseTransport | None = None,
) -> None:
base = (base_url or os.environ.get("API_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
if not base.endswith(API_PREFIX):
base += API_PREFIX
self.base_url = base
self.doctor_id = doctor_id or os.environ.get("DOCTOR_ID") or DEFAULT_DOCTOR_ID
self._http = httpx.Client(
base_url=base, timeout=httpx.Timeout(timeout, connect=min(timeout, 1.5)), transport=transport
)
# ------------------------------------------------------------------ doctor endpoints
def patients(self) -> list[PatientListItem]:
return self._get(f"/doctors/{self.doctor_id}/patients", list[PatientListItem])
def patient_card(self, token: str) -> PatientCard:
return self._get(f"/patients/{token}", PatientCard, params={"doctor_id": self.doctor_id})
def alerts(self, since_id: int = -1, *, open_only: bool = False) -> list[AlertOut]:
params = {"since_id": since_id, "open_only": str(open_only).lower()}
return self._get(f"/doctors/{self.doctor_id}/alerts", list[AlertOut], params=params)
def ack(self, alert_id: int) -> AlertOut:
return self._request("POST", f"/alerts/{alert_id}/ack", AlertOut, json=AckIn(doctor_id=self.doctor_id))
def connect(self, body: ConnectPatientIn) -> ConnectPatientOut:
return self._request("POST", "/patients/connect", ConnectPatientOut, json=body)
def clock(self) -> ClockOut | None:
"""Demo clock (ADR-005); optional decoration, so failures return None instead of raising."""
try:
return self._get("/demo/clock", ClockOut)
except ApiError:
return None
# ------------------------------------------------------------------ plumbing
def _get(self, path: str, model: type[T], params: dict[str, Any] | None = None) -> T:
return self._request("GET", path, model, params=params)
def _request(
self,
method: str,
path: str,
model: type[T],
*,
params: dict[str, Any] | None = None,
json: BaseModel | None = None,
) -> T:
payload = json.model_dump(mode="json") if json is not None else None
try:
resp = self._http.request(method, path, params=params, json=payload)
except (httpx.ConnectError, httpx.TimeoutException) as exc:
log.warning("api unavailable: %s %s (%s)", method, _route(path), type(exc).__name__)
raise ApiUnavailable("API недоступен — проверьте, что сервис запущен") from exc
except httpx.HTTPError as exc:
log.warning("api transport error: %s %s (%s)", method, _route(path), type(exc).__name__)
raise ApiUnavailable("API недоступен — ошибка соединения") from exc
if resp.status_code == 404:
raise ApiError("Не найдено", 404)
if resp.status_code >= 400:
log.warning("api error: %s %s -> %s", method, _route(path), resp.status_code)
raise ApiError(f"Ошибка API ({resp.status_code})", resp.status_code)
try:
return TypeAdapter(model).validate_python(resp.json())
except (ValueError, ValidationError) as exc:
log.warning("api bad payload: %s %s", method, _route(path))
raise ApiError("API вернул неожиданный ответ") from exc
def close(self) -> None:
self._http.close()
def _route(path: str) -> str:
"""Path without identifiers, so logs carry no patient tokens."""
parts = path.strip("/").split("/")
return "/" + "/".join(p if not any(ch.isdigit() for ch in p) and not p.startswith("tok") else "*" for p in parts)
+116 -11
View File
@@ -1,18 +1,123 @@
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API."""
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API.
import os
Entry point: `streamlit run dashboard/app.py`. Pages are discovered from `dashboard/pages/*.py` — adding a page
is adding a file, app.py never needs editing:
import httpx
* file name `<N>[letter]_<Title>.py` — N (and the optional letter) sets the order, underscores in the title become
spaces (`2_Демо-консоль.py` → «Демо-консоль»);
* section: pages with N == 1 go to «Кабинет врача», the rest to «Демо»;
* optional module-level constants (read as text, the file is not imported):
`NAV_TITLE = "..."`, `NAV_ICON = ":material/terminal:"`, `NAV_SECTION = "..."`, `NAV_URL = "slug"`.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
_HERE = str(Path(__file__).resolve().parent)
# `streamlit run` puts dashboard/ on sys.path, where this very file would shadow the backend `app` package
# (needed for the frozen schemas). Import everything from the repo root as `dashboard.*` / `app.*` instead.
sys.path[:] = [p for p in sys.path if p not in ("", _HERE) and Path(p or ".").resolve() != Path(_HERE)]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
PAGES_DIR = Path(__file__).resolve().parent / "pages"
DOCTOR_SECTION = "Кабинет врача"
DEMO_SECTION = "Демо"
DEFAULT_ICONS = {
"Пациенты": ":material/groups:",
"Подключить пациента": ":material/person_add:",
"Демо-консоль": ":material/smartphone:",
"Сплит-экран": ":material/splitscreen:",
}
_NAME_RE = re.compile(r"^(\d+)([a-z]?)_(.+)$")
_META_RE = re.compile(r"""^NAV_(TITLE|ICON|SECTION|URL)\s*(?::\s*str\s*)?=\s*["'](.+?)["']\s*$""", re.MULTILINE)
_TRANSLIT = dict(
zip(
"абвгдеёжзийклмнопрстуфхцчшщъыьэюя",
[*"abvgdee", "zh", *"zijklmnoprstufh", "ts", "ch", "sh", "sch", "_", "y", "_", "e", "yu", "ya"],
strict=True,
)
)
@dataclass(frozen=True)
class PageSpec:
path: Path
title: str
icon: str
section: str
url_path: str
order: tuple[int, str]
def slugify(title: str) -> str:
out = "".join(_TRANSLIT.get(ch, ch) for ch in title.lower())
out = out.replace("_", "")
return re.sub(r"[^a-z0-9]+", "-", out).strip("-") or "page"
def discover_pages(pages_dir: Path = PAGES_DIR) -> list[PageSpec]:
specs = []
for path in pages_dir.glob("*.py"):
m = _NAME_RE.match(path.stem)
if not m:
continue
number, letter, raw = int(m.group(1)), m.group(2), m.group(3)
meta = dict(_META_RE.findall(path.read_text(encoding="utf-8")))
title = meta.get("TITLE", raw.replace("_", " "))
specs.append(
PageSpec(
path=path,
title=title,
icon=meta.get("ICON", DEFAULT_ICONS.get(title, ":material/description:")),
section=meta.get("SECTION", DOCTOR_SECTION if number == 1 else DEMO_SECTION),
url_path=meta.get("URL", slugify(title)),
order=(number, letter),
)
)
return sorted(specs, key=lambda s: s.order)
def main() -> None:
import streamlit as st
API = os.environ.get("API_BASE_URL", "http://localhost:8000")
from dashboard import theme
from dashboard.components import live_alerts, state, ui
st.set_page_config(page_title="PTSRaiAgent — кабинет врача", page_icon="🩺", layout="wide")
st.title("Кабинет врача")
st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide")
st.html(theme.CSS)
st.logo(str(ROOT / "dashboard" / "assets" / "logo.svg"), size="large")
specs = discover_pages()
nav: dict[str, list[st.Page]] = {}
section_of: dict[str, str] = {}
for i, s in enumerate(specs):
page = st.Page(str(s.path), title=s.title, icon=s.icon, url_path=s.url_path, default=i == 0)
nav.setdefault(s.section, []).append(page)
section_of[s.url_path] = s.section
current = st.navigation(nav, position="sidebar", expanded=True)
with st.sidebar:
st.html(
'<div class="mt-sidebar-note"><strong>Рядом между визитами</strong>'
"Наблюдение за состоянием пациента и поддержка назначенного лечения.</div>"
)
st.caption("Прототип на синтетических данных")
try:
status = httpx.get(f"{API}/health", timeout=2).json().get("status")
except httpx.HTTPError:
status = "недоступен"
st.write(f"API: **{status}**")
short, _ = state.doctor_names()
st.html(ui.workspace_header(short, current.title))
live_alerts.flush_pending_toasts()
# Full-app rerun on a new alert only on doctor pages: demo-console pages may be mid-scenario.
live_alerts.render(rerun_app_on_new=section_of.get(current.url_path) == DOCTOR_SECTION)
current.run()
ui.footer()
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="228" height="48" viewBox="0 0 228 48" role="img" aria-label="PTSRaiAgent — помощник врача">
<rect x="0" y="6" width="36" height="36" rx="11" fill="#285e4e"/>
<path d="M18 15v18M9 24h18" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
<g font-family="-apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif">
<text x="47" y="25" fill="#213b38" font-size="21" font-weight="600" letter-spacing="-.7">PTSRaiAgent</text>
<text x="48" y="39" fill="#63736f" font-size="9" letter-spacing="1">ПОМОЩНИК ВРАЧА</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 626 B

+173
View File
@@ -0,0 +1,173 @@
"""Plotly figures of the patient card. Pure builders: data in, `go.Figure` out (tested without Streamlit).
Chart rules: one y-axis per chart (scales of different range are separate charts, daily metrics are small
multiples on a shared time axis), a single neutral series colour, status colours only for thresholds.
"""
from __future__ import annotations
from collections.abc import Iterable
from datetime import date, timedelta
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from app.api.schemas import SeriesPoint
from dashboard import theme
from dashboard.components.helpers import SCALE_MAX, metric_points, severity_bands, short_date
# Band shading: one warm hue with growing opacity (sequential), so "higher = worse" reads without a legend.
_BAND_ALPHA = [0.0, 0.05, 0.09, 0.14, 0.20]
def _day_ticks(days: list[date], max_ticks: int = 10) -> dict[str, object]:
if not days:
return {}
first, last = min(days), max(days)
span = (last - first).days + 1
step = max(1, -(-span // max_ticks))
ticks = [first + timedelta(days=i) for i in range(0, span, step)]
return {"tickmode": "array", "tickvals": ticks, "ticktext": [short_date(d) for d in ticks]}
def _point_label(p: SeriesPoint) -> str:
return f"{p.value:g}" if p.scheduled else f"{p.value:g} · внепл."
def _therapy_day(d: date, start: date | None) -> str:
return f"день {(d - start).days}" if start else short_date(d)
def scale_figure(
series: Iterable[SeriesPoint], scale_id: str, title: str, start: date | None = None
) -> go.Figure | None:
"""Total score over time with the severity gradations from config/scales; unscheduled sessions are hollow."""
pts = metric_points(series, scale_id)
if not pts:
return None
top = SCALE_MAX.get(scale_id, max(p.value for p in pts))
fig = go.Figure()
bands = severity_bands(scale_id)
for i, b in enumerate(bands):
alpha = _BAND_ALPHA[min(i, len(_BAND_ALPHA) - 1)] if len(bands) > 3 else [0.0, 0.08, 0.18][min(i, 2)]
fig.add_hrect(y0=b.lo, y1=b.hi + 1 if b.hi < top else top, line_width=0, fillcolor=f"rgba(214,69,69,{alpha})")
fig.add_annotation(
x=0,
xref="paper",
xanchor="left",
y=(b.lo + min(b.hi + 1, top)) / 2,
text=b.label,
showarrow=False,
font={"size": 10, "color": "rgba(122,130,140,0.85)"},
)
xs = [p.day for p in pts]
hover = [f"{short_date(p.day)} · {_therapy_day(p.day, start)}" for p in pts]
fig.add_trace(
go.Scatter(
x=xs,
y=[p.value for p in pts],
mode="lines+markers+text",
line={"color": theme.SERIES, "width": 2},
marker={
"size": [9 if p.scheduled else 13 for p in pts],
"symbol": ["circle" if p.scheduled else "diamond-open" for p in pts],
"color": theme.SERIES,
"line": {"width": [2 if p.scheduled else 2.5 for p in pts], "color": "#FFFFFF"},
},
text=[_point_label(p) if i == len(pts) - 1 else "" for i, p in enumerate(pts)],
textposition="top left",
textfont={"color": theme.INK, "size": 12},
customdata=[[h, "плановый" if p.scheduled else "внеплановый"] for h, p in zip(hover, pts, strict=True)],
hovertemplate="%{customdata[0]}<br><b>%{y:g}</b> баллов · %{customdata[1]}<extra></extra>",
name=title,
)
)
pad = timedelta(days=1)
fig.update_layout(
theme.plotly_layout(title={"text": title, "font": {"size": 14, "color": theme.INK}, "x": 0}, height=260),
hovermode="closest",
)
fig.update_xaxes(**theme.AXIS, **_day_ticks(xs, 4), tickangle=0, range=[min(xs) - pad, max(xs) + pad])
fig.update_yaxes(**theme.AXIS, range=[0, top], fixedrange=True)
return fig
def wellbeing_figure(series: Iterable[SeriesPoint], start: date | None = None) -> go.Figure | None:
"""Daily check-in as three small multiples on one time axis: mood 0–10, sleep hours, alcohol 0/1/2."""
data = list(series)
mood, sleep, alcohol = (metric_points(data, m) for m in ("mood", "sleep_hours", "alcohol"))
if not (mood or sleep or alcohol):
return None
days = sorted({p.day for p in mood + sleep + alcohol})
fig = make_subplots(
rows=3,
cols=1,
shared_xaxes=True,
vertical_spacing=0.09,
row_heights=[0.42, 0.33, 0.25],
subplot_titles=("Настроение, 0–10", "Сон, часов", "Алкоголь"),
)
red_line = {"color": theme.TONES["red"].solid, "width": 1, "dash": "dot"}
def hov(p: SeriesPoint) -> str:
return f"{short_date(p.day)} · {_therapy_day(p.day, start)}"
if mood:
# Mood ≤ 3 for three days triggers an unscheduled PHQ-9 (config/rules.yaml) — shade that zone.
fig.add_hrect(y0=0, y1=3, line_width=0, fillcolor="rgba(214,69,69,0.08)", row=1, col=1)
fig.add_trace(
go.Scatter(
x=[p.day for p in mood],
y=[p.value for p in mood],
mode="lines+markers",
line={"color": theme.SERIES, "width": 2},
marker={"size": 8, "color": theme.SERIES, "line": {"width": 2, "color": "#FFFFFF"}},
customdata=[hov(p) for p in mood],
hovertemplate="%{customdata}<br>настроение <b>%{y:g}</b>/10<extra></extra>",
),
row=1,
col=1,
)
if sleep:
fig.add_trace(
go.Bar(
x=[p.day for p in sleep],
y=[p.value for p in sleep],
marker={"color": [theme.TONES["red"].solid if p.value < 4 else "#9DBFEA" for p in sleep]},
marker_cornerradius=4,
customdata=[hov(p) for p in sleep],
hovertemplate="%{customdata}<br>сон ≈ <b>%{y:g}</b> ч<extra></extra>",
),
row=2,
col=1,
)
fig.add_hline(y=4, line=red_line, row=2, col=1)
if alcohol:
alc_label = {0: "нет", 1: "1–2 порции", 2: "больше"}
fig.add_trace(
go.Bar(
x=[p.day for p in alcohol],
y=[max(p.value, 0.08) for p in alcohol],
marker={
"color": [
{0: theme.LINE, 1: theme.TONES["yellow"].solid}.get(int(p.value), theme.TONES["red"].solid)
for p in alcohol
]
},
marker_cornerradius=4,
customdata=[[hov(p), alc_label.get(int(p.value), "?")] for p in alcohol],
hovertemplate="%{customdata[0]}<br>алкоголь: <b>%{customdata[1]}</b><extra></extra>",
),
row=3,
col=1,
)
fig.update_layout(theme.plotly_layout(height=430, bargap=0.25, hovermode="x"))
fig.update_annotations(font={"size": 12, "color": theme.INK_2}, x=0, xanchor="left")
pad = timedelta(hours=14)
fig.update_xaxes(**theme.AXIS, range=[days[0] - pad, days[-1] + pad])
fig.update_xaxes(**_day_ticks(days), row=3, col=1)
fig.update_yaxes(**theme.AXIS, fixedrange=True)
fig.update_yaxes(range=[0, 10.5], tickvals=[0, 3, 5, 10], row=1, col=1)
fig.update_yaxes(range=[0, 9], tickvals=[0, 4, 8], row=2, col=1)
fig.update_yaxes(range=[0, 2.2], tickvals=[0, 1, 2], ticktext=["0", "1–2", "3+"], row=3, col=1)
return fig
+299
View File
@@ -0,0 +1,299 @@
"""Pure presentation logic of the dashboard (no Streamlit calls), unit-tested in tests/test_dashboard_helpers.py.
Clinical meaning stays on the backend: colours come from ADR-006 levels computed by the rules engine and the
discontinuation-risk index is computed server-side. Here we only order, label and reshape that data for display.
"""
from __future__ import annotations
import random
import re
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from datetime import date, datetime
from functools import cache
from pathlib import Path
from zoneinfo import ZoneInfo
import yaml
from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, PatientListItem, SeriesPoint
TZ = ZoneInfo("Europe/Moscow")
REPO_ROOT = Path(__file__).resolve().parents[2]
# ADR-006 ordering for the doctor's attention: acute first, then high/moderate, then info-only, then quiet.
STATUS_ORDER = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
MODULE_LABELS = {"veteran": "ветеран", "depression": "депрессия", "family": "семья"}
LEVEL_LABELS = {"acute": "Кризис", "high": "Высокий", "moderate": "Умеренный", "info": "Инфо"}
MONTHS_SHORT = ["янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]
DRUGS: dict[str, list[str]] = {
"сертралин": ["25 мг", "50 мг", "100 мг", "150 мг"],
"пароксетин": ["10 мг", "20 мг", "30 мг"],
"эсциталопрам": ["5 мг", "10 мг", "20 мг"],
"флуоксетин": ["20 мг", "40 мг"],
}
# ------------------------------------------------------------------ list: ordering, filtering, KPIs
def sort_patients(items: Iterable[PatientListItem]) -> list[PatientListItem]:
"""red → yellow → grey → green; inside a colour the higher discontinuation risk goes first."""
return sorted(items, key=lambda p: (STATUS_ORDER[p.status_color], -p.discontinuation_risk.score, p.display_name))
def filter_patients(
items: Iterable[PatientListItem], color: str | None = None, query: str = ""
) -> list[PatientListItem]:
q = query.strip().lower()
return [
p
for p in items
if (color is None or p.status_color == color)
and (not q or q in p.display_name.lower() or q in (p.drug or "").lower())
]
@dataclass(frozen=True)
class Kpis:
total: int
red: int
yellow: int
grey: int
green: int
high_risk: int
def kpis(items: Sequence[PatientListItem]) -> Kpis:
by = {c: sum(1 for p in items if p.status_color == c) for c in STATUS_ORDER}
high = sum(1 for p in items if p.discontinuation_risk.level == "high")
return Kpis(len(items), by["red"], by["yellow"], by["grey"], by["green"], high)
# ------------------------------------------------------------------ small visual mappings
@dataclass(frozen=True)
class View:
text: str
tone: str # key of theme.TONES
hint: str = ""
def trend_view(trend: str) -> View:
"""Direction of symptom scores; "up" means symptoms got worse (schemas.PatientListItem.trend)."""
return {
"up": View("↑", "red", "симптомы нарастают"),
"down": View("↓", "green", "симптомы снижаются"),
"flat": View("→", "grey", "без динамики"),
}.get(trend, View("·", "grey", "мало данных"))
def risk_view(risk: DiscontinuationRisk) -> View:
"""Badge for the rule-based discontinuation-risk index (score 0–100) with factors as a tooltip."""
label, tone = {"high": ("Высокий", "red"), "medium": ("Средний", "yellow"), "low": ("Низкий", "green")}[risk.level]
factors = "\n".join(f"• {f}" for f in risk.factors) or "• значимых факторов нет"
return View(f"{label} · {risk.score}", tone, f"Риск прерывания терапии: {risk.score}/100\n{factors}")
def adherence_view(adherence: float | None) -> View:
"""7-day adherence. ≥ 80 % is the usual "adherent" cut-off for antidepressants; < 60 % needs a call."""
if adherence is None:
return View("—", "grey")
pct = round(adherence * 100)
tone = "green" if adherence >= 0.8 else "yellow" if adherence >= 0.6 else "red"
return View(f"{pct}%", tone)
def status_view(color: str, top_level: str | None = None) -> View:
if color == "red":
return View("Срочно", "red", "Кризисное уведомление")
if color == "yellow":
return View("Внимание", "yellow", "врачу ≤ 24 ч или к визиту")
if color == "grey":
return View("Нет ответа", "grey", "молчание / только инфо-флаги")
return View("Без уведомлений", "green", "открытых сигналов нет")
def level_tone(level: str) -> str:
return {"acute": "red", "high": "yellow", "moderate": "yellow"}.get(level, "grey")
# ------------------------------------------------------------------ scales
@dataclass(frozen=True)
class Band:
lo: float
hi: float
code: str
label: str
SCALE_MAX = {"phq9": 27, "pcl5": 80}
@cache
def severity_bands(scale_id: str) -> tuple[Band, ...]:
"""Severity gradations from config/scales/<id>.yaml (ADR-008: thresholds live in config, not code)."""
path = REPO_ROOT / "config" / "scales" / f"{scale_id}.yaml"
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except OSError:
return ()
rows = data.get("severity", [])
return tuple(Band(float(b["min"]), float(b["max"]), str(b["code"]), str(b["label"])) for b in rows)
def severity_label(scale_id: str, value: float | None) -> str:
if value is None:
return ""
for b in severity_bands(scale_id):
if b.lo <= value <= b.hi:
return b.label
return ""
def metric_points(series: Iterable[SeriesPoint], metric: str) -> list[SeriesPoint]:
return sorted((p for p in series if p.metric == metric), key=lambda p: p.day)
def last_and_prev(series: Iterable[SeriesPoint], metric: str) -> tuple[float | None, float | None]:
pts = metric_points(series, metric)
last = pts[-1].value if pts else None
prev = pts[-2].value if len(pts) > 1 else None
return last, prev
# ------------------------------------------------------------------ dose calendar
@dataclass
class DoseGrid:
"""Heat-strip of doses: one column per calendar day, one row per scheduled time of day."""
days: list[date] = field(default_factory=list)
slots: list[str] = field(default_factory=list)
cells: dict[tuple[str, date], str] = field(default_factory=dict)
counts: dict[str, int] = field(default_factory=dict)
@property
def adherence(self) -> float | None:
"""Share of resolved doses taken (pending doses are not counted yet)."""
resolved = sum(self.counts.get(s, 0) for s in ("taken", "missed", "skipped"))
return self.counts.get("taken", 0) / resolved if resolved else None
def _local(dt: datetime) -> datetime:
return dt.astimezone(TZ) if dt.tzinfo else dt
def dose_grid(doses: Iterable[DoseOut]) -> DoseGrid:
grid = DoseGrid(counts={"taken": 0, "missed": 0, "skipped": 0, "pending": 0})
items = sorted(doses, key=lambda d: d.planned_at)
if not items:
return grid
days: set[date] = set()
slots: set[str] = set()
for d in items:
local = _local(d.planned_at)
day, slot = local.date(), local.strftime("%H:%M")
days.add(day)
slots.add(slot)
grid.cells[(slot, day)] = d.status
grid.counts[d.status] += 1
first, last = min(days), max(days)
grid.days = [date.fromordinal(o) for o in range(first.toordinal(), last.toordinal() + 1)]
grid.slots = sorted(slots)
return grid
# ------------------------------------------------------------------ alerts
def apply_local_acks(alerts: Iterable[AlertOut], acks: dict[int, tuple[str, datetime]]) -> list[AlertOut]:
"""Overlay acks done in this session (so the UI reflects the click even before the next poll)."""
out = []
for a in alerts:
if a.acked_at is None and a.id in acks:
by, at = acks[a.id]
a = a.model_copy(update={"acked_by": by, "acked_at": at})
out.append(a)
return out
def split_new(alerts: Iterable[AlertOut], seen: set[int]) -> list[AlertOut]:
return sorted((a for a in alerts if a.id not in seen), key=lambda a: a.id)
def sort_alerts(alerts: Iterable[AlertOut]) -> list[AlertOut]:
"""Open first (acute before others), then newest first."""
rank = {"acute": 0, "high": 1, "moderate": 2, "info": 3}
return sorted(alerts, key=lambda a: (a.acked_at is not None, rank.get(a.level, 9), -a.ts.timestamp()))
# ------------------------------------------------------------------ Russian formatting
def plural_ru(n: int, one: str, few: str, many: str) -> str:
n = abs(n)
if n % 10 == 1 and n % 100 != 11:
return one
if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14:
return few
return many
def age_label(age: int) -> str:
if age <= 0:
return "возраст не указан"
return f"{age} {plural_ru(age, 'год', 'года', 'лет')}"
def short_date(d: date) -> str:
return f"{d.day} {MONTHS_SHORT[d.month - 1]}"
def when_label(dt: datetime, today: date | None = None) -> str:
local = _local(dt)
day = "сегодня" if today and local.date() == today else short_date(local.date())
return f"{day}, {local:%H:%M}"
def visit_label(visit: date | None, today: date | None) -> str:
if visit is None:
return "визит не назначен"
if today is None:
return f"визит {short_date(visit)}"
delta = (visit - today).days
if delta == 0:
return "визит сегодня"
if delta < 0:
return f"визит {short_date(visit)} (просрочен)"
return f"визит {short_date(visit)} · через {delta} {plural_ru(delta, 'день', 'дня', 'дней')}"
# ------------------------------------------------------------------ connect form
def absolute_link(link: str, public_url: str) -> str:
"""The API may return a relative web-console link (`/console?code=…`); a QR needs an absolute URL."""
if link.startswith(("http://", "https://", "tg://")):
return link
return public_url.rstrip("/") + "/" + link.lstrip("/")
def snils_checksum(digits: str) -> int:
"""Control number of a SNILS (Pension Fund algorithm): Σ digit·(9−i), then mod 101, 100/101 → 0."""
s = sum(int(ch) * (9 - i) for i, ch in enumerate(digits))
if s < 100:
return s
if s in (100, 101):
return 0
s %= 101
return 0 if s == 100 else s
def format_snils(digits: str, check: int) -> str:
return f"{digits[0:3]}-{digits[3:6]}-{digits[6:9]} {check:02d}"
def is_valid_snils(value: str) -> bool:
d = re.sub(r"\D", "", value)
return len(d) == 11 and snils_checksum(d[:9]) == int(d[9:])
def synthetic_snils(rng: random.Random | None = None) -> str:
"""Random checksum-valid SNILS for synthetic test patients. Starts with 000 so it never looks like a real one."""
r = rng or random.Random()
digits = "000" + "".join(str(r.randint(0, 9)) for _ in range(6))
return format_snils(digits, snils_checksum(digits))
+85
View File
@@ -0,0 +1,85 @@
"""Live alerts across all pages: polls the API every few seconds, toasts new alerts, pins open acute ones on top.
Uses the built-in `st.fragment(run_every=...)`: only this fragment re-runs on the timer, the page is untouched
unless a new alert arrives on a doctor page (then the whole app reruns so the list and the card pick it up).
"""
from __future__ import annotations
from html import escape
import streamlit as st
from app.api.schemas import AlertOut
from dashboard import theme
from dashboard.api_client import ApiError
from dashboard.components import helpers as h
from dashboard.components import state, ui
POLL_SECONDS = 2.5
MAX_BANNER = 3
def _toast(a: AlertOut) -> None:
icon = theme.TONES[h.level_tone(a.level)].icon
st.toast(f"**{a.patient_name}** — {a.reason}", icon=icon, duration="long" if a.level == "acute" else "short")
def flush_pending_toasts() -> None:
"""Toasts queued right before a full-app rerun are shown on the next run (a rerun drops pending deltas)."""
for a in st.session_state.pop("pending_toasts", []):
_toast(a)
def _banner(acute: list[AlertOut]) -> None:
for a in acute[:MAX_BANNER]:
left, right = st.columns([8, 1.6], vertical_alignment="center")
with left:
st.html(
ui.banner(
f"<b>Кризисный сигнал · {escape(a.patient_name)}</b> — {escape(a.reason)}"
f'<div style="font-size:.8rem;margin-top:2px">{escape(h.when_label(a.ts))} · '
"по протоколу — связаться с пациентом сегодня</div>",
tone="red",
pulse=True,
)
)
with right:
if st.button(
"Карточка", key=f"acute-open-{a.id}", type="primary", icon=":material/arrow_forward:", width="stretch"
):
st.switch_page(state.PATIENTS_PAGE, query_params={"patient": a.token})
if len(acute) > MAX_BANNER:
st.caption(f"Ещё кризисных сигналов: {len(acute) - MAX_BANNER}")
def render(*, rerun_app_on_new: bool = True) -> None:
"""Place once per run (app.py does it above every page)."""
@st.fragment(run_every=POLL_SECONDS)
def _poll() -> None:
ss = st.session_state
try:
alerts = state.client().alerts(open_only=True)
except ApiError:
return # pages show their own "API недоступен" banner; the poller just retries next tick
acked = state.local_acks()
alerts = [a for a in alerts if a.acked_at is None and a.id not in acked]
seen: set[int] | None = ss.get("seen_alert_ids")
if seen is None:
# First poll of the session: what is already open is not "new" — no toast storm on page load.
ss["seen_alert_ids"] = {a.id for a in alerts}
new: list[AlertOut] = []
else:
new = h.split_new(alerts, seen)
seen.update(a.id for a in new)
if new and rerun_app_on_new:
ss["pending_toasts"] = ss.get("pending_toasts", []) + new
st.rerun(scope="app")
for a in new:
_toast(a)
_banner(h.sort_alerts(a for a in alerts if a.level == "acute"))
_poll()

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