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
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
88 changed files with 3604 additions and 142 deletions
+12
View File
@@ -11,6 +11,16 @@ 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=
@@ -23,6 +33,8 @@ CLINICAL_DB_URL=postgresql+asyncpg://clinical_app:clinical_app@localhost:5432/cl
# --- Telegram: prototype on synthetic data only; prod channel is MAX / Gosuslugi. Empty = bot disabled.
TG_BOT_TOKEN=
# http://user:pass@host:port or socks5://… — needed on the RF server (docker has no route to Telegram over IPv4)
TG_PROXY_URL=
DOCTOR_NOTIFY_CHAT_ID=
# --- Demo virtual clock (ADR-005)
+52
View File
@@ -0,0 +1,52 @@
name: CD
# Every merge into dev → lint + tests → deploy to the shared server (dev.meditron.tenvex.ru / meditron.tenvex.ru).
# Secrets: DEPLOY_SSH_KEY (private key of the `ptsr-deploy` user), DEPLOY_HOST (user@host).
on:
push:
branches: [dev]
workflow_dispatch:
concurrency:
group: deploy-dev
cancel-in-progress: false
jobs:
test:
name: ruff · pytest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: "0.8.4"
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run pytest -q
deploy:
name: deploy dev
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ssh key
run: |
install -d -m 700 ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
- name: deploy
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_SSH_OPTS: "-i /root/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
run: |
export DEPLOY_SSH_OPTS="${DEPLOY_SSH_OPTS//\/root\/.ssh/$HOME/.ssh}"
infra/deploy/deploy.sh HEAD
- name: smoke
run: |
sleep 10
for u in https://dev.meditron.tenvex.ru/ https://dev.meditron.tenvex.ru/api/v1/demo/personas https://dev.meditron.tenvex.ru/doctor/; do
code=$(curl -s -o /dev/null -w '%{http_code}' -m 20 "$u"); echo "$u -> $code"; [ "$code" = 200 ]
done
+1 -1
View File
@@ -6,7 +6,7 @@ install:
up:
@test -f .env || cp .env.example .env
docker compose up -d --build
@echo "API: http://localhost:8000/docs Dashboard: http://localhost:8501"
@echo "Patient app: http://localhost:8080 API: http://localhost:8000/docs Doctor/demo console: http://localhost:8501"
down:
docker compose down
+112 -9
View File
@@ -1,14 +1,117 @@
# PTSRaiAgent — ИИ-помощник сопровождения при ПТСР и депрессии
> Хакатон MedITron · кейс SberMed AI «ИИ-помощник пациента». Прототип на **синтетических данных**.
> Telegram используется только для прототипа на синтетических данных; прод-канал — MAX / Госуслуги.
> Хакатон MedITron · кейс SberMed AI «ИИ-помощник пациента». Прототип работает **только на синтетических данных**.
> Демо-стенд: **https://meditron.tenvex.ru** (кабинет врача: https://meditron.tenvex.ru/doctor/, API: https://meditron.tenvex.ru/docs).
🚧 README заполняется к сдаче (issue «README по требованиям оформления»). Разделы по требованиям жюри:
---
1. Цели и задачи решения
2. Что реализовано
3. Развёртывание, запуск, использование
4. Входные и выходные данные, API
5. Ограничения и особенности медицинской тематики
## 1. Цели и задачи
Пока смотрите [`CLAUDE.md`](CLAUDE.md) и [`docs/`](docs/).
**Проблема.** Ветераны СВО и люди с депрессией до помощи доходят с трудом, а начав лечение, часто его бросают: в первые недели побочные эффекты уже есть, а эффекта ещё нет. Врач между визитами ничего не знает о пациенте.
**Решение.** Цифровой помощник сопровождает пациента между визитами к врачу. Из сценариев кейса закрыты два: **мониторинг после обращения** и **управление приёмом лекарств**.
- Пациент общается с помощником в чате: веб-приложение или Telegram. В проде канал — MAX / Госуслуги.
- Помощник собирает валидированные шкалы, ежедневный чек-ин и отметки о приёме препарата.
- Он отвечает на вопросы о лечении по клиническим рекомендациям Минздрава, по инструкции ГРЛС и по карте пациента в ЕМИАС.
- Врач видит «красные зоны» и получает сигналы при ухудшении.
**Принципы.**
- ИИ не ставит диагнозы и не назначает лечение.
- LLM только формулирует ответы и извлекает данные.
- **Риск, алерты и скоринг считают детерминированные правила** по валидированным шкалам (PHQ-9, PCL-5, GAD-7, AUDIT-C, C-SSRS, DAR-5).
- Клинические решения принимает врач.
## 2. Что реализовано
| Блок | Что есть |
|---|---|
| **Чат-агент** | LLM с инструментами только для чтения: поиск по базе клинических рекомендаций (RAG, депрессия 2024, ПТСР 2023), карта пациента из ЕМИАС (мок), инструкция ГРЛС, детерминированная проверка препарата под пациента (сопутствующие болезни, непереносимость вспомогательных веществ, взаимодействия). Ответ печатается в реальном времени (SSE), видны шаги агента. Провайдеры: GigaChat (функции), OpenRouter (временно, только синтетика), офлайн-mock. |
| **Диалоговый движок** | Обезличивание → проверка безопасности → FSM. Согласие (152-ФЗ, отзыв в один шаг), проверка возраста, онбординг ветерана (PC-PTSD-5 → PCL-5 → PHQ-9 → AUDIT-C), опросник intake (C-SSRS, PHQ-2→9, TSQ→PCL-5, RUS-AUDIT-S), ежедневный чек-ин, напоминания о дозах, побочные эффекты. На вопросы отвечает в любом состоянии. |
| **Безопасность** | Лексикон риска (100% recall на наборе кризисных фраз), фиксированный кризисный скрипт без LLM, алерт врачу и дежурному, план безопасности. В демо кризисное окно показывается в конце теста и не блокирует диалог (`CRISIS_BLOCKING`). |
| **Правила мониторинга** | 17 правил в `config/rules.yaml`: п.9 PHQ-9, рост баллов, побочки 3 дня подряд, намерение бросить, пропуски 3 из 7, сон < 4 ч, алкоголь, молчание > 3 дней и другие. |
| **Кабинет врача** | Список «красных зон» с индексом риска прерывания терапии, карточка (графики шкал, сон, настроение, дозы, алерты, план безопасности, ИИ-саммари), живые алерты, подключение пациента с кодом и QR, аудит доступа. |
| **Веб-приложение пациента** | Вход через ЕМИАС (демо: код от врача), анонимный вход или демо-вход в один клик (у каждого посетителя своя копия пациента). Экраны: сегодня, лекарства, чат, врач, профиль. |
| **Демо-консоль** | Виртуальные часы (13 дней терапии за 3 минуты), проигрывание сценария, сплит-экран, тумблер начального теста. |
| **Инфраструктура** | Docker compose, CI (ruff + pytest, около 1060 тестов), CD: мерж в `dev` выкатывает стенд. |
## 3. Развёртывание и запуск
**Требования:** только Docker с Compose v2 — всё остальное собирается в контейнерах. Для разработки — Python 3.12 и [uv](https://docs.astral.sh/uv/), Node 20.
```bash
cp .env.example .env # по умолчанию офлайн: LLM_PROVIDER=mock, агент выключен
make up # postgres + API (:8000) + кабинет врача/демо-консоль (:8501) + приложение пациента (:8080)
make seed # синтетический кабинет: врач, ветеран Андрей и 7 пациентов
```
- API и Swagger: http://localhost:8000/docs
- Кабинет врача и демо-консоль: http://localhost:8501
- Веб-приложение пациента: http://localhost:8080 (собирается внутри Docker, Node на машине не нужен; для разработки — `cd web && npm run dev`, :5173)
**Включить чат-агента** в `.env`:
- `CHAT_AGENT=gigachat` + `GIGACHAT_CREDENTIALS=…` — целевой вариант для РФ;
- или `CHAT_AGENT=openrouter` + `OPENROUTER_API_KEY=…` (+ `OPENROUTER_PROXY_URL` с сервера в РФ) — только для прототипа;
- проверить из терминала: `uv run python -m app.llm.agent "Можно мне ибупрофен?" --persona andrey`.
**Демо-переключатели:**
- `SCREENING_ENABLED` — начальный тест (дублируется тумблером в демо-консоли);
- `CRISIS_BLOCKING` — клинический протокол: кризис блокирует диалог до подтверждения врача;
- `DEMO_CLOCK` — виртуальные часы;
- `INTAKE_DEMO` — опросники intake на синтетике.
**Сервер:** `infra/deploy/` — override compose, nginx-vhost, `deploy.sh`. Выкатка на стенд — `.gitea/workflows/deploy.yml`.
**Разработка:** `uv sync`, `make lint test`.
## 4. Входные и выходные данные, API
**Входные данные:**
- сообщения и ответы кнопками пациента;
- назначения (мок МИС, `data/meds/mis_prescriptions.json`);
- карта ЕМИАС (мок, `data/emias/records.yaml`);
- выдержки ГРЛС (`data/meds/grls/`);
- клинические рекомендации (`config/rag/corpus/`).
**Выходные данные:** ответы пациенту, баллы шкал, алерты врачу, карточка пациента, журнал аудита.
Все клинические конфиги (шкалы, пороги, правила, тексты, лексикон) лежат в `config/` и правятся без кода.
Основные эндпоинты (`/api/v1`, полная спецификация — `/docs`):
| Метод и путь | Назначение |
|---|---|
| `POST /dialog/{token}/events` | событие диалога: текст, кнопка, start → ответы помощника |
| `POST /dialog/{token}/events/stream` | то же в виде SSE: шаги агента, текст по мере генерации, финальные ответы |
| `GET /dialog/{token}/outbox` | системные сообщения: напоминания, чек-ины |
| `POST /auth/emias`, `/auth/emias/demo`, `/auth/demo/fresh`, `/auth/anonymous` | входы веб-приложения |
| `GET /me/{token}/today` | план на сегодня: дозы, отметки за неделю, визит |
| `GET /doctors/{id}/patients` | список пациентов врача с цветом и индексом риска |
| `GET /patients/{token}?doctor_id=` | карточка пациента (пишется в аудит) |
| `GET /doctors/{id}/alerts`, `POST /alerts/{id}/ack` | алерты и их подтверждение |
| `POST /patients/connect` | подключение пациента: назначение и одноразовый код |
| `GET/POST /demo/clock…`, `/demo/reset`, `/demo/settings` | демо-управление |
## 5. Ограничения и особенности медицинской тематики
- **Только синтетические данные.** Персоны, карты ЕМИАС, назначения и выдержки ГРЛС вымышлены или пересказаны для прототипа и подлежат сверке.
- **Не медицинское изделие в текущем виде.** Сервис не ставит диагнозы и не меняет терапию. Пороги шкал, тексты и лексикон — стартовые значения из литературы, их утверждает психиатр (`docs/clinical/`).
- **152-ФЗ:** отдельное согласие на обработку данных о здоровье, две раздельные БД (identity и clinical) со связью через HMAC-токен, в LLM уходит только обезличенный текст, каждое обращение врача пишется в аудит.
- **Зарубежные сервисы.** OpenRouter используется временно и только на синтетике. Для реальных пациентов нужен GigaChat в РФ-контуре.
- **Безопасность.** Кризисный ответ фиксированный и выдаётся без LLM. Пропуск кризиса недопустим, ложные срабатывания допустимы. Демо-режим (кризис без блокировки) не равен клиническому протоколу: в проде `CRISIS_BLOCKING=true`.
- **Ответы LLM** могут слегка различаться от раза к разу. Фактура берётся только из инструментов, а запрещённые формулировки (диагнозы, дозы, «отмените») фильтруются.
## Структура репозитория
```
app/ бэкенд: core (privacy, safety, шкалы, правила, часы), dialog (движок и сценарии), llm (шлюз, RAG,
чат-агент), db, api, scheduler, integrations (моки МИС/ЕМИАС/ГРЛС, уведомления), services
dashboard/ кабинет врача и демо-консоль (Streamlit)
web/ веб-приложение пациента (React + Vite)
config/ клинические конфиги: шкалы, правила, модули, тексты, лексикон, RAG-корпус
data/ синтетика: сценарий демо, карты ЕМИАС, назначения, выдержки ГРЛС
infra/ postgres init, деплой на сервер
tests/ pytest (около 1060 тестов)
docs/ спецификация, ADR (DECISIONS.md), клинические материалы, концепция и CJM, регламент
```
Подробнее: [`docs/info/SPEC.md`](docs/info/SPEC.md) · [`docs/DECISIONS.md`](docs/DECISIONS.md) · [`docs/info/CLINICAL_MODULES.md`](docs/info/CLINICAL_MODULES.md) · [`docs/source/`](docs/source/) (кейс, концепция, CJM).
+11 -1
View File
@@ -34,6 +34,7 @@ from app.db import repo
from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult
from app.db.identity import Patient
from app.integrations.base import Prescription as PrescriptionOut
from app.services.anonymous_care import display_name as anonymous_name
COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
_MODULES = frozenset(get_args(Module))
@@ -96,6 +97,7 @@ class PatientData:
doses: Sequence[Dose]
prescriptions: Sequence[Prescription]
alerts: Sequence[Alert] # newest first
contact_requested_at: datetime | None = None
async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData:
@@ -104,6 +106,9 @@ async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> P
rx = (
await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id))
).all()
dialog = await repo.get_dialog_state(session, token)
requests = (dialog.data.get("contact_requests") or []) if dialog else []
contact_at = datetime.fromisoformat(requests[-1]["ts"]) if requests else None
return PatientData(
profile=profile,
checkins=await repo.list_checkins(session, token),
@@ -111,6 +116,7 @@ async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> P
doses=doses,
prescriptions=rx,
alerts=await repo.list_alerts(session, token=token),
contact_requested_at=contact_at,
)
@@ -228,6 +234,10 @@ def _last_event(d: PatientData, now: datetime) -> str:
alert = d.alerts[0] if d.alerts else None
scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS]
scale = scales[-1] if scales else None
if d.contact_requested_at is not None and all(
d.contact_requested_at >= ts for ts in [x.ts for x in (alert, scale) if x is not None]
):
return "Пациент просит врача связаться"
if scale is not None and (alert is None or scale.ts > alert.ts):
day = _therapy_day(d.profile, _local(scale.ts, now))
return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})"
@@ -249,7 +259,7 @@ def list_item(d: PatientData, patient: Patient | None, now: datetime) -> Patient
rx = _active_rx(d, today)
return PatientListItem(
token=p.token,
display_name=patient.display_name if patient else "Пациент",
display_name=patient.display_name if patient else anonymous_name(p.token),
age=_age(patient, p, today),
modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES],
drug=_drug_label(rx) if rx else None,
+2 -1
View File
@@ -25,7 +25,8 @@ DOCTOR_ID = "doc-smirnova"
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
DEMO_PERSONAS: list[DemoPersona] = [
DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
# ЕМИАС demo sign-in shows this persona as «Никита В.» (the one-click demo on the welcome screen is «Андрей С.»).
DemoPersona(token="tok-andrey", display_name="Никита В.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
]
+149 -13
View File
@@ -7,9 +7,9 @@ exactly like the web console, and consent, onboarding, scales and check-ins come
Two ways in:
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
patient is linked to the attending doctor, who sees the data on the dashboard.
- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor.
- **Anonymous**: a random pseudonymous token, no identity record, and access to the prototype care team.
Clinical answers are stored on the server and visible to the assigned doctor under a pseudonym.
Consent is kept in the dialog state. Existing doctor-less profiles require explicit care opt-in.
The token returned here is the session credential of the prototype. Production needs short-lived sessions
issued by the server instead of handing out the pseudonymous token itself.
@@ -17,17 +17,19 @@ issued by the server instead of handing out the pseudonymous token itself.
import logging
import secrets
import time as monotonic_time
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Any, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from app.api import fixtures
from app.api.schemas import DemoPersona
from app.config import get_settings
from app.core import clock
from app.core.instrument_catalog import Catalog, catalog
from app.core.intake_questions import DIARY_KINDS
@@ -35,12 +37,16 @@ from app.core.intake_runtime import intake_enabled, load_runtime
from app.core.interpretation import texts as results_texts
from app.core.meds_reference import find_drug, indication_for, load_reference
from app.core.safety import CrisisResource, crisis_reply
from app.db import repo
from app.db import identity_repo, repo
from app.db.audit import audit
from app.db.clinical import DailyCheckin, Dose, Prescription
from app.db.identity import TOKEN_LEN
from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session
from app.db.session import get_clinical_session, get_identity_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
from app.llm.stt import InvalidAudio, SttError, SttNoSpeech, get_stt, wav_seconds
from app.services import anonymous_care
from app.services.garden import Garden, garden_for
log = logging.getLogger(__name__)
@@ -79,14 +85,14 @@ async def _attach_doctor(token: str, doctor_id: str) -> None:
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
async def _seed_demo_persona(token: str) -> None:
async def _seed_demo_persona(token: str, source: str | None = None) -> None:
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
doctor's card tell the same story.
"""
card = fixtures.card(token)
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
card = fixtures.card(source or token)
item = next((p for p in fixtures.PATIENTS if p.token == (source or token)), None)
if card is None or item is None:
return
now = clock.now()
@@ -146,26 +152,144 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
if persona is None:
raise HTTPException(404, "persona not found")
if persona.token == DEMO_SOURCE:
# Every visitor gets a private copy (never the shared account); this entry starts from consent and the
# initial questionnaires (subject to the screening switch), unlike the one-click chat demo.
return await _fresh_demo_copy(start_in_chat=False)
await _seed_demo_persona(persona.token)
log.info("web auth: emias demo tok=%s", persona.token[:6])
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
DEMO_SOURCE = "tok-andrey"
ONE_CLICK_NAME = "Андрей С." # «Демо: войти как Андрей»; the ЕМИАС demo persona is named in fixtures (Никита)
@router.post("/auth/demo/fresh", response_model=AuthOut)
async def auth_demo_fresh() -> AuthOut:
return await _fresh_demo_copy(start_in_chat=True)
async def _fresh_demo_copy(*, start_in_chat: bool) -> AuthOut:
"""Presentation entry: a fresh private copy of veteran Andrey for every sign-in — consent given, prescription
and ЕМИАС record in place, dialog resting in the chat. Nobody's crisis or half-done questionnaire leaks into
another viewer's demo. Synthetic data only."""
token = f"{DEMO_SOURCE}-{secrets.token_hex(4)}"
now = clock.now()
persona = next(p for p in fixtures.DEMO_PERSONAS if p.token == DEMO_SOURCE)
name = persona.display_name if not start_in_chat else ONE_CLICK_NAME
snils = f"000-{secrets.randbelow(10**3):03d}-{secrets.randbelow(10**3):03d} {secrets.randbelow(100):02d}"
async with get_identity_session() as s:
await identity_repo.create_patient(
s,
token=token,
display_name=f"{name} (демо)",
snils=snils,
clinic_id="demo",
doctor_id=fixtures.DOCTOR_ID,
now=now,
birth_year=now.year - 34,
sex="m",
)
if start_in_chat:
await identity_repo.record_consent(s, token=token, version="demo", now=now)
await _seed_demo_persona(token, source=DEMO_SOURCE)
async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, sex="m", birth_year=now.year - 34)
if start_in_chat:
consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None}
await repo.set_dialog_state(s, token=token, state="idle", data={**consent, "adult": True}, now=now)
log.info("web auth: demo fresh tok=%s", token[:14])
return AuthOut(token=token, mode="emias", display_name=name)
@router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
"""Anonymous sign-in: a fresh random token; no identity record, no doctor.
"""Anonymous sign-in: a fresh random token; care without an identity record.
The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself.
The clinical side gets a pseudonymous profile assigned to the prototype care team. It holds
no identity fields. Legacy clients may supply `focus`; the common stage 1 screening sets modules itself.
"""
token = secrets.token_hex(TOKEN_LEN // 2)
focus = body.focus if body is not None else None
async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[focus]] if focus else [])
await repo.upsert_profile(
s, token=token, doctor_id=anonymous_care.doctor_id(), modules=[_FOCUS_MODULE[focus]] if focus else []
)
log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous")
@router.get("/patient/capabilities")
async def patient_capabilities() -> dict[str, bool]:
"""Expose availability without leaking clinical policy or server configuration."""
return {"primary_screening": intake_enabled(), "voice_input": get_stt(get_settings()) is not None}
class SttOut(BaseModel):
text: str
provider: str
# 16 kHz mono 16-bit PCM = 32 000 bytes/s, + header slack.
_WAV_BYTES_PER_SECOND = 32_000
@router.post("/me/{token}/stt", response_model=SttOut)
async def transcribe_voice(token: str, request: Request) -> SttOut:
"""Voice → text for the patient to check and edit (#116). Body: raw `audio/wav`, 16 kHz mono.
Only transcribes: nothing is written to the dialog and the agent is not called — the patient sends the
checked text as an ordinary message, where safety and `scrub()` apply. The audio is neither stored nor logged.
"""
settings = get_settings()
stt = get_stt(settings)
if stt is None:
raise HTTPException(404, "voice input is off")
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
max_bytes = (settings.stt_max_seconds + 2) * _WAV_BYTES_PER_SECOND + 4096
declared = request.headers.get("content-length")
if declared and declared.isdigit() and int(declared) > max_bytes:
raise HTTPException(413, "recording too long")
audio = await request.body()
if len(audio) > max_bytes:
raise HTTPException(413, "recording too long")
try:
seconds = wav_seconds(audio)
except InvalidAudio as exc:
raise HTTPException(415, "expected audio/wav") from exc
if seconds > settings.stt_max_seconds + 1:
raise HTTPException(413, "recording too long")
started = monotonic_time.monotonic() # latency only, not domain time (that is app.core.clock)
try:
text = await stt.transcribe(audio, "wav")
except 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
@@ -305,6 +429,18 @@ async def my_today(token: str) -> TodayOut:
)
@router.get("/me/{token}/garden", response_model=Garden)
async def my_garden(token: str) -> Garden:
"""The patient's garden (#118): grows from medication marks, check-ins/diary and finished questionnaires.
Never shrinks and never depends on answers. Seen only by the patient — not on the dashboard, not in summaries.
"""
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
return await garden_for(s, token)
class ResultFinding(BaseModel):
code: str
title: str
+71 -1
View File
@@ -8,9 +8,14 @@ disclosed — the dashboard polls every 2–3 s), `ack_alert` (in `repo.ack_aler
`app/api/fixtures.py` is kept for tests and as the demo cabinet reference (doctor id).
"""
import asyncio
import json
import logging
import secrets
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from app.api import demo as console
@@ -42,9 +47,12 @@ from app.db.identity import Patient
from app.db.session import get_clinical_session, get_identity_session
from app.dialog.contracts import Event, get_engine
from app.integrations.base import integrations
from app.llm.agent import stream as agent_stream
from app.scheduler import jobs as scheduler
from app.services.anonymous_care import display_name as anonymous_name
from app.services.monitoring import refresh_status
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
router.include_router(max_notifications.router)
router.include_router(questionnaire_router)
@@ -66,7 +74,7 @@ async def _patients(tokens: list[str]) -> dict[str, Patient]:
def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token)
return p.display_name if p else "Пациент"
return p.display_name if p else anonymous_name(token)
# ------------------------------------------------------------------ doctor dashboard
@@ -218,6 +226,49 @@ async def dialog_event(token: str, body: DialogIn) -> DialogOut:
return DialogOut(replies=await get_engine().handle(token, body))
@router.post("/dialog/{token}/events/stream", tags=["dialog"])
async def dialog_event_stream(token: str, body: DialogIn) -> StreamingResponse:
"""Same as /events, as Server-Sent Events: `status` (agent steps), `delta` (answer as it is written),
`reset`, then `done` with the final replies (identical to /events). `error` if the engine failed."""
await _require_patient(token)
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
marker = agent_stream.STREAM.set(queue) # copied into the task's context below
try:
async def run() -> None:
try:
replies = await get_engine().handle(token, body)
queue.put_nowait(("done", [r.model_dump() for r in replies]))
except Exception: # noqa: BLE001 — reported to the client, which falls back to /events
log.exception("dialog stream failed")
queue.put_nowait(("error", None))
task = asyncio.create_task(run())
finally:
agent_stream.STREAM.reset(marker)
async def events():
try:
while True:
try:
kind, payload = await asyncio.wait_for(queue.get(), timeout=15)
except TimeoutError:
yield ": keepalive\n\n"
continue
yield f"event: {kind}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
if kind in ("done", "error"):
return
finally:
if not task.done():
await task
return StreamingResponse(
events(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, # nginx: do not buffer SSE
)
@router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"])
async def dialog_outbox(token: str) -> DialogOut:
"""System-initiated replies (reminders, check-ins) waiting for the web console."""
@@ -287,3 +338,22 @@ async def demo_personas() -> list[DemoPersona]:
)
)
return out
@router.get("/demo/settings", tags=["demo"])
async def get_demo_settings() -> dict[str, bool]:
"""Presentation switches: `screening_enabled` — initial questionnaires after consent (off → straight to chat)."""
from app.core import demo_settings
async with get_clinical_session() as s:
return await demo_settings.get_all(s)
@router.post("/demo/settings", tags=["demo"])
async def set_demo_settings(body: dict[str, bool]) -> dict[str, bool]:
from app.core import demo_settings
async with get_clinical_session() as s:
if demo_settings.SCREENING in body:
await demo_settings.set_screening(s, bool(body[demo_settings.SCREENING]))
return await demo_settings.get_all(s)
+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))
+32
View File
@@ -37,12 +37,28 @@ class Settings(BaseSettings):
openrouter_api_key: str = ""
openrouter_model: str = "google/gemini-2.5-flash-lite"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
openrouter_proxy_url: str = "" # OpenRouter blocks RF IPs: the server goes through an HTTP proxy (as vobot does)
gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here
chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5
# Voice input (#116, #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.
@@ -55,6 +71,17 @@ class Settings(BaseSettings):
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:
@@ -77,6 +104,11 @@ class Settings(BaseSettings):
# Virtual clock for the demo (ADR-005).
demo_clock: bool = True
# Initial screening (onboarding questionnaires) on/off — the default; the demo console can flip it at runtime.
screening_enabled: bool = True
# Crisis blocks the dialog until the doctor's ack (clinical protocol) — off for the demo: the crisis message
# and the doctor's alert still happen, the patient keeps using everything.
crisis_blocking: bool = False
tz: str = "Europe/Moscow"
api_base_url: str = "http://localhost:8000"
+34
View File
@@ -0,0 +1,34 @@
"""Presentation switches (demo): stored in `demo_settings`, default from settings/env.
SCREENING_ENABLED=false # env default
POST /api/v1/demo/settings {"screening_enabled": false} # one button in the demo console
Screening off: after consent the patient goes straight to the chat — no onboarding questionnaires.
"""
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db.clinical import DemoSetting
SCREENING = "screening_enabled"
async def get_all(session: AsyncSession) -> dict[str, Any]:
row = await session.get(DemoSetting, SCREENING)
return {SCREENING: bool(row.value) if row is not None else get_settings().screening_enabled}
async def screening_enabled(session: AsyncSession) -> bool:
return (await get_all(session))[SCREENING]
async def set_screening(session: AsyncSession, enabled: bool) -> None:
row = await session.get(DemoSetting, SCREENING)
if row is None:
session.add(DemoSetting(key=SCREENING, value=enabled))
else:
row.value = enabled
await session.flush()
+5
View File
@@ -61,6 +61,11 @@ def red_flag_category(item_id: str) -> str | None:
return next((i["category"] for i in red_flags()["items"] if i["id"] == item_id), None)
def red_flag_triggers(item_id: str) -> bool:
"""False for questions that are asked and stored but never stop/alert (rf_weapons, team decision 2026-10-04)."""
return next((i.get("trigger", True) for i in red_flags()["items"] if i["id"] == item_id), True)
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
"""Stage 1: the red-flag block first (config/instruments/red_flags.yaml), then the fixed order of «Опросники
для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, the context questions; stage 2 (rest of
+9
View File
@@ -192,3 +192,12 @@ class ClockState(ClinicalBase):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
offset_seconds: Mapped[float] = mapped_column(Float, default=0.0)
class DemoSetting(ClinicalBase):
"""Presentation switches changed at runtime from the demo console (e.g. the initial screening on/off)."""
__tablename__ = "demo_settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[Any] = mapped_column(JSONType)
+8
View File
@@ -14,6 +14,7 @@ Decisions:
from typing import Any
from app.config import get_settings
from app.core.safety import CrisisResource, CrisisScript, crisis_reply
from app.dialog.contracts import Reply
from app.dialog.fsm import Ctx, Step
@@ -37,6 +38,9 @@ def open_crisis(ctx: Ctx, categories: list[str], source: str) -> Step:
text = "\n".join([ctx.texts.crisis.repeat, *_resource_lines(script.resources)])
return Step(replies=[Reply(text=text, kind="crisis")])
crisis = {"open": True, "opened_at": ctx.now.isoformat(), "source": source, "variant": script.variant}
if not get_settings().crisis_blocking:
# Non-blocking (demo default): the script is shown, the doctor is alerted, the patient keeps going.
return Step(replies=[Reply(text=script.text, kind="crisis")], data={"crisis": crisis})
return Step(
replies=[Reply(text=script.text, kind="crisis")],
next_state="crisis",
@@ -44,6 +48,10 @@ def open_crisis(ctx: Ctx, categories: list[str], source: str) -> Step:
)
def crisis_resource_lines(resources: list[CrisisResource]) -> list[str]:
return _resource_lines(resources)
def no_consent_crisis_text(script: CrisisScript) -> str:
"""Crisis script without the "doctor notified" line — for people we may not (or did not) notify about."""
return "\n".join([script.body, "", *_resource_lines(script.resources), "", script.grounding])
+73 -1
View File
@@ -28,6 +28,7 @@ Logs carry only the token, states and event codes (guardrail #8).
import asyncio
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
@@ -36,6 +37,7 @@ from weakref import WeakValueDictionary
from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core import clock, safety
from app.core.intake_runtime import intake_enabled
from app.core.privacy import scrub
@@ -46,7 +48,7 @@ from app.db.session import get_clinical_session
from app.dialog import scale_session
from app.dialog.actions import execute
from app.dialog.contracts import Event, Reply, register_engine
from app.dialog.crisis import crisis_open, no_consent_crisis_text, open_crisis
from app.dialog.crisis import crisis_open, crisis_resource_lines, no_consent_crisis_text, open_crisis
from app.dialog.flows import load_flows
from app.dialog.flows.core import close_crisis, revoke
from app.dialog.fsm import (
@@ -66,6 +68,16 @@ from app.services.contracts import MonitoringService, get_monitoring
log = logging.getLogger(__name__)
LAST_PROMPT = "last_prompt"
DEFERRED_CRISIS = "deferred_crisis"
QUESTIONNAIRE_PREFIXES = ("scale.", "veteran.onboarding.", "intake.")
DEFERRABLE_KINDS = frozenset({"crisis", "safety_plan"})
MEDICINE_WORDS = re.compile(
r"(лекарств|таблет|препарат|сертралин|пароксетин|флуоксетин|эсциталопрам|антидепрессант|побочк)", re.I
)
# States with their own free-text handling (idle → flows' `text:idle`; explicit question states).
SIDE_ANSWER_SKIP = frozenset({"idle", "kb.question", "meds.question"})
FREE_TEXT_PREFIXES = ("daily.", "meds.", "kb.") # steps that expect free text (check-in, side effects, questions)
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
MAX_ENTER_CHAIN = 8
_patient_locks = WeakValueDictionary()
@@ -137,12 +149,18 @@ class Engine:
await self._apply(ctx, await self._start_pending(ctx), run)
self._defer_crisis_messages(ctx, event, run)
if _consented(ctx):
for reply in run.replies:
# Outgoing texts may quote the safety plan (names of relatives) — scrub before storing too.
await repo.add_message(
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
)
prompt = next((r for r in reversed(run.replies) if r.buttons), None)
if prompt is not None and ctx.state not in RESTING_STATES:
# Re-shown after an off-script question so the patient (and the web screen) gets the buttons back.
ctx.data[LAST_PROMPT] = prompt.model_dump()
if event.type != "system" or ctx.state != state_before:
# System events the step ignores (reminders inside a scale) do not count as the patient being there.
ctx.data[STEP_AT] = ctx.now.isoformat()
@@ -165,6 +183,21 @@ class Engine:
)
return run.replies
def _defer_crisis_messages(self, ctx: Ctx, event: Event, run: _Run) -> None:
"""Crisis messages triggered by test answers wait until the questionnaire is over (team decision
2026-10-04: let the patient finish the test, then show the crisis message; it blocks nothing). The doctor's
alert is not delayed — only the patient-facing message. A typed acute message is never deferred."""
if get_settings().crisis_blocking:
return # clinical protocol: the crisis takes over immediately
if event.type == "button" and ctx.state.startswith(QUESTIONNAIRE_PREFIXES):
held = [r for r in run.replies if r.kind in DEFERRABLE_KINDS]
if held:
run.replies[:] = [r for r in run.replies if r.kind not in DEFERRABLE_KINDS]
ctx.data[DEFERRED_CRISIS] = [*(ctx.data.get(DEFERRED_CRISIS) or []), *(r.model_dump() for r in held)]
return
if ctx.data.get(DEFERRED_CRISIS) and not ctx.state.startswith(QUESTIONNAIRE_PREFIXES):
run.replies.extend(Reply.model_validate(r) for r in ctx.data.pop(DEFERRED_CRISIS))
def _abandoned(self, ctx: Ctx) -> bool:
"""The current question step was left unanswered longer than `abandon_after_hours` (app.core.clock)."""
if intake_enabled() or not _consented(ctx) or ctx.state in RESTING_STATES or crisis_open(ctx.data):
@@ -289,8 +322,47 @@ class Engine:
step.notify_monitoring = True
return step
side = await self._side_answer(ctx)
if side is not None:
return side
return await self._dispatch(ctx, event)
async def _side_answer(self, ctx: Ctx) -> Step | None:
"""A question asked in the middle of a questionnaire, a check-in or a crisis is answered right away
(agent → guidelines/instruction), then the pending question is shown again with its buttons — the
chat never ignores a question. Idle and the explicit question states have their own handlers.
In a crisis the answer always carries the emergency contacts; the crisis itself stays open until the
doctor acknowledges it, and acute messages never get here (handled above)."""
from app.dialog.flows.knowledge import knowledge_answer, looks_like_question
in_crisis = (crisis_open(ctx.data) or ctx.state == "crisis") and await ctx.monitoring.is_in_crisis(ctx.token)
free_text_step = ctx.state in SIDE_ANSWER_SKIP or ctx.state.startswith(FREE_TEXT_PREFIXES)
if ctx.text is None or (free_text_step and not in_crisis):
return None # idle / check-in / question states parse free text themselves
asks = looks_like_question(ctx.text) or bool(MEDICINE_WORDS.search(ctx.text))
if asks:
answer = await knowledge_answer(ctx, explicit=True)
elif in_crisis:
# Not a question, but in a crisis every message gets a human reply (with contacts below), never silence.
answer = await ctx.gateway.phrase("support")
else:
return None # e.g. a free-text answer to the current scale item — the step handles it
if not answer:
return None
log.info("dialog side answer token=%s state=%s crisis=%s", ctx.token, ctx.state, in_crisis)
replies = [Reply(text=answer)]
if in_crisis:
script = crisis_reply([])
replies.append(
Reply(
text="\n".join([ctx.texts.crisis.repeat, *crisis_resource_lines(script.resources)]), kind="crisis"
)
)
elif ctx.data.get(LAST_PROMPT):
last = Reply.model_validate(ctx.data[LAST_PROMPT])
replies.append(last.model_copy(update={"text": f"{ctx.texts.side_answer_return}\n\n{last.text}"}))
return Step(replies=replies)
def _can_screen(self, ctx: Ctx, screen: str) -> bool:
if crisis_open(ctx.data) or scale_session.active_scale(ctx.state) == screen:
return False
+5 -1
View File
@@ -66,9 +66,13 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
"""Entry state of the next module onboarding: `"{module}.onboarding.start"` for the first module of the
profile (after `after`, if given) that has such a state registered; `"idle"` when none is left."""
from app.core.demo_settings import screening_enabled
from app.core.intake_runtime import intake_enabled
if after is None and not await screening_enabled(ctx.session):
return "idle" # demo switch: no initial questionnaires, straight to the chat
profile = await ctx.profile()
modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.intake_runtime import intake_enabled
# Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
# person has no profile at all before it.
+45 -1
View File
@@ -4,6 +4,7 @@ import math
from dataclasses import asdict, replace
from zoneinfo import ZoneInfo
from app.config import get_settings
from app.core import interpretation
from app.core.intake_questions import (
DIARY_KINDS,
@@ -15,6 +16,7 @@ from app.core.intake_questions import (
group_answers,
next_intake,
red_flag_category,
red_flag_triggers,
scale_question,
texts,
)
@@ -116,6 +118,39 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
return step
def _defer_urgent(row, code, categories=None):
"""Remember the first crisis signal of this questionnaire; the questionnaire goes on (team decision
2026-10-04: the crisis message comes at the end and blocks nothing)."""
if not row.details.get("pending_urgent"):
row.details = {**row.details, "pending_urgent": {"code": code, "categories": categories or []}}
async def _flush_urgent(ctx, row, step):
"""Questionnaire over: acute alert + callback for the doctor, then the (non-blocking) crisis message."""
pending = (row.details or {}).get("pending_urgent")
if not pending or step.next_state == "intake.collect":
return step
row.details = {**row.details, "pending_urgent": None}
code, categories = pending["code"], pending.get("categories") or []
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
await service.alert(
ctx.session,
ctx.token,
f"{row.id}:{code}",
code,
ctx.now,
level=AlertLevel.ACUTE,
params={"record_id": row.id, "deferred": True},
)
await schedule_post_crisis_call(ctx.session, ctx.token, ctx.now, source="questionnaire:" + code)
crisis = open_crisis(ctx, categories, source="questionnaire:" + code)
for reply in crisis.replies:
reply.buttons.append([Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")])
return step.then(crisis)
async def item9_discrepancy(ctx, row):
profile = await ctx.profile()
if profile:
@@ -170,18 +205,27 @@ async def manual_review(ctx, row, code):
async def advance(ctx, row):
return await _flush_urgent(ctx, row, await _advance(ctx, row))
async def _advance(ctx, row):
a = row.answers
c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy()
# Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category.
for item_id, value in a.items():
if item_id.startswith("rf_") and value == 1:
if item_id.startswith("rf_") and value == 1 and red_flag_triggers(item_id):
category = red_flag_category(item_id)
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)
+12
View File
@@ -62,6 +62,7 @@ from app.dialog.flows.meds import (
)
from app.dialog.fsm import Ctx, Step, abandonable, on_enter, state, trigger
from app.dialog.scale_session import register_scale_intro
from app.llm import agent
PREFIX = "daily"
STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text")
@@ -429,6 +430,9 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text)
if facts is None:
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return Step(replies=[menu_reply(ctx, agent.render(smart))])
text = await knowledge_answer(ctx, explicit=False) if looks_like_question(ctx.text) else None
return Step(replies=[menu_reply(ctx, text or await ctx.gateway.phrase("support"))])
fields: dict[str, Any] = {}
@@ -446,6 +450,14 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
text = (await side_effect_reply(ctx, codes)).text
if fields:
await save_checkin(ctx, **fields)
if not facts.quit_intent:
# Chat first: whatever the patient writes in idle gets the agent's answer; check-in signals above are
# recorded silently for the doctor. Quit intent keeps its rule reaction (explanation + doctor notified).
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return Step(
replies=[menu_reply(ctx, agent.render(smart))], notify_monitoring=bool(fields or facts.side_effects)
)
if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx))
# A question is answered even if it also mentioned alcohol/side effects (those are recorded above).
+6 -2
View File
@@ -33,6 +33,7 @@ from dataclasses import replace
from datetime import timedelta
from typing import Any
from app.config import get_settings
from app.core.privacy import scrub
from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
from app.db import repo
@@ -237,7 +238,10 @@ async def _record(
sex = profile.sex if profile is not None and profile.sex in ("m", "f") else None
result = score(scale.id, answers, sex=sex) # type: ignore[arg-type]
crisis_flags = [f for f in result.flags if f in ctx.texts.settings.crisis_flags]
if crisis_flags or session["idx"] + 1 >= len(scale.items):
# Clinical protocol (crisis_blocking): a crisis flag ends the scale at once. Demo default: the scale is finished
# first and the crisis message comes at the end of the questionnaire (engine defers it), blocking nothing.
stop_now = bool(crisis_flags) and get_settings().crisis_blocking
if stop_now or session["idx"] + 1 >= len(scale.items):
return await _finish(ctx, scale, session, answers, result, crisis_flags)
updated = {**session, "answers": answers, "idx": session["idx"] + 1}
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
@@ -278,7 +282,7 @@ async def _finish(
# Crisis path decided by the scale's own flags (deterministic), not by the LLM.
risk_screen = ctx.texts.settings.risk_screen_scale
if crisis_open(ctx.data):
crisis = Step(next_state="crisis")
crisis = Step(next_state="crisis") if get_settings().crisis_blocking else Step()
else:
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
step = done.then(crisis)
+1
View File
@@ -113,6 +113,7 @@ class DialogTexts(_Strict):
reach_out: ReachOutTexts
visit: VisitTexts
unavailable: str
side_answer_return: str = "Вернёмся к вопросу:"
def dialog_texts_path() -> Path:
+10 -2
View File
@@ -63,7 +63,11 @@ def _load(path: Path) -> dict[str, PatientRecord]:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
out: dict[str, PatientRecord] = {}
for snils, rec in (raw.get("records") or {}).items():
out[pseudo_id(snils)] = PatientRecord.model_validate(rec)
aliases = rec.pop("tokens", []) if isinstance(rec, dict) else []
record = PatientRecord.model_validate(rec)
out[pseudo_id(snils)] = record
for token in aliases: # demo tokens used by the patient web app (e.g. tok-andrey) share the record
out[token] = record
return out
@@ -73,7 +77,11 @@ class EmiasRecordSource:
async def get(self, token: str) -> PatientRecord | None:
try:
base = _load(self._path or default_records_path()).get(token)
records = _load(self._path or default_records_path())
base = records.get(token)
if base is None and "-" in token:
# Fresh demo copies (POST /auth/demo/fresh): "tok-andrey-1a2b3c4d" → the "tok-andrey" record.
base = records.get(token.rsplit("-", 1)[0])
except (OSError, yaml.YAMLError, ValueError) as exc:
log.error("emias records unavailable: %s", type(exc).__name__)
base = None
+2 -2
View File
@@ -92,9 +92,9 @@ class TelegramDoctorChat:
def _get_sender(self) -> ChatSender:
if self._sender is None:
from aiogram import Bot
from app.channels.telegram import make_bot
self._bot = Bot(self._bot_token)
self._bot = make_bot(self._bot_token)
self._sender = self._bot.send_message
return self._sender
+41 -7
View File
@@ -14,12 +14,14 @@ Guarantees (on top of what the dialog engine already did — scrub, consent, saf
import asyncio
import json
import logging
import re
from typing import Any
from pydantic import BaseModel
from app.config import Settings, get_settings
from app.core.types import ScrubbedText
from app.llm.agent import stream
from app.llm.agent.prompts import SYSTEM_PROMPT
from app.llm.agent.providers import (
GigaChatToolProvider,
@@ -35,6 +37,19 @@ from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200
MAX_TOOL_CALLS_PER_TURN = 4 # seen live: one «как дела?» turn issued 23 parallel guideline searches
# Greetings / small talk: answered without the LLM (instant, free, never drags medical findings into «привет»).
SMALL_TALK = re.compile(
r"^\W*(прив\w*|здравств\w*|здаров\w*|добр(ый|ое|ого)\s+\w+|хай|hello|hi|ку)\b"
r"|\b(как\s+(дела|ты|вы|жизнь|поживаешь)|что\s+делаешь|чем\s+занят\w*|спишь|кто\s+ты|ты\s+(бот|робот|кто))\b",
re.I,
)
SMALL_TALK_MAX_WORDS = 8
SMALL_TALK_REPLY = (
"Здравствуйте! Я на связи. Могу рассказать о вашем лечении: как действует препарат и когда ждать эффекта, "
"какие бывают побочные эффекты, что можно и нельзя сочетать с вашими таблетками, что в составе. "
"Спросите — отвечу по вашей карте, инструкции и клиническим рекомендациям."
)
NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"}
@@ -66,6 +81,15 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
return None
def is_small_talk(text: str) -> bool:
"""Short greeting / chit-chat without a medical question in it."""
words = re.findall(r"\w+", text)
medical = r"(лекарств|таблет|препарат|побоч|доз|лечени|можно|ибупрофен|нурофен|алкогол|пив|сертралин|сон|сплю)"
if len(words) > SMALL_TALK_MAX_WORDS or re.search(medical, text, re.I):
return False
return bool(SMALL_TALK.search(text))
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
"""One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError)."""
wire = _wire(messages) if provider.name == "openrouter" else messages # gigachat needs the tool `name`
@@ -102,6 +126,9 @@ async def answer_chat(
except ValueError:
log.warning("chat agent: unscrubbed input rejected")
return None
if is_small_talk(question):
return AgentAnswer(text=SMALL_TALK_REPLY, sources=[], tools=[], provider="small_talk")
stream.emit("status", "Думаю над вопросом…")
ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)},
@@ -123,17 +150,18 @@ async def answer_chat(
{
"role": "assistant",
"content": turn.content,
"tool_calls": [
"tool_calls": [ # the calls we actually run (capped) — history must match the results
{
"id": c.id,
"type": "function",
"function": {"name": c.name, "arguments": json.dumps(c.arguments, ensure_ascii=False)},
}
for c in turn.tool_calls
for c in turn.tool_calls[:MAX_TOOL_CALLS_PER_TURN]
],
}
)
for c in turn.tool_calls:
for c in turn.tool_calls[:MAX_TOOL_CALLS_PER_TURN]:
stream.emit("status", stream.TOOL_STATUS.get(c.name, "Проверяю данные…"))
result = await run_tool(ctx, c.name, c.arguments)
messages.append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": result})
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
@@ -159,8 +187,14 @@ def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
return AgentAnswer(text=text, sources=ctx.sources[:4], tools=ctx.used, provider=provider)
_MD_BULLET = re.compile(r"^\s*[*-]\s+", re.M)
_MD_MARKS = re.compile(r"\*\*|__|^#+\s*", re.M)
SOURCES_MIN_CHARS = 160 # short replies (greetings, small talk) go without the sources footer
def render(answer: AgentAnswer) -> str:
"""Patient-facing text: answer + short sources footer."""
if not answer.sources:
return answer.text
return f"{answer.text}\n\nИсточники: " + "; ".join(answer.sources)
"""Patient-facing text: answer (markdown stripped to plain text with «•» bullets) + short sources footer."""
text = _MD_MARKS.sub("", _MD_BULLET.sub("• ", answer.text)).strip()
if not answer.sources or len(text) < SOURCES_MIN_CHARS:
return text
return f"{text}\n\nИсточники: " + "; ".join(answer.sources)
+9 -1
View File
@@ -32,7 +32,15 @@ _SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
- проводить психотерапию; обещать конфиденциальность сверх реальной;
- выдумывать факты, которых нет в результатах инструментов.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
На приветствия и болтовню («привет», «как дела?», «спишь?») отвечай 1–2 фразами, тепло, от лица помощника
сервиса (не называй себя «языковой моделью» или «ИИ-моделью») и предложи, с чем можешь помочь: вопросы о
лечении, лекарствах, побочных эффектах, самочувствии. Находки из карты в ответ на болтовню не упоминай.
Никогда не переспрашивай, о каком препарате речь, если вопрос о «моём лекарстве»: это текущее назначение.
Про аналоги и замену: назови группу текущего препарата по инструкции (например, «антидепрессант группы СИОЗС»),
скажи, что в группе есть и другие препараты, а подбор и замену делает только лечащий врач.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений. Без markdown: никаких «*», «#»,
«**»; если нужен список — короткие строки с «•».
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия {phone:helpline}, и что врач будет уведомлён."""
+77 -1
View File
@@ -16,6 +16,7 @@ import httpx
from pydantic import BaseModel, Field
from app.config import Settings
from app.llm.agent import stream
log = logging.getLogger(__name__)
@@ -49,9 +50,12 @@ class OpenRouterProvider:
self._model = settings.openrouter_model
self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
self._timeout = settings.chat_agent_timeout_s
self._proxy = settings.openrouter_proxy_url or None
self._client = client
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
if stream.active():
return await self._chat_stream(messages, tools)
body: dict[str, Any] = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1}
body["max_tokens"] = 900
if not any(m["role"] == "tool" for m in messages):
@@ -61,7 +65,7 @@ class OpenRouterProvider:
if self._client is not None:
resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout)
else:
async with httpx.AsyncClient(timeout=self._timeout) as client:
async with httpx.AsyncClient(timeout=self._timeout, proxy=self._proxy) as client:
resp = await client.post(self._url, json=body, headers=headers)
except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc
@@ -173,6 +177,78 @@ class GigaChatToolProvider:
return AssistantTurn(content=msg.content or None, tool_calls=calls)
# ------------------------------------------------------------------------------------------------ streaming
async def _iter_sse(resp: httpx.Response):
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue # comments («: OPENROUTER PROCESSING») and blank lines
data = line[5:].strip()
if data == "[DONE]":
return
try:
yield json.loads(data)
except json.JSONDecodeError:
continue
async def _openrouter_stream(self: "OpenRouterProvider", messages: list[dict[str, Any]], tools: list[dict[str, Any]]):
"""Same request with `stream: true`: content deltas go to the live channel, tool calls are assembled."""
body: dict[str, Any] = {
"model": self._model,
"messages": messages,
"tools": tools,
"temperature": 0.1,
"max_tokens": 900,
"stream": True,
}
if not any(m["role"] == "tool" for m in messages):
body["tool_choice"] = "required"
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
content: list[str] = []
calls: dict[int, dict[str, Any]] = {}
try:
client = self._client or httpx.AsyncClient(timeout=self._timeout, proxy=self._proxy)
try:
async with client.stream("POST", self._url, json=body, headers=headers, timeout=self._timeout) as resp:
if resp.status_code != 200:
await resp.aread()
raise ProviderError(f"http {resp.status_code}")
async for chunk in _iter_sse(resp):
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
if delta.get("content"):
content.append(delta["content"])
stream.emit("delta", delta["content"])
for tc in delta.get("tool_calls") or []:
slot = calls.setdefault(tc.get("index", 0), {"id": "", "name": "", "args": ""})
slot["id"] = tc.get("id") or slot["id"]
fn = tc.get("function") or {}
slot["name"] += fn.get("name") or ""
slot["args"] += fn.get("arguments") or ""
finally:
if self._client is None:
await client.aclose()
except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc
out = []
for i in sorted(calls):
c = calls[i]
try:
args = json.loads(c["args"] or "{}")
except json.JSONDecodeError:
args = {}
out.append(ToolCall(id=c["id"] or c["name"] or f"call{i}", name=c["name"], arguments=args))
if out and content:
stream.emit("reset", None) # a preface before tool calls is not the answer
log.info("openrouter stream ok calls=%d chars=%d", len(out), sum(map(len, content)))
return AssistantTurn(content="".join(content) or None, tool_calls=out)
OpenRouterProvider._chat_stream = _openrouter_stream # type: ignore[attr-defined]
# ------------------------------------------------------------------------------------------------ offline mock
_DRUG_WORDS = re.compile(
+32
View File
@@ -0,0 +1,32 @@
"""Live progress of the chat agent for streaming channels (web SSE).
The SSE endpoint sets a queue in the context; the agent pushes events while it works:
status — a tool step in plain Russian («Смотрю вашу карту ЕМИАС…»)
delta — a chunk of the answer as the model writes it
reset — the streamed text so far was a preface before tool calls: clear it
Channels without a queue (Telegram, tests) are unaffected. Deltas are the raw model text: the final reply
(forbidden-output filter, sources footer) always comes in the closing `done` event and replaces it.
"""
import asyncio
from contextvars import ContextVar
from typing import Any
STREAM: ContextVar[asyncio.Queue[tuple[str, Any]] | None] = ContextVar("agent_stream", default=None)
TOOL_STATUS = {
"get_patient_record": "Смотрю вашу карту ЕМИАС…",
"check_drug_for_patient": "Проверяю препарат по вашей карте: болезни, непереносимости, другие лекарства…",
"get_drug_info": "Открываю инструкцию к препарату (ГРЛС)…",
"search_guidelines": "Ищу в клинических рекомендациях Минздрава…",
}
def active() -> bool:
return STREAM.get() is not None
def emit(kind: str, payload: Any) -> None:
q = STREAM.get()
if q is not None:
q.put_nowait((kind, payload))
+3 -1
View File
@@ -43,7 +43,9 @@ FORBIDDEN_OUTPUT: tuple[re.Pattern[str], ...] = tuple(
for p in (
r"диагноз",
r"у (вас|тебя) (\w+ )?(депресси|птср|тревожн\w* расстройств|расстройств|биполяр|шизофрен)",
r"(это|похоже на|признаки) (\w+ )?(депресси|птср|посттравматическ|расстройств)",
# Diagnosis-like statements about the patient. A plain definition («ПТСР — это расстройство, которое…»)
# is education, not a diagnosis, so bare «это …» is no longer blocked.
r"(похоже на|признаки|скорее всего,? у вас|у вас,? похоже) (\w+ )?(депресси|птср|посттравматическ|расстройств)",
r"(увеличьте|уменьшите|снизьте|повысьте|удвойте|измените|увеличь|уменьши|снизь) (\w+ )?доз",
r"(?<!не )отмен(ите|и) ",
r"(?<!не )(прекратите|перестаньте|бросьте) (\w+ )?(приём|прием|принимать|пить)",
+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
+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}"
+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
+32
View File
@@ -51,6 +51,37 @@ async def refresh_status(session: AsyncSession, token: str) -> str:
return status.color
# Which data a crisis rule is triggered by: a crisis for the same code is raised again only when such data is
# newer than the last alert with that code (acked or not) — otherwise an acked PHQ-9 item 9 re-fired on every
# data change for 24 h (seen live: AUDIT-C answers 0/0/0 → crisis again).
_CRISIS_TRIGGERS: dict[str, tuple[str, ...]] = {
"phq9_item9": ("phq9",),
"cssrs_high": ("cssrs_screen",),
"cssrs_moderate": ("cssrs_screen",),
"weapon_access_risk": ("phq9", "cssrs_screen"),
}
async def _drop_handled_crises(s: AsyncSession, token: str, actions: list[Action]) -> list[Action]:
crisis_codes = {a.code for a in actions if a.type == "crisis"}
if not crisis_codes:
return actions
alerts = await repo.list_alerts(s, token=token)
results = await repo.list_scale_results(s, token)
handled: set[str] = set()
for code in crisis_codes:
last_alert = max((a.ts for a in alerts if a.code == code), default=None)
if last_alert is None:
continue
scales = _CRISIS_TRIGGERS.get(code)
last_data = max((r.ts for r in results if scales is None or r.scale in scales), default=None)
if last_data is None or last_data <= last_alert:
handled.add(code)
if handled:
log.info("monitoring token=%s crisis already handled=%s", token, sorted(handled))
return [a for a in actions if a.code not in handled]
def _drop_running_crises(actions: list[Action], open_acute: set[str]) -> list[Action]:
"""Silence every action of a crisis rule whose acute alert is still open (see module docstring)."""
running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute}
@@ -68,6 +99,7 @@ class RuleMonitoring:
return []
open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"}
actions = _drop_running_crises(evaluate(snapshot), open_acute)
actions = await _drop_handled_crises(s, token, actions)
for action in actions:
if action.type == "enhanced_monitoring":
profile.enhanced_monitoring = True
+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: "Назначенный опрос"
+2
View File
@@ -17,6 +17,8 @@ items:
- id: rf_weapons
text: Есть ли у Вас дома или рядом доступ к оружию?
category: null
# Asked and stored, but NOT a stop rule (team decision 2026-10-04: weapon ownership is common in Russia).
trigger: false
- id: rf_violence
text: Угрожает ли Вам сейчас кто-то дома или применяет к Вам насилие?
category: domestic_violence
+1
View File
@@ -47,6 +47,7 @@ rules:
reason_ru: "C-SSRS: высокий риск (намерение/план или поведение)"
- id: weapon_access_risk
enabled: false # team decision 2026-10-04: weapon ownership is common in Russia — the answer triggers nothing
description: >
Access to weapons together with any suicide-risk signal (recent PHQ-9 item 9 >= 1 or C-SSRS >= moderate).
Means restriction is one of the most evidence-based suicide-prevention measures → crisis safety block.
+3
View File
@@ -118,3 +118,6 @@ visit:
unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия {phone:helpline}.
# Answer to an off-script question is followed by the pending question again (engine side answer).
side_answer_return: "Вернёмся к вопросу:"
+15
View File
@@ -33,6 +33,21 @@ question_starts:
- "чем"
- "где"
- "есть ли"
- "что содержится"
- "что содержат"
- "что входит"
- "что в составе"
- "какой состав"
- "какие побочки"
- "какие побочные"
- "состав"
- "какие"
- "можно"
- "из чего"
- "в чём"
- "в чем"
- "расскажи"
- "объясни"
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
+17
View File
@@ -0,0 +1,17 @@
# Распознавание голоса пациента (#116). Модель только расшифровывает речь — не отвечает и не советует.
# Расшифровка возвращается пациенту на проверку и уходит в диалог обычным текстом (safety → scrub → LLM).
# Системная инструкция модели распознавания.
system: >-
Ты — система распознавания речи. Дословно расшифруй русскую речь из аудио.
Выведи только расшифровку: без кавычек, пояснений и ответа на сказанное.
Ставь знаки препинания. Если в записи совсем нет речи — выведи ровно [тишина].
# Текст рядом с аудио в сообщении пользователя.
user: "Расшифруй эту аудиозапись."
# Что модель возвращает, если речи нет (сравнивается без учёта регистра).
silence_marker: "[тишина]"
# Офлайн-режим (STT_PROVIDER=mock): фиксированная фраза демо-пациента, синтетика.
mock_transcript: "Сегодня долго не мог уснуть. Хочу обсудить это на следующей встрече."
+2 -1
View File
@@ -14,7 +14,8 @@ status: "черновик, утверждает психиатр (#8)"
settings:
# Флаги шкал (config/scales/*.yaml), которые считаются сигналом риска для блока безопасного хранения оружия.
# Положительный п.9 PHQ-9 и C-SSRS — суицидальный риск; AUDIT-C — алкоголь как фактор риска при доступе к оружию.
weapon_risk_flags: [phq9_item9_positive, cssrs_high, cssrs_moderate, auditc_positive]
# The weapon question exists but affects nothing (team decision 2026-10-04): no safety block, no trigger.
weapon_risk_flags: []
intro:
text: |-
+6
View File
@@ -103,6 +103,12 @@ class ConsoleApi:
def reset(self) -> dict[str, Any]:
return dict(self._request("POST", "/api/v1/demo/reset", timeout=60))
def demo_settings(self) -> dict[str, Any]:
return dict(self._request("GET", "/api/v1/demo/settings"))
def set_screening(self, enabled: bool) -> dict[str, Any]:
return dict(self._request("POST", "/api/v1/demo/settings", json={"screening_enabled": enabled}))
# ------------------------------------------------------------------ patient dialog
def send_event(
self,
@@ -33,6 +33,20 @@ w.render_connect_hero()
w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}")
# One switch for the presentation: initial questionnaires after consent on/off (off → straight to the chat).
try:
_screening = bool(w.get_api().demo_settings().get("screening_enabled", True))
_wanted = st.sidebar.toggle(
"Начальный тест (скрининг)",
value=_screening,
help="Выключено: после согласия пациент сразу попадает в чат, без опросников.",
)
if _wanted != _screening:
w.get_api().set_screening(_wanted)
st.sidebar.success("Начальный тест " + ("включён" if _wanted else "выключен"))
except Exception: # noqa: BLE001 — the switch must never break the console
st.sidebar.caption("Переключатель теста недоступен (API)")
with st.container(border=True, key="panel-console-clock"):
w.render_clock_bar(persona, key="console-clock")
+2
View File
@@ -8,10 +8,12 @@
# allergies — аллергии и непереносимости; `tags` сверяются с составом препаратов (вспомогательные вещества)
# other_medications — назначения других специалистов; `classes` сверяются со взаимодействиями
# pregnancy — для женщин: беременность/лактация (null — нет данных)
# tokens — демо-токены веб-приложения, которым принадлежит та же карта
# Текущее психотропное назначение приходит из таблицы prescriptions (мок МИС, app/integrations/mis_mock.py).
records:
"900-000-013 34": # Андрей С., 34, ветеран СВО — герой демо
tokens: [tok-andrey] # демо-вход веб-приложения пациента
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
+9
View File
@@ -47,5 +47,14 @@ services:
ports:
- "8501:8501"
web:
# Patient web app (React) — built inside Docker, no Node needed on the host.
build: ./web
depends_on:
app:
condition: service_healthy
ports:
- "8080:80"
volumes:
pgdata:
+28
View File
@@ -3,6 +3,34 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
## 2026-10-31 — ADR-016: Голос в пилоте — SaluteSpeech (REST), зарубежный STT вне демо запрещён
**Контекст:** с реальными пациентами голос — ПДн, и обрабатывать его можно только в РФ-контуре (152-ФЗ, ст. 12 и ст. 18 ч. 5). OpenRouter допустим лишь на синтетике (ADR-014). Пилот «Династия» стартует 01.02.2027.
**Решение:**
1. `SaluteSTT` (`app/llm/stt.py`) за тем же протоколом `SpeechToText`. Используется синхронное REST-распознавание файла (`speech:recognize`): для сообщений до 60 с потоковый gRPC (как в vobot) не нужен, protobuf-стабы и grpc в зависимостях не появляются. Из WAV браузера снимается заголовок, отправляется PCM 16 бит mono.
2. OAuth-токен (30 мин) кешируется на процесс и обновляется за минуту до истечения или по 401 — чтобы не выжигать лимит OAuth Сбера.
3. Креды — `SALUTE_SPEECH_CREDENTIALS` (секрет, только `.env`), `SALUTE_SPEECH_SCOPE`. TLS проверяется всегда; корневой сертификат Минцифры не входит в certifi, путь к нему — `SALUTE_SPEECH_CA` (как `MAX_CA_BUNDLE`).
4. **Guard:** при `DEMO_CLOCK=false` `STT_PROVIDER=openrouter` — ошибка валидации настроек, приложение не стартует. `auto` выбирает SaluteSpeech, если есть креды; вне демо без них голос выключен, а не уходит в OpenRouter или mock (выдуманный текст реальному пациенту).
**Последствия:** для пилота нужны договор SaluteSpeech, ключ и сертификат Минцифры на сервере. До получения ключа живой проверки нет — только тесты с подменой транспорта.
## 2026-10-31 — ADR-015: Сад — растит честная отметка, после кризиса тишина, материалы не считаются
**Контекст:** геймификация при ПТСР и депрессии клинически чувствительна (#120). Сад (#118, #119) не должен давить, подменять клиническую динамику и искажать данные для врача. Решение принято командой и **ждёт вычитки психиатром** (отметка в #120).
**Решение:**
1. Растит **факт честного ответа**, а не «правильный» ответ: «Пропущу» по дозе растит сад так же, как «Принял(а)» (`answered_dose_statuses`). Иначе сад подталкивал бы отмечать приём, которого не было, и врач видел бы ложную приверженность. Доза без ответа (`missed`) не растит, но ничего и не отнимает.
2. Настроение и баллы шкал на рост не влияют. Очки только прибавляются, дневные лимиты есть по каждому типу активности. Сравнения с другими пациентами нет.
3. **Тишина после кризиса:** если за 72 ч был алерт уровня `acute`/`high` из любого источника (чат, шкала, опрос), сервер отдаёт `quiet` — рост сохраняется, но без «Сад подрос» и анимации.
4. Тексты не обещают улучшения: «Растёт от ваших отметок» вместо «Растёт вместе с вами»; о пропуске — «сад не вянет», без «не страшно».
5. **Материалы от врача не растят сад:** просмотр пассивен, награда за него поощряла бы пролистывание. Если такой раздел появится, его можно учесть только как отдельное решение после вычитки.
**Последствия:** правила и пороги — в `config/garden.yaml`, тексты — в `web/src/content/ru.ts`; менять их можно без кода по итогам вычитки.
## 2026-10-31 — ADR-014: Голосовой ввод — расшифровка отдельно от диалога; OpenRouter только на синтетике
**Контекст:** для гранта ВШЭ и пилота «Династия» нужен голосовой ввод (слайд 11: «Записать → Проверить → Сводка»). Голос — ПДн, а `scrub()` его не обезличивает. OpenRouter — зарубежный сервис (ADR-004, guardrail 9).
**Решение:**
1. `POST /me/{token}/stt` **только расшифровывает**. Текст возвращается пациенту на проверку и правку, в диалог уходит обычным сообщением, поэтому safety, `scrub()` и шлюз LLM работают как с набранным текстом. Автоотправки нет.
2. Аудио не сохраняется и не логируется. В лог пишутся только длительность, провайдер, время ответа и длина текста.
3. Формат — WAV 16 кГц mono, его кодирует браузер: ffmpeg на сервере не нужен, одинаково работает в Chrome и iOS Safari. Лимит — `STT_MAX_SECONDS` (60 с).
4. Провайдеры за одним протоколом `SpeechToText` (`app/llm/stt.py`): `mock` (офлайн), `openrouter` (модель с аудиовходом, `input_audio`). **OpenRouter допустим только на синтетических данных** (демо, жюри, грант). До первого реального пациента нужен РФ-провайдер, SaluteSpeech (#121).
**Последствия:** голос в демо работает без новых зависимостей. Для пилота нужен договор с SaluteSpeech.
## 2026-10-04 — ADR-013: Правки по клиническому ревью — красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров
**Контекст:** ревью опросников: обязательны блок красных флагов и план безопасности со звонком после кризиса; маршрутизация должна сначала смотреть на риск и флаги, потом на шкалы; в документе два противоречащих правила кризисного экрана («любое Да» против «только высокий риск»); кризисные номера разбросаны по текстам (и чат-агент давал взрослым детский телефон доверия).
**Решение:**
+19 -7
View File
@@ -1,24 +1,36 @@
#!/usr/bin/env bash
# Deploy origin/dev to the shared server (ssh alias `vobot`, dir /srv/ptsr). Run from a clean checkout:
# Deploy a git ref to the shared server (dir /srv/ptsr). Used by hand and by CD (.gitea/workflows/deploy.yml).
# infra/deploy/deploy.sh [git-ref] default: origin/dev
# Env: DEPLOY_HOST (ssh host/alias, default `vobot`), DEPLOY_SSH_OPTS (extra ssh/scp options).
# Keeps /srv/ptsr/.env (secrets live only on the server) and the Postgres volume.
# The host drops some ssh connections (rate limiting) — every remote step is retried.
set -euo pipefail
REF=${1:-origin/dev}
HOST=${DEPLOY_HOST:-vobot}
OPTS=${DEPLOY_SSH_OPTS:-}
DIR=/srv/ptsr
git fetch -q origin || true
git fetch -q origin 2>/dev/null || true
SHA=$(git rev-parse --short "$REF")
TGZ=$(mktemp --suffix=.tgz)
trap 'rm -f "$TGZ"' EXIT
git archive --format=tar.gz -o "$TGZ" "$REF"
retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; echo "retry $n: $1" >&2; sleep $((n * 10)); done; }
echo "deploy $REF ($SHA) -> $HOST:$DIR"
git archive --format=tar "$REF" | ssh "$HOST" "rm -rf $DIR/app.new && mkdir -p $DIR/app.new && tar -x -C $DIR/app.new"
ssh "$HOST" bash -s <<SH
# shellcheck disable=SC2086
retry scp -q $OPTS "$TGZ" "$HOST:$DIR/release.tgz"
# shellcheck disable=SC2086
retry ssh $OPTS "$HOST" "bash -s" <<SH
set -euo pipefail
cd $DIR
rm -rf app.new && mkdir app.new && tar -xzf release.tgz -C app.new && rm -f release.tgz
cp .env app.new/.env
rm -rf app.old; [ -d app ] && mv app app.old; mv app.new app
chmod 755 $DIR
cd app
echo "$SHA" > REVISION
docker run --rm -v "\$PWD/web:/w" -w /w node:20-alpine sh -c "npm ci --no-audit --no-fund >/dev/null && npm run build >/dev/null"
docker run --rm --user "\$(id -u):\$(id -g)" -e HOME=/tmp -e npm_config_cache=/tmp/.npm -v "\$PWD/web:/w" -w /w node:20-alpine sh -c "npm ci --no-audit --no-fund >/dev/null && npm run build >/dev/null"
docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build </dev/null
for i in \$(seq 1 40); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 3; done
curl -sf 127.0.0.1:8010/health && echo " api ok"
curl -sf 127.0.0.1:8010/health >/dev/null && echo "$SHA" > REVISION && echo "api ok @ $SHA"
SH
+5 -1
View File
@@ -23,8 +23,12 @@ services:
- --browser.gatherUsageStats=false
environment:
API_BASE_URL: http://app:8000
DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-http://localhost}/doctor
DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-https://dev.meditron.tenvex.ru}/doctor
ports: !override
- "127.0.0.1:8511:8501"
restart: unless-stopped
mem_limit: 768m
web:
# On the server the host nginx serves the web bundle itself (deploy.sh builds web/dist).
profiles: ["local-only"]
+69 -2
View File
@@ -1,8 +1,9 @@
# meditron.tenvex.ru — PTSRaiAgent prototype (synthetic data only).
# PTSRaiAgent prototype (synthetic data only) — shared host with vobot.
# / patient web app (static, web/dist)
# /api/ FastAPI (127.0.0.1:8010)
# /doctor/ Streamlit doctor dashboard + demo console (127.0.0.1:8511, baseUrlPath=doctor)
# TLS: after the DNS A-record exists — certbot --nginx -d meditron.tenvex.ru
# dev.meditron.tenvex.ru — HTTPS (Let's Encrypt, certbot certonly --nginx, auto-renew).
# meditron.tenvex.ru — HTTPS (same stack for now; will serve the main branch after promotion).
map $http_upgrade $ptsr_connection_upgrade {
default upgrade;
@@ -12,7 +13,73 @@ map $http_upgrade $ptsr_connection_upgrade {
server {
listen 80;
listen [::]:80;
server_name dev.meditron.tenvex.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name dev.meditron.tenvex.ru;
ssl_certificate /etc/letsencrypt/live/dev.meditron.tenvex.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.meditron.tenvex.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m;
root /srv/ptsr/app/web/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # chat agent + scheduler replay on clock advance
}
location ~ ^/(docs|openapi.json|health)$ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
}
location /doctor/ {
proxy_pass http://127.0.0.1:8511;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $ptsr_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_buffering off;
}
}
server {
listen 80;
listen [::]:80;
server_name meditron.tenvex.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name meditron.tenvex.ru;
ssl_certificate /etc/letsencrypt/live/meditron.tenvex.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/meditron.tenvex.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m;
+1
View File
@@ -20,6 +20,7 @@ dependencies = [
"pandas>=2.2",
"qrcode[pil]>=7.4",
"tzdata>=2026.5",
"aiohttp-socks>=0.8",
]
[dependency-groups]
+2
View File
@@ -12,6 +12,8 @@ def _no_live_llm(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CHAT_AGENT", "off")
monkeypatch.setenv("OPENROUTER_API_KEY", "")
monkeypatch.setenv("LLM_PROVIDER", "mock")
# Tests cover the clinical protocol by default (blocking crisis); demo-mode tests flip it explicitly.
monkeypatch.setenv("CRISIS_BLOCKING", "true")
get_settings.cache_clear()
yield
get_settings.cache_clear()
+83
View File
@@ -0,0 +1,83 @@
"""Live agent output: OpenRouter stream parsing and the SSE endpoint (status → delta → done)."""
import asyncio
import json
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.db import session
from app.db.bootstrap import init_db
from app.llm import agent
from app.llm.agent import stream
from app.llm.agent.providers import OpenRouterProvider
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
def _sse(*chunks: dict) -> bytes:
lines = [": OPENROUTER PROCESSING", ""]
lines += [f"data: {json.dumps(c, ensure_ascii=False)}" for c in chunks] + ["data: [DONE]", ""]
return "\n\n".join(lines).encode()
async def test_openrouter_stream_deltas_and_tool_calls():
body = _sse(
{"choices": [{"delta": {"content": "Сейчас "}}]},
{
"choices": [
{
"delta": {
"tool_calls": [{"index": 0, "id": "c1", "function": {"name": "get_patient_", "arguments": ""}}]
}
}
]
},
{"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"name": "record", "arguments": "{}"}}]}}]},
)
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, content=body)))
provider = OpenRouterProvider(Settings(_env_file=None, openrouter_api_key="k"), client=client)
q: asyncio.Queue = asyncio.Queue()
token = stream.STREAM.set(q)
try:
turn = await provider.chat([{"role": "user", "content": "x"}], [])
finally:
stream.STREAM.reset(token)
assert [c.name for c in turn.tool_calls] == ["get_patient_record"]
events = [q.get_nowait() for _ in range(q.qsize())]
assert ("delta", "Сейчас ") in events and ("reset", None) in events
@pytest.fixture
async def client():
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
yield c
await session.configure()
async def test_sse_endpoint_streams_steps_then_final(client, monkeypatch):
async def fake(token, text, **kw):
stream.emit("status", stream.TOOL_STATUS["get_patient_record"])
stream.emit("delta", "Ответ ")
stream.emit("delta", "агента")
return agent.AgentAnswer(text="Ответ агента", sources=[], tools=["get_patient_record"], provider="fake")
monkeypatch.setattr(agent, "answer_chat", fake)
token = (await client.post("/api/v1/auth/demo/fresh", json={})).json()["token"]
kinds = []
async with client.stream(
"POST", f"/api/v1/dialog/{token}/events/stream", json={"type": "text", "text": "Можно ибупрофен?"}
) as resp:
assert resp.headers["content-type"].startswith("text/event-stream")
async for line in resp.aiter_lines():
if line.startswith("event:"):
kinds.append(line[6:].strip())
if line.startswith("data:") and kinds and kinds[-1] == "done":
final = json.loads(line[5:])
assert kinds[0] == "status" and "delta" in kinds and kinds[-1] == "done"
assert any("Ответ агента" in r["text"] for r in final)
+82 -13
View File
@@ -12,6 +12,7 @@ from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription
from app.main import app
from app.services.anonymous_care import doctor_id as anonymous_doctor
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "b" * 32
@@ -48,17 +49,17 @@ async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: Asy
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s:
# Only a doctor-less pseudonymous profile: the dialog API serves known profiles only.
# The care team sees a pseudonymous profile without an identity record.
profile = await repo.get_profile(s, first["token"])
assert profile.doctor_id == "" and profile.modules == []
assert profile.doctor_id == anonymous_doctor() and profile.modules == []
async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None:
async def test_anonymous_focus_does_not_remove_access_to_doctor(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"]
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile.modules == ["veteran"] and not profile.doctor_id
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False
assert profile.modules == ["veteran"] and profile.doctor_id == anonymous_doctor()
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is True
assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422
@@ -86,10 +87,14 @@ async def test_emias_rejects_unknown_code(client: AsyncClient) -> None:
async def test_emias_demo_persona(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0]
res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})
assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name}
res = (await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})).json()
# Every visitor gets a private copy of the persona, never the shared account.
assert res["token"].startswith(persona.token + "-") and res["mode"] == "emias"
assert res["display_name"] == persona.display_name
again = (await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})).json()
assert again["token"] != res["token"]
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, persona.token)).doctor_id == fixtures.DOCTOR_ID
assert (await repo.get_profile(s, res["token"])).doctor_id == fixtures.DOCTOR_ID
missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"})
assert missing.status_code == 404
@@ -122,7 +127,7 @@ async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
body = (await client.get(f"/api/v1/me/{token}/today")).json()
assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == []
assert body["has_doctor"] is True and body["doses"] == [] and body["prescriptions"] == []
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
@@ -140,8 +145,8 @@ async def test_today_links_prescription_to_reference_and_therapy_day(client: Asy
async def test_demo_persona_week_mirrors_dashboard_story(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token})
persona = auth.json()["token"]
rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["therapy_day"] > 1
@@ -158,8 +163,8 @@ async def test_questionnaires_are_off_without_release_or_demo_flag(
async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "1")
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token})
persona = auth.json()["token"]
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
@@ -220,3 +225,67 @@ async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
body = (await client.get("/api/v1/patient/demo-personas")).json()
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
async def test_anonymous_is_visible_only_to_assigned_doctor(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()["token"]
second = (await client.post("/api/v1/auth/anonymous")).json()["token"]
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
names = {p["token"]: p["display_name"] for p in items}
assert names[first].startswith("Пациент ") and names[first] != names[second]
assert first not in names[first]
own = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": anonymous_doctor()})
assert own.status_code == 200 and own.json()["patient"]["display_name"] == names[first]
denied = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": "another-doctor"})
assert denied.status_code == 403
assert (await client.get("/api/v1/doctors/another-doctor/patients")).json() == []
async def test_old_anonymous_profile_requires_explicit_care_opt_in(client: AsyncClient) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
# Merely opening Today must not reveal an old private profile to a doctor.
assert not (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["has_doctor"]
assert (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json() == []
for _ in range(2):
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).json() == {"has_doctor": True}
async with session.get_clinical_session() as s:
p = await repo.get_profile(s, TOKEN)
assert p.doctor_id == anonymous_doctor() and p.modules == ["veteran"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, TOKEN) is None
async def test_connect_care_does_not_reassign_or_create_patients(client: AsyncClient) -> None:
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 404
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="own-doctor")
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 200
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "own-doctor"
async def test_screening_capability_respects_release_gate(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
assert (await client.get("/api/v1/patient/capabilities")).json()["primary_screening"] is False
monkeypatch.setenv("INTAKE_DEMO", "1")
assert (await client.get("/api/v1/patient/capabilities")).json()["primary_screening"] is True
async def test_anonymous_contact_request_reaches_doctor(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
for event in [
{"type": "start"},
{"type": "button", "button": "consent:yes"},
{"type": "button", "button": "age:adult"},
{"type": "button", "button": "menu:contact_doctor"},
]:
response = await client.post(f"/api/v1/dialog/{token}/events", json=event)
assert response.status_code == 200
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
assert items[0]["last_event"] == "Пациент просит врача связаться"
card = (await client.get(f"/api/v1/patients/{token}", params={"doctor_id": anonymous_doctor()})).json()
assert card["patient"]["last_event"] == items[0]["last_event"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, token) is None
+71
View File
@@ -0,0 +1,71 @@
"""Voice input endpoint (#116): transcribes only, validates the audio, never touches the dialog."""
from collections.abc import AsyncIterator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.api import patient_web
from app.db import session
from app.db.bootstrap import init_db
from app.db.clinical import Message
from app.llm.stt import MockSTT, SttError, SttNoSpeech, load_texts
from app.main import app
from tests.test_llm_stt import make_wav
MEM = "sqlite+aiosqlite:///:memory:"
WAV = {"Content-Type": "audio/wav"}
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
monkeypatch.setattr(patient_web, "get_stt", lambda _settings: MockSTT()) # never the network in tests
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def _token(client: AsyncClient) -> str:
return (await client.post("/api/v1/auth/anonymous")).json()["token"]
async def test_transcribes_without_writing_to_dialog(client: AsyncClient) -> None:
token = await _token(client)
resp = await client.post(f"/api/v1/me/{token}/stt", content=make_wav(2), headers=WAV)
assert resp.status_code == 200
assert resp.json() == {"text": load_texts().mock_transcript, "provider": "mock"}
async with session.get_clinical_session() as s:
assert (await s.execute(select(Message))).scalars().all() == []
async def test_capability_and_off(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
assert (await client.get("/api/v1/patient/capabilities")).json()["voice_input"] is True
monkeypatch.setattr(patient_web, "get_stt", lambda _settings: None)
assert (await client.get("/api/v1/patient/capabilities")).json()["voice_input"] is False
token = await _token(client)
assert (await client.post(f"/api/v1/me/{token}/stt", content=make_wav(1), headers=WAV)).status_code == 404
async def test_rejects_unknown_patient_bad_format_and_long_audio(client: AsyncClient) -> None:
assert (await client.post("/api/v1/me/nobody/stt", content=make_wav(1), headers=WAV)).status_code == 404
token = await _token(client)
url = f"/api/v1/me/{token}/stt"
assert (await client.post(url, content=b"OggS" + b"\0" * 200)).status_code == 415
assert (await client.post(url, content=make_wav(70), headers=WAV)).status_code == 413
assert (await client.post(url, content=make_wav(10, rate=48_000), headers=WAV)).status_code == 200
@pytest.mark.parametrize(("error", "status"), [(SttNoSpeech("x"), 422), (SttError("x"), 503)])
async def test_provider_errors(client: AsyncClient, monkeypatch: pytest.MonkeyPatch, error, status) -> None:
class Failing:
name = "failing"
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
raise error
monkeypatch.setattr(patient_web, "get_stt", lambda _settings: Failing())
token = await _token(client)
assert (await client.post(f"/api/v1/me/{token}/stt", content=make_wav(1), headers=WAV)).status_code == status
+1
View File
@@ -79,6 +79,7 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
"questionnaire_notices",
"questionnaire_tasks",
"scheduler_events",
"demo_settings",
} == clinical_tables
await init_db() # idempotent
+36
View File
@@ -0,0 +1,36 @@
"""Demo mode (crisis_blocking off): a crisis signal inside a questionnaire does not stop it; the crisis message
comes at the end and blocks nothing; the weapon question triggers nothing."""
import pytest
from app.db.clinical import Alert
from tests.test_dialog_intake import answer_button, complete_existing, press, released, rows # noqa: F401
@pytest.fixture
def non_blocking(monkeypatch):
from app.config import get_settings
monkeypatch.setattr(get_settings(), "crisis_blocking", False)
async def test_red_flag_does_not_stop_the_questionnaire(released, non_blocking): # noqa: F811
replies = await press(released, "intake:start")
replies = await press(released, answer_button(replies, {"rf_psychosis": 0})) # «Да» on a red flag
assert not any(r.kind == "crisis" for r in replies), "no crisis window in the middle of the test"
assert any(r.buttons for r in replies), "the next question is asked"
(row,) = await rows()
assert row.status != "interrupted" and row.details["pending_urgent"]["code"] == "red_flag_psychosis"
replies = await complete_existing(released, replies, {})
assert any(r.kind == "crisis" for r in replies), "the crisis message comes at the end"
assert any(a.code == "red_flag_psychosis" and a.level == "acute" for a in await rows(Alert))
async def test_weapon_answer_triggers_nothing(released, non_blocking): # noqa: F811
replies = await press(released, "intake:start")
for _ in range(2): # psychosis, harm to others: «Нет»
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies, {"rf_weapons": 0})) # «Да» on weapons
assert not any(r.kind == "crisis" for r in replies)
(row,) = await rows()
assert not row.details.get("pending_urgent")
+45
View File
@@ -0,0 +1,45 @@
"""Presentation mode: fresh private demo patient straight into the chat; screening switch skips onboarding."""
from collections.abc import AsyncIterator
import pytest
from httpx import ASGITransport, AsyncClient
from app.core import demo_settings
from app.db import repo, session
from app.db.bootstrap import init_db
from app.integrations.emias_record import emias_records
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
yield c
await session.configure()
async def test_fresh_demo_patients_are_private_and_ready(client):
a = (await client.post("/api/v1/auth/demo/fresh", json={})).json()["token"]
b = (await client.post("/api/v1/auth/demo/fresh", json={})).json()["token"]
assert a != b and a.startswith("tok-andrey-")
async with session.get_clinical_session() as s:
st = await repo.get_dialog_state(s, a)
assert st.state == "idle" and st.data["consent"] is True
rec = await emias_records.get(a)
assert rec is not None and any("язвенная" in c.name.lower() for c in rec.conditions)
r = await client.post(f"/api/v1/dialog/{a}/events", json={"type": "text", "text": "привет"})
assert r.status_code == 200 and r.json()["replies"]
async def test_screening_switch(client):
assert (await client.get("/api/v1/demo/settings")).json() == {"screening_enabled": True}
assert (await client.post("/api/v1/demo/settings", json={"screening_enabled": False})).json() == {
"screening_enabled": False
}
async with session.get_clinical_session() as s:
assert await demo_settings.screening_enabled(s) is False
+2 -1
View File
@@ -270,7 +270,8 @@ async def test_repeat_crisis_does_not_duplicate_script(dbs: None, engine: Engine
assert r[0].text.startswith(TEXTS.crisis.repeat) and "112" in r[0].text
assert spy.calls == []
r = await text(engine, "ладно")
assert r[0].text == TEXTS.crisis.hold
# In a crisis every message gets a human reply, followed by the emergency contacts (chat never goes silent).
assert r[-1].kind == "crisis" and "112" in r[-1].text and r[0].kind != "crisis"
_, data = await get_state()
assert [e["handled"] for e in data["safety_events"]] == ["crisis_script", "crisis_repeat"]
+24
View File
@@ -0,0 +1,24 @@
"""In idle every message goes to the chat agent (when it is on); quit intent keeps the rule reaction."""
from app.llm import agent
from app.llm.agent import AgentAnswer
from tests.test_dialog_knowledge import TOKEN, engine, joined, say # noqa: F401
async def test_idle_text_goes_to_agent(engine, monkeypatch): # noqa: F811
seen = []
async def fake(token, text, **kw):
seen.append(str(text))
return AgentAnswer(text="ОТВЕТ АГЕНТА", sources=[], tools=["search_guidelines"], provider="fake")
monkeypatch.setattr(agent, "answer_chat", fake)
for msg in ("прив", "какие у меня будут ощущения и побочки от лекарств", "а какие аналоги моего лекарства есть"):
replies = await say(engine, msg)
assert "ОТВЕТ АГЕНТА" in joined(replies), msg
assert len(seen) == 3 and all(t == TOKEN for t in [TOKEN])
async def test_idle_falls_back_when_agent_off(engine): # noqa: F811
replies = await say(engine, "прив")
assert replies and "ОТВЕТ АГЕНТА" not in joined(replies)
+73
View File
@@ -0,0 +1,73 @@
"""Questions are answered in any state (side answer): mid-scale the pending question comes back with its
buttons; in a crisis the answer carries the emergency contacts and the crisis stays open."""
from app.core import clock
from app.db import repo, session
from app.services.contracts import NoopMonitoring
from tests.test_dialog_knowledge import TOKEN, buttons, engine, joined, press, say, state # noqa: F401
class _InCrisis(NoopMonitoring):
async def is_in_crisis(self, token: str) -> bool: # an open acute alert, as RuleMonitoring would report
return True
async def test_question_mid_scale_is_answered_and_question_returns(engine): # noqa: F811
await press(engine, "menu:checkin") # some flow with buttons; then force a scale for determinism
async with session.get_clinical_session() as s:
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
from app.dialog.contracts import Event
first = await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
assert buttons(first), "scale question with buttons expected"
st_before, _ = await state()
replies = await say(engine, "Через сколько начнёт действовать лекарство?")
st_after, data = await state()
assert st_after == st_before # still inside the scale
assert "2–4 недели" in joined(replies) or "врач" in joined(replies).lower()
assert buttons(replies), "the pending question is shown again with its buttons"
assert "Вернёмся к вопросу" in joined(replies)
assert data.get("last_prompt")
async def test_question_in_crisis_gets_answer_and_contacts(engine): # noqa: F811
engine._monitoring = _InCrisis()
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="crisis", data={"consent": True, "crisis": {"open": True}}, now=clock.now()
)
replies = await say(engine, "Через сколько начнёт действовать лекарство?")
st, _ = await state()
assert st == "crisis"
kinds = [r.kind for r in replies]
assert "crisis" in kinds and len(replies) >= 2
assert "112" in joined(replies)
async def test_non_question_mid_scale_still_goes_to_the_scale(engine): # noqa: F811
from app.dialog.contracts import Event
await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
replies = await say(engine, "ну так себе")
assert "Вернёмся к вопросу" not in joined(replies)
async def test_crisis_any_text_gets_a_reply_with_contacts(engine): # noqa: F811
engine._monitoring = _InCrisis()
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="crisis", data={"consent": True, "crisis": {"open": True}}, now=clock.now()
)
for text in ("привет", "мне страшно", "что содержится в моих лекарствах"):
replies = await say(engine, text)
assert len(replies) >= 2, text
assert replies[-1].kind == "crisis" and "112" in joined(replies)
assert replies[0].kind != "crisis" # a real reply first, not only the stub
async def test_medicine_mention_without_question_mark_mid_scale(engine): # noqa: F811
from app.dialog.contracts import Event
await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
replies = await say(engine, "что содержится в моих лекарствах")
assert "Вернёмся к вопросу" in joined(replies) and buttons(replies)
+1 -1
View File
@@ -268,7 +268,7 @@ async def test_auditc_sex_declined_uses_default_threshold(dbs_nosex: None, engin
assert (await results())["auditc"].severity == "positive" # unknown sex → sensitive threshold ≥ 3
# Weapon at home + AUDIT-C positive = risk signal → safe-storage block.
r = await press(engine, "vonb:weapon:self")
assert VT.weapon.safety_block_self in [x.text for x in r]
assert VT.weapon.safety_block_self not in [x.text for x in r] # the weapon answer affects nothing
# ----------------------------------------------------------------------------------------------- weapon / safety
+29
View File
@@ -269,3 +269,32 @@ def test_build_provider_prefers_gigachat():
s = Settings(_env_file=None, chat_agent="auto", gigachat_credentials="x", openrouter_api_key="y")
assert isinstance(agent.build_provider(s), GigaChatToolProvider)
async def test_web_demo_token_shares_andreys_record():
rec = await emias_records.get("tok-andrey")
assert rec is not None and any("язвенная" in c.name.lower() for c in rec.conditions)
@pytest.mark.parametrize("text", ["прив", "Привет!", "как дела у тебя ? что делаешь ? спишь ?", "добрый вечер"])
async def test_small_talk_without_llm(text):
provider, sent = _fake_openrouter("x", _CALLS)
ans = await agent.answer_chat(ANDREY, scrub(text), provider=provider, settings=_settings())
assert ans is not None and ans.provider == "small_talk" and sent == []
async def test_medical_question_is_not_small_talk():
assert not agent.is_small_talk("привет, можно мне ибупрофен?")
async def test_tool_calls_are_capped(sertraline):
many = [
{"id": f"s{i}", "type": "function", "function": {"name": "search_guidelines", "arguments": '{"query": "x"}'}}
for i in range(20)
]
provider, sent = _fake_openrouter("Ответ по рекомендациям, обсудите с врачом.", many)
ans = await agent.answer_chat(ANDREY, scrub("что такое ПТСР?"), provider=provider, settings=_settings())
assert ans is not None and len(ans.tools) == agent.MAX_TOOL_CALLS_PER_TURN
tool_msgs = [m for m in sent[1]["messages"] if m["role"] == "tool"]
assistant = [m for m in sent[1]["messages"] if m["role"] == "assistant"][0]
assert len(tool_msgs) == len(assistant["tool_calls"]) == agent.MAX_TOOL_CALLS_PER_TURN
+212
View File
@@ -0,0 +1,212 @@
"""Speech-to-text providers (#116, #121): OpenRouter and SaluteSpeech request shape, retries, silence, WAV parsing,
provider choice and the «no foreign STT outside the demo» guard."""
import base64
import io
import json
import time
import wave
import httpx
import pytest
from pydantic import ValidationError
from app.config import Settings
from app.llm import stt as stt_mod
from app.llm.stt import (
SALUTE_OAUTH_URL,
SALUTE_RECOGNIZE_URL,
InvalidAudio,
MockSTT,
OpenRouterSTT,
SaluteSTT,
SttError,
SttNoSpeech,
get_stt,
load_texts,
wav_seconds,
)
def make_wav(seconds: float, rate: int = 16_000) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(b"\x00\x00" * int(rate * seconds))
return buf.getvalue()
def _settings(**kw) -> Settings:
base = {"openrouter_api_key": "test", "stt_provider": "openrouter", "stt_timeout_s": 5.0}
return Settings(_env_file=None, **{**base, **kw})
def _fake(responses: list[httpx.Response]) -> tuple[OpenRouterSTT, list[dict]]:
sent: list[dict] = []
replies = iter(responses)
def handler(request: httpx.Request) -> httpx.Response:
sent.append(json.loads(request.content))
return next(replies)
stt = OpenRouterSTT(_settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
return stt, sent
def _ok(text: str) -> httpx.Response:
return httpx.Response(200, json={"choices": [{"message": {"content": text}}]})
def test_wav_seconds() -> None:
assert wav_seconds(make_wav(2.5)) == pytest.approx(2.5)
with pytest.raises(InvalidAudio):
wav_seconds(b"OggS" + b"\x00" * 100)
with pytest.raises(InvalidAudio):
wav_seconds(b"")
async def test_openrouter_sends_audio_and_returns_transcript() -> None:
audio = make_wav(1)
stt, sent = _fake([_ok("«Сегодня плохо спал.»")])
assert await stt.transcribe(audio) == "Сегодня плохо спал."
body = sent[0]
assert body["model"] == "google/gemini-2.5-flash" and body["temperature"] == 0
assert body["reasoning"] == {"enabled": False}
part = body["messages"][1]["content"][1]
assert part["type"] == "input_audio" and part["input_audio"]["format"] == "wav"
assert base64.b64decode(part["input_audio"]["data"]) == audio
async def test_silence_marker_is_no_speech() -> None:
stt, _ = _fake([_ok(load_texts().silence_marker.upper())])
with pytest.raises(SttNoSpeech):
await stt.transcribe(make_wav(1))
async def test_retries_then_succeeds(monkeypatch) -> None:
monkeypatch.setattr("app.llm.stt.asyncio.sleep", _no_sleep)
stt, sent = _fake([httpx.Response(503), _ok("текст")])
assert await stt.transcribe(make_wav(1)) == "текст"
assert len(sent) == 2
async def test_http_error_raises(monkeypatch) -> None:
monkeypatch.setattr("app.llm.stt.asyncio.sleep", _no_sleep)
stt, _ = _fake([httpx.Response(401, json={"error": {"message": "bad key"}})])
with pytest.raises(SttError):
await stt.transcribe(make_wav(1))
async def _no_sleep(_: float) -> None:
return None
async def test_mock_is_offline() -> None:
assert await MockSTT().transcribe(b"") == load_texts().mock_transcript
def test_provider_choice() -> None:
assert isinstance(get_stt(_settings(stt_provider="auto")), OpenRouterSTT)
assert isinstance(get_stt(_settings(stt_provider="auto", openrouter_api_key="")), MockSTT)
assert get_stt(_settings(stt_provider="openrouter", openrouter_api_key="")) is None
assert get_stt(_settings(stt_provider="off")) is None
# SaluteSpeech wins `auto` whenever its credentials are set.
assert isinstance(get_stt(_settings(stt_provider="auto", salute_speech_credentials="c")), SaluteSTT)
assert isinstance(get_stt(_settings(stt_provider="salute", salute_speech_credentials="c")), SaluteSTT)
assert get_stt(_settings(stt_provider="salute")) is None
def test_outside_demo_no_foreign_stt() -> None:
"""#121: with real patients (DEMO_CLOCK=false) OpenRouter refuses to start, `auto` never picks it or mock."""
with pytest.raises(ValidationError, match="DEMO_CLOCK=false"):
_settings(stt_provider="openrouter", demo_clock=False)
assert get_stt(_settings(stt_provider="auto", demo_clock=False)) is None
salute = get_stt(_settings(stt_provider="auto", demo_clock=False, salute_speech_credentials="c"))
assert isinstance(salute, SaluteSTT)
def test_salute_ca_must_exist(tmp_path) -> None:
with pytest.raises(ValidationError, match="SALUTE_SPEECH_CA"):
_settings(salute_speech_ca=tmp_path / "missing.crt")
class _SaluteFake:
"""Fake Sber: OAuth + recognize. `replies` are recognize responses in order."""
def __init__(self, replies: list[httpx.Response]) -> None:
self.replies = iter(replies)
self.oauth_calls = 0
self.recognize: list[httpx.Request] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
if str(request.url) == SALUTE_OAUTH_URL:
self.oauth_calls += 1
assert request.headers["Authorization"] == "Basic cred-1"
assert request.headers["RqUID"] and b"scope=SALUTE_SPEECH_PERS" in request.content
exp_ms = int((time.time() + 1800) * 1000)
return httpx.Response(200, json={"access_token": f"tok-{self.oauth_calls}", "expires_at": exp_ms})
assert str(request.url).startswith(SALUTE_RECOGNIZE_URL)
self.recognize.append(request)
return next(self.replies)
def _salute(replies: list[httpx.Response]) -> tuple[SaluteSTT, _SaluteFake]:
stt_mod._salute_token.cache_clear()
fake = _SaluteFake(replies)
settings = _settings(stt_provider="salute", salute_speech_credentials="cred-1")
return SaluteSTT(settings, client=httpx.AsyncClient(transport=httpx.MockTransport(fake))), fake
def _said(*parts: str) -> httpx.Response:
return httpx.Response(200, json={"status": 200, "result": list(parts), "emotions": []})
async def test_salute_sends_raw_pcm_and_joins_result() -> None:
stt, fake = _salute([_said("сегодня плохо спал", "", "хочу обсудить")])
wav = make_wav(1.0)
assert await stt.transcribe(wav) == "сегодня плохо спал хочу обсудить"
req = fake.recognize[0]
assert req.headers["Content-Type"] == "audio/x-pcm;bit=16;rate=16000"
assert req.headers["Authorization"] == "Bearer tok-1"
assert req.url.params["language"] == "ru-RU"
assert req.content == wav[44:] # the WAV header is stripped: PCM only
async def test_salute_token_cached_and_refreshed_on_401() -> None:
stt, fake = _salute([_said("раз"), _said("два"), httpx.Response(401), _said("три")])
for _ in range(2):
await stt.transcribe(make_wav(0.5))
assert fake.oauth_calls == 1 # one token for both requests
assert await stt.transcribe(make_wav(0.5)) == "три"
assert fake.oauth_calls == 2 and fake.recognize[-1].headers["Authorization"] == "Bearer tok-2"
async def test_salute_empty_result_is_no_speech() -> None:
stt, _ = _salute([_said()])
with pytest.raises(SttNoSpeech):
await stt.transcribe(make_wav(0.5))
async def test_salute_errors(monkeypatch) -> None:
monkeypatch.setattr("app.llm.stt.asyncio.sleep", _no_sleep)
stt, _ = _salute([httpx.Response(503), httpx.Response(503), httpx.Response(503)])
with pytest.raises(SttError):
await stt.transcribe(make_wav(0.5))
stt, _ = _salute([httpx.Response(400, json={"status": 400, "message": "bad audio"})])
with pytest.raises(SttError):
await stt.transcribe(make_wav(0.5))
async def test_salute_rejects_non_pcm_mono() -> None:
stt, fake = _salute([])
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(16_000)
w.writeframes(b"\x00\x00" * 3200)
with pytest.raises(InvalidAudio):
await stt.transcribe(buf.getvalue())
assert fake.oauth_calls == 0
+3 -2
View File
@@ -125,8 +125,9 @@ def test_cssrs_levels() -> None:
@pytest.mark.parametrize(
("weapon", "cssrs", "item9", "fires"),
[
(True, "moderate", None, True),
(True, None, 1, True),
# Team decision 2026-10-04: weapon access triggers nothing (rule disabled in config/rules.yaml).
(True, "moderate", None, False),
(True, None, 1, False),
(True, "low", 0, False),
(False, "high", 2, False),
(None, "moderate", None, False),
+32
View File
@@ -0,0 +1,32 @@
"""An acked crisis is not raised again for the same signal; a newer PHQ-9 with item 9 raises a new one."""
from datetime import datetime, timedelta
from app.core.rules import Action
from app.core.types import AlertLevel
from app.services import monitoring
class _A:
def __init__(self, code, ts, scale=None):
self.code, self.ts, self.scale = code, ts, scale
async def test_drop_handled_crises(monkeypatch):
t0 = datetime(2026, 10, 4, 16, 32)
alerts = [_A("phq9_item9", t0 + timedelta(seconds=1))]
results = [_A(None, t0, "phq9"), _A(None, t0 + timedelta(hours=2), "auditc")]
async def list_alerts(s, token):
return alerts
async def list_scale_results(s, token):
return results
monkeypatch.setattr(monitoring.repo, "list_alerts", list_alerts)
monkeypatch.setattr(monitoring.repo, "list_scale_results", list_scale_results)
crisis = Action(type="crisis", level=AlertLevel.ACUTE, code="phq9_item9")
other = Action(type="alert", level=AlertLevel.HIGH, code="phq9_severe")
assert await monitoring._drop_handled_crises(None, "t", [crisis, other]) == [other] # AUDIT-C is not a trigger
results.append(_A(None, t0 + timedelta(hours=3), "phq9")) # a new PHQ-9 after the alert
assert await monitoring._drop_handled_crises(None, "t", [crisis]) == [crisis]
+132
View File
@@ -0,0 +1,132 @@
"""Garden (#118): deterministic growth from activities, daily caps, never shrinks, ignores answers."""
from collections.abc import AsyncIterator
from datetime import date, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.config import Settings
from app.core import clock
from app.db import session
from app.db.clinical import Alert, DailyCheckin, Dose
from app.main import app
from app.services.garden import compute, load_config
from scripts.seed_demo import seed
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 5, 14, 0)
D = date(2026, 10, 5)
def test_points_steps_and_daily_caps() -> None:
cfg = load_config()
one_diary = compute([("diary", D)], D)
assert one_diary.points == cfg.activities["diary"].points
# Five diary entries on one day count as one; on five days — five times.
assert compute([("diary", D)] * 5, D).points == one_diary.points
five_days = compute([("diary", D - timedelta(days=i)) for i in range(5)], D)
assert five_days.points == 5 * one_diary.points
assert five_days.steps == five_days.points // cfg.step_points
assert five_days.points_in_step == five_days.points % cfg.step_points
def test_never_shrinks_and_future_ignored() -> None:
events = [("meds", D), ("diary", D), ("questionnaire", D)]
now = compute(events, D)
# A week without any activity takes nothing away.
assert compute(events, D + timedelta(days=7)).points == now.points
# Rows dated after the (demo) today are not counted yet.
assert compute([*events, ("diary", D + timedelta(days=1))], D).points == now.points
def test_plants_grow_one_after_another() -> None:
cfg = load_config()
per_plant = cfg.stages_per_plant
g = compute([("questionnaire", D - timedelta(days=i)) for i in range(100)], D)
stages = [p.stage for p in g.plants]
assert stages == sorted(stages, reverse=True) and stages[0] == per_plant
assert sum(stages) == min(g.steps, per_plant * cfg.max_plants)
assert compute([], D).title == cfg.titles[0].title
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def test_andrey_has_a_living_garden_that_grows_and_ignores_mood(client: AsyncClient) -> None:
token = (await seed(12)).andrey_token
url = f"/api/v1/me/{token}/garden"
before = (await client.get(url)).json()
assert before["steps"] >= 4 and before["plants"][0]["stage"] > 0
# Mood is an answer: a bad day and a good day grow the garden the same way.
async with session.get_clinical_session() as s:
rows = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for r in rows:
r.mood = 1
assert (await client.get(url)).json()["points"] == before["points"]
# Taking a dose today adds points.
async with session.get_clinical_session() as s:
dose = Dose(token=token, prescription_id=1, planned_at=clock.now(), status="taken", answered_at=clock.now())
s.add(dose)
after = (await client.get(url)).json()
assert after["points"] >= before["points"]
assert any(i["activity"] == "diary" for i in after["today"])
async def test_honest_skip_grows_like_taken_and_missed_does_not(client: AsyncClient) -> None:
"""#120: the garden rewards an honest answer, never a «taken» mark — otherwise it would push to false marks."""
token = (await seed(12)).andrey_token
url = f"/api/v1/me/{token}/garden"
base = (await client.get(url)).json()["points"]
async with session.get_clinical_session() as s:
for d in (await s.scalars(select(Dose).where(Dose.token == token))).all():
await s.delete(d)
none = (await client.get(url)).json()["points"]
async def with_dose(status: str) -> int:
async with session.get_clinical_session() as s:
for d in (await s.scalars(select(Dose).where(Dose.token == token))).all():
await s.delete(d)
s.add(Dose(token=token, prescription_id=1, planned_at=clock.now(), status=status, answered_at=clock.now()))
return (await client.get(url)).json()["points"]
assert await with_dose("taken") == await with_dose("skipped") > none
assert await with_dose("missed") == none
assert base >= none
async def test_quiet_after_recent_high_alert(client: AsyncClient) -> None:
"""#120: no celebration after a crisis signal from any source; an old or moderate alert does not silence it."""
token = (await seed(12)).andrey_token
url = f"/api/v1/me/{token}/garden"
async with session.get_clinical_session() as s:
for a in (await s.scalars(select(Alert).where(Alert.token == token))).all():
await s.delete(a)
assert (await client.get(url)).json()["quiet"] is False
def alert(level: str, hours_ago: int) -> Alert:
ts = clock.now() - timedelta(hours=hours_ago)
return Alert(token=token, ts=ts, level=level, code="test", reason="test", params={})
async with session.get_clinical_session() as s:
s.add_all([alert("moderate", 1), alert("acute", 24 * 10)])
assert (await client.get(url)).json()["quiet"] is False
async with session.get_clinical_session() as s:
s.add(alert("high", 2))
quiet = (await client.get(url)).json()
assert quiet["quiet"] is True and quiet["points"] > 0
async def test_unknown_patient_404(client: AsyncClient) -> None:
await seed(0)
assert (await client.get("/api/v1/me/nobody/garden")).status_code == 404
+3 -4
View File
@@ -223,10 +223,9 @@ async def test_weapon_access_from_dialog_facts(db: MemoryNotifier) -> None:
assert snap is not None and snap.weapon_access is True
await scale("cssrs_screen", 3, severity="moderate", scheduled=False)
actions = await monitoring.RuleMonitoring().after_data_change(TOKEN)
assert {a.code for a in actions if a.type == "crisis"} == {"weapon_access_risk"}
plan = next(a for a in actions if a.type == "show_safety_plan")
assert plan.params["block"] == "means_restriction"
assert await color() == "red"
# Team decision 2026-10-04: weapon access is stored but triggers nothing (no crisis, no safety block).
assert "weapon_access_risk" not in {a.code for a in actions}
assert not any(a.type == "show_safety_plan" and a.params.get("block") == "means_restriction" for a in actions)
async def test_snapshot_mapping_and_unknown_patient(db: MemoryNotifier) -> None:
+20 -2
View File
@@ -22,6 +22,7 @@ from app.channels.telegram import (
render_markup,
split_text,
)
from app.config import get_settings
from app.db import identity_repo, session
from app.db.bootstrap import init_db
from app.dialog.contracts import Button, Event, Reply
@@ -163,7 +164,10 @@ async def test_verified_token_without_identity_record(dbs: None, engine: Recordi
assert sender.texts == [load_texts().code_invalid]
async def test_unbound_chat_gets_not_connected(dbs: None, engine: RecordingEngine) -> None:
async def test_unbound_chat_gets_not_connected(
dbs: None, engine: RecordingEngine, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(get_settings(), "demo_clock", False)
sender = FakeSender()
channel = TelegramChannel(sender)
await channel.on_start(CHAT, None)
@@ -251,6 +255,18 @@ async def test_outbox_pushes_to_bound_chat_and_falls_back(dbs: None, engine: Rec
assert [r.text for r in fallback.drain(TOKEN2)] == ["Как вы сегодня?"]
async def test_start_without_code_on_demo_stand_binds_a_demo_copy(
dbs: None, engine: RecordingEngine, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(get_settings(), "demo_clock", True)
sender = FakeSender()
channel = TelegramChannel(sender)
await channel.on_start(CHAT, None)
token = await channel.directory.token_for(CHAT)
assert token is not None and token.startswith("tok-andrey-")
assert [c[1].type for c in engine.calls] == ["start"]
async def test_start_is_noop_without_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": ""})())
before = integrations.outbox
@@ -261,7 +277,9 @@ async def test_start_is_noop_without_token(monkeypatch: pytest.MonkeyPatch) -> N
async def test_start_stop_swaps_outbox(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": "123:fake"})())
monkeypatch.setattr(
telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": "123:fake", "tg_proxy_url": ""})()
)
async def fake_polling(self: object, *bots: object, **kwargs: object) -> None:
import asyncio
+16
View File
@@ -0,0 +1,16 @@
"""TG_PROXY_URL: the bot session goes through the proxy when it is set."""
from app.channels import telegram
from app.config import get_settings
TOKEN = "123456:" + "A" * 35
def test_make_bot_without_proxy(monkeypatch):
monkeypatch.setattr(get_settings(), "tg_proxy_url", "")
assert telegram.make_bot(TOKEN).session.proxy is None
def test_make_bot_with_proxy(monkeypatch):
monkeypatch.setattr(get_settings(), "tg_proxy_url", "http://user:pass@127.0.0.1:8888")
assert telegram.make_bot(TOKEN).session.proxy == "http://user:pass@127.0.0.1:8888"
Generated
+24
View File
@@ -122,6 +122,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
]
[[package]]
name = "aiohttp-socks"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "python-socks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/1d/a306e0111222180e60f17131a3f5d9bc694dd999a8115959a7dd76c2238e/aiohttp_socks-0.12.0.tar.gz", hash = "sha256:3caf9f5a4164611122d412bc11b2f9114fd29c85e1ba27bb38060d3c236bdc8d", size = 12061, upload-time = "2026-08-12T04:43:15.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/64/ca6289632020523ea1841f01363f83ada10eeb0f21dd931fc1118dd85668/aiohttp_socks-0.12.0-py3-none-any.whl", hash = "sha256:ba6f95ec775c761d87f8578ab48f137d0457c676da104984202bf75e747d5ee6", size = 10693, upload-time = "2026-08-12T04:43:14.524Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -1075,6 +1088,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiogram" },
{ name = "aiohttp-socks" },
{ name = "apscheduler" },
{ name = "asyncpg" },
{ name = "fastapi" },
@@ -1103,6 +1117,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiogram", specifier = ">=3.10" },
{ name = "aiohttp-socks", specifier = ">=0.8" },
{ name = "apscheduler", specifier = ">=3.10,<4" },
{ name = "asyncpg", specifier = ">=0.29" },
{ name = "fastapi", specifier = ">=0.115" },
@@ -1340,6 +1355,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "python-socks"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/ad/484ffb79532517b11a90af38647c38652224650b31a7ae1cedd5a418d8ab/python_socks-3.1.1.tar.gz", hash = "sha256:8d3e817cdbe858dc0bb8c8fdc8e79b6ce37acce110d33374c6f57a675cc9029e", size = 232781, upload-time = "2026-09-08T13:03:31.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/23/2c2cef1b4313c55d1713201acd4ee2043fb2cf22e546ca9d36bf4317faea/python_socks-3.1.1-py3-none-any.whl", hash = "sha256:327e0d6378702c73a7790bf732e9f01392f17b48c7348a50b5bd1f710c2df1be", size = 49566, upload-time = "2026-09-08T13:03:29.604Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.vite
+12
View File
@@ -0,0 +1,12 @@
# Patient web app: build with Node in a throwaway stage, serve the static bundle with nginx (+ /api → app).
# The jury only needs Docker: `make up` builds and starts it (http://localhost:8080).
FROM node:20-alpine AS build
WORKDIR /w
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /w/dist /usr/share/nginx/html
+19
View File
@@ -0,0 +1,19 @@
# Same-origin API: the app calls /api/v1/... — proxied to the FastAPI container (SSE streaming unbuffered).
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://app:8000;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 120s;
client_max_body_size 4m; # voice input: up to 60 s of 16 kHz WAV (~1.9 MB)
}
}
+73 -1
View File
@@ -4,6 +4,7 @@ import type {
CrisisResource,
DemoPersona,
DialogEvent,
Garden,
Focus,
MedsReference,
Reply,
@@ -43,15 +44,86 @@ const post = <T>(path: string, body?: unknown) =>
export const api = {
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
signInAnonymous: (focus: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
signInAnonymous: (focus?: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
// Presentation entry: a fresh private copy of the demo patient, straight to the chat.
signInDemoFresh: () => post<AuthResult>("/auth/demo/fresh", {}),
patientCapabilities: () =>
request<{ primary_screening: boolean; voice_input?: boolean }>("/patient/capabilities"),
// Voice → text for the patient to check (#117). Raw WAV body; the server only transcribes.
transcribe: async (token: string, wav: Blob): Promise<string> => {
let res: Response;
try {
res = await fetch(`${BASE}/me/${encodeURIComponent(token)}/stt`, {
method: "POST",
headers: { "Content-Type": "audio/wav" },
body: wav,
});
} catch {
throw new ApiError(0, "network");
}
if (!res.ok) throw new ApiError(res.status, res.statusText);
return ((await res.json()) as { text: string }).text;
},
connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`),
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"),
medsReference: () => request<MedsReference>("/meds/reference"),
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
garden: (token: string) => request<Garden>(`/me/${encodeURIComponent(token)}/garden`),
results: (token: string) => request<Results>(`/me/${encodeURIComponent(token)}/results`),
sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
outbox: async (token: string) =>
(await request<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/outbox`)).replies,
};
export type StreamEvent =
| { kind: "status"; text: string }
| { kind: "delta"; text: string }
| { kind: "reset" }
| { kind: "done"; replies: Reply[] };
/** POST /dialog/{token}/events/stream (Server-Sent Events): agent steps and the answer as it is written,
* then the final replies. Resolves with the final replies; throws if the stream breaks (caller falls back). */
export async function sendEventStream(
token: string,
event: DialogEvent,
onEvent: (e: StreamEvent) => void,
): Promise<Reply[]> {
const res = await fetch(`${BASE}/dialog/${token}/events/stream`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify(event),
});
if (!res.ok || !res.body) throw new ApiError(res.status, res.statusText);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let cut: number;
while ((cut = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, cut);
buf = buf.slice(cut + 2);
let kind = "message";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event:")) kind = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (!data) continue;
const payload = JSON.parse(data);
if (kind === "status") onEvent({ kind, text: String(payload) });
else if (kind === "delta") onEvent({ kind, text: String(payload) });
else if (kind === "reset") onEvent({ kind: "reset" });
else if (kind === "done") {
onEvent({ kind: "done", replies: payload as Reply[] });
return payload as Reply[];
} else if (kind === "error") throw new ApiError(500, "stream error");
}
}
throw new ApiError(0, "stream closed");
}
+15
View File
@@ -180,3 +180,18 @@ export interface MedsReference {
string
>;
}
/** GET /me/{token}/garden (#118): grows from activities, never shrinks, ignores answers. */
export type GardenActivity = "meds" | "diary" | "questionnaire";
export interface Garden {
points: number;
steps: number;
title: string;
step_points: number;
points_in_step: number;
plants: { stage: number }[];
today: { activity: GardenActivity; title: string; done: boolean; detail: string | null }[];
/** Recent acute/high alert (#120): growth stays, no toast or animation. */
quiet: boolean;
}
+1
View File
@@ -16,6 +16,7 @@ import { Icon, LogoMark, type IconName } from "./Icon";
const TABS: { to: string; label: string; icon: IconName }[] = [
{ to: "/today", label: ru.nav.today, icon: "home" },
{ to: "/messages", label: ru.nav.chat, icon: "message" },
{ to: "/meds", label: ru.nav.meds, icon: "pill" },
{ to: "/doctor", label: ru.nav.doctor, icon: "doctor" },
{ to: "/profile", label: ru.nav.profile, icon: "user" },
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { Garden as GardenData, GardenActivity } from "../api/types";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
import { Icon, type IconName } from "./Icon";
import { useToast } from "./Toast";
/*
* «Ваш сад» (#119, slide 13): every useful activity grows the garden. Without pressure — it never wilts, does not
* depend on answers, nobody competes; there are no streaks, no «missed» marks and no red. The numbers come from the
* server (#118); this only draws them.
*/
const FLOWERS = ["#d9604c", "#e9a23b", "#c96f9a", "#e07a3f", "#b9577a", "#d4a02a"];
const SLOTS = [44, 92, 140, 188, 236, 284];
const GROUND = 128;
const ICONS: Record<GardenActivity, IconName> = { meds: "pill", diary: "book", questionnaire: "clipboard" };
const seenKey = (token: string) => `ptsr.garden.steps.${token}`;
function Plant({ x, stage, color, fresh }: { x: number; stage: number; color: string; fresh: boolean }) {
if (stage <= 0) return <ellipse cx={x} cy={GROUND + 1} rx={5} ry={2} fill="#c9b08a" opacity={0.7} />;
const h = [0, 18, 38, 54, 62][Math.min(stage, 4)];
const top = GROUND - h;
return (
<g
className={fresh ? "garden__plant garden__plant--fresh" : "garden__plant"}
style={{ transformOrigin: `${x}px ${GROUND}px` }}
>
<path
d={`M${x} ${GROUND} C ${x - 2} ${GROUND - h / 2}, ${x + 2} ${top + h / 3}, ${x} ${top}`}
stroke="#4f7d5a"
strokeWidth={2.4}
fill="none"
strokeLinecap="round"
/>
<path d={`M${x} ${GROUND - h * 0.35} q -11 -3 -13 -11 q 10 0 13 9`} fill="#6f9c6a" />
{stage >= 2 && <path d={`M${x} ${GROUND - h * 0.6} q 11 -3 13 -11 q -10 0 -13 9`} fill="#6f9c6a" />}
{stage === 1 && <path d={`M${x} ${top + 2} q 8 -4 9 -10 q -8 1 -9 8`} fill="#7fae74" />}
{stage === 3 && <ellipse cx={x} cy={top - 3} rx={4.5} ry={7} fill={color} />}
{stage >= 4 && (
<g>
{[0, 72, 144, 216, 288].map((a) => (
<ellipse key={a} cx={x} cy={top - 8} rx={4.6} ry={8} fill={color} transform={`rotate(${a} ${x} ${top})`} />
))}
<circle cx={x} cy={top} r={4.2} fill="#f6d36b" />
</g>
)}
</g>
);
}
export function Garden({ refreshKey }: { refreshKey?: unknown }) {
const { session } = useSession();
const dialog = useDialog();
const toast = useToast();
const [garden, setGarden] = useState<GardenData | null>(null);
const [freshFrom, setFreshFrom] = useState<number | null>(null);
const announced = useRef(false);
useEffect(() => {
if (!session) return;
let alive = true;
api
.garden(session.token)
.then((g) => {
if (!alive) return;
setGarden(g);
const raw = window.localStorage.getItem(seenKey(session.token));
const seen = raw === null ? null : Number(raw);
// No celebration right after a crisis: growth stays, the toast and the animation do not. The server knows
// about alerts from scales and questionnaires too (`quiet`); the chat check covers a crisis reply just shown.
const recentCrisis = dialog.entries.slice(-12).some((e) => e.from === "assistant" && e.reply.kind === "crisis");
if (seen !== null && g.steps > seen && !g.quiet && !recentCrisis) {
setFreshFrom(seen);
if (!announced.current) toast(ru.garden.grew);
announced.current = true;
}
window.localStorage.setItem(seenKey(session.token), String(g.steps));
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [session, refreshKey]);
if (!garden) return null;
const t = ru.garden;
const perPlant = 4;
const bloom = garden.plants.filter((p) => p.stage >= perPlant).length;
const growing = garden.plants.filter((p) => p.stage > 0 && p.stage < perPlant).length;
const share = garden.points_in_step / garden.step_points;
return (
<section className="card garden" aria-labelledby="garden-title">
<div>
<p className="garden__eyebrow">{t.eyebrow}</p>
<h2 id="garden-title" className="h3">
{t.lead}
</h2>
</div>
<svg
className="garden__scene"
viewBox="0 0 328 150"
role="img"
aria-label={t.label(garden.title, bloom, growing)}
>
<defs>
<linearGradient id="garden-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#eef3ea" />
<stop offset="1" stopColor="#f7f3ea" />
</linearGradient>
</defs>
<rect width="328" height="150" rx="12" fill="url(#garden-sky)" />
<circle cx="282" cy="36" r="17" fill="#f2cc72" opacity={0.9} />
<path
d={`M0 ${GROUND + 4} Q 90 ${GROUND - 8} 170 ${GROUND + 2} T 328 ${GROUND - 2} V150 H0 Z`}
fill="#e5d3b0"
/>
<path
d={`M0 ${GROUND + 12} Q 110 ${GROUND + 2} 210 ${GROUND + 12} T 328 ${GROUND + 8} V150 H0 Z`}
fill="#dcc59c"
/>
{garden.plants.map((p, i) => {
// The plant that just grew (its stage covers steps after the last seen count) gets a gentle grow-in.
const fresh =
freshFrom !== null && garden.steps > freshFrom && Math.floor((garden.steps - 1) / perPlant) === i;
return (
<Plant
key={i}
x={SLOTS[i % SLOTS.length]}
stage={p.stage}
color={FLOWERS[i % FLOWERS.length]}
fresh={fresh}
/>
);
})}
</svg>
<p className="garden__title">{garden.title}</p>
<div className="garden__progress">
<div className="garden__progress-head">
<span>{t.next}</span>
<span className="muted small">{t.of(garden.points_in_step, garden.step_points)}</span>
</div>
<div
className="garden__bar"
role="progressbar"
aria-valuemin={0}
aria-valuemax={garden.step_points}
aria-valuenow={garden.points_in_step}
aria-label={t.next}
>
<span style={{ width: `${Math.round(share * 100)}%` }} />
</div>
</div>
{garden.today.length > 0 && (
<ul className="garden__today" aria-label={t.today}>
{garden.today.map((item) => (
<li key={item.activity} className="garden__item">
<span className="garden__icon" aria-hidden="true">
<Icon name={ICONS[item.activity]} size={16} />
</span>
<span className="garden__item-text">
{item.title}
{item.detail && <span className="muted small"> · {item.detail}</span>}
</span>
<span
className={item.done ? "garden__check garden__check--done" : "garden__check"}
aria-label={item.done ? "сделано" : "ещё нет"}
>
{item.done && <Icon name="check" size={14} />}
</span>
</li>
))}
</ul>
)}
<p className="garden__note">{t.noPressure}</p>
</section>
);
}
+6
View File
@@ -32,6 +32,12 @@ const paths = {
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
close: <path d="M6 6l12 12M18 6L6 18" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
mic: (
<>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21" />
</>
),
x: <path d="M7 7l10 10M17 7L7 17" />,
info: (
<>
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from "react";
import { ApiError, api } from "../api/client";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
import { MAX_SECONDS, Recorder, voiceSupported } from "../voice/recorder";
import { Icon } from "./Icon";
import { useToast } from "./Toast";
/*
* Voice input next to a free-text field (#117, slide 11: «Записать → Проверить»). The recognised text is put into
* the field for the patient to check and edit — never sent automatically: safety and scrub() run when they send it.
*/
let capability: Promise<boolean> | null = null;
function voiceEnabled(): Promise<boolean> {
capability ??= api
.patientCapabilities()
.then((c) => Boolean(c.voice_input) && voiceSupported())
.catch(() => false);
return capability;
}
const BARS = 14;
type State = "idle" | "recording" | "transcribing";
export function VoiceInput({
disabled,
onText,
compact,
}: {
disabled?: boolean;
onText: (text: string) => void;
/** Chat composer: a round icon button; the recording panel floats above the composer. */
compact?: boolean;
}) {
const { session } = useSession();
const toast = useToast();
const [enabled, setEnabled] = useState(false);
const [state, setState] = useState<State>("idle");
const [seconds, setSeconds] = useState(0);
const [levels, setLevels] = useState<number[]>(() => Array(BARS).fill(0));
const rec = useRef<Recorder | null>(null);
const timer = useRef<number>();
useEffect(() => {
let alive = true;
void voiceEnabled().then((on) => alive && setEnabled(on));
return () => {
alive = false;
window.clearInterval(timer.current);
rec.current?.cancel();
};
}, []);
if (!enabled || !session) return null;
const start = async () => {
const r = new Recorder();
try {
await r.start((level) => setLevels((prev) => [...prev.slice(1), level]));
} catch {
r.cancel();
toast(ru.voice.noMic);
return;
}
rec.current = r;
setSeconds(0);
setState("recording");
const began = Date.now();
timer.current = window.setInterval(() => {
const s = Math.floor((Date.now() - began) / 1000);
setSeconds(s);
if (s >= MAX_SECONDS) void stop();
}, 250);
};
const reset = () => {
window.clearInterval(timer.current);
rec.current = null;
setLevels(Array(BARS).fill(0));
};
const stop = async () => {
const wav = rec.current?.stop() ?? null;
reset();
if (!wav) {
setState("idle");
return;
}
setState("transcribing");
try {
const text = await api.transcribe(session.token, wav);
onText(text);
toast(ru.voice.check);
} catch (e) {
const status = e instanceof ApiError ? e.status : 0;
toast(status === 422 ? ru.voice.noSpeech : status === 413 ? ru.voice.tooLong : ru.voice.failed);
} finally {
setState("idle");
}
};
const cancel = () => {
rec.current?.cancel();
reset();
setState("idle");
};
if (state === "idle" && compact) {
return (
<button
type="button"
className="mic"
disabled={disabled}
onClick={() => void start()}
aria-label={ru.voice.start}
title={ru.voice.start}
>
<Icon name="mic" size={20} />
</button>
);
}
if (state === "idle") {
return (
<button type="button" className="voice-btn" disabled={disabled} onClick={() => void start()}>
<Icon name="mic" size={20} />
{ru.voice.start}
</button>
);
}
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, "0");
return (
<div className={compact ? "voice voice--float" : "voice"} role="group" aria-label={ru.voice.recording}>
{state === "recording" ? (
<>
<p className="voice__hint">{ru.voice.recording}</p>
<div className="voice__wave" aria-hidden="true">
{levels.map((l, i) => (
<span key={i} style={{ height: `${Math.max(12, Math.round(l * 100))}%` }} />
))}
</div>
<div className="voice__row">
<span className="voice__time" aria-live="off">
{mm}:{ss}
</span>
<button type="button" className="btn btn--ghost voice__cancel" onClick={cancel}>
{ru.voice.cancel}
</button>
<button type="button" className="btn btn--primary voice__stop" onClick={() => void stop()}>
{ru.voice.stop}
</button>
</div>
<p className="voice__privacy">{ru.voice.privacy}</p>
</>
) : (
<p className="voice__hint" aria-live="polite">
{ru.voice.transcribing}
</p>
)}
</div>
);
}
+49 -15
View File
@@ -16,6 +16,7 @@ export const ru = {
doctor: "Врач",
profile: "Профиль",
messages: "Сообщения",
chat: "Чат",
signIn: "Вход",
help: "Помощь",
},
@@ -41,6 +42,12 @@ export const ru = {
},
welcome: {
title: "Вход",
demo: {
title: "Демо: войти как Андрей",
text: "Ветеран СВО, 34 года, сертралин. Назначения и карта ЕМИАС уже подключены — сразу в чат.",
busy: "Готовим демо…",
error: "Не удалось открыть демо. Попробуйте ещё раз.",
},
emias: {
title: "Войти через ЕМИАС",
perks: [
@@ -52,7 +59,7 @@ export const ru = {
or: "или",
anonymous: {
title: "Войти анонимно",
text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.",
text: "Без имени, СНИЛС и полиса. Первичный опрос и связь с врачом — по обезличенному профилю.",
},
disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.",
},
@@ -71,14 +78,25 @@ export const ru = {
demoDay: (day: number) => `${day}-й день терапии`,
},
anonymous: {
title: "Что вы хотите проверить?",
note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.",
focus: {
depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" },
ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" },
},
title: "Помощь без личных данных",
note: "Врач видит ваши ответы и результаты под условным номером, без имени, СНИЛС и полиса. Ответы хранятся на сервере, ключ доступа к профилю — на этом устройстве.",
start: "Начать первичный опрос",
continue: "Продолжить без личных данных",
lead: "Пройдите единый первичный опрос: заранее выбирать предполагаемый диагноз не нужно.",
unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.",
submitting: "Создаём профиль…",
},
garden: {
eyebrow: "Ваш сад",
lead: "Растёт от ваших отметок",
next: "Новый росток",
of: (a: number, b: number) => `${a} из ${b}`,
today: "Сегодня",
noPressure: "Сад не вянет от пропусков. Честное «Пропущу» по лекарству тоже растит сад",
grew: "Сад подрос",
label: (title: string, bloom: number, growing: number) =>
`${title}. Цветов: ${bloom}, растёт: ${growing}.`,
},
today: {
title: "Сегодня",
takeMed: "Принять лекарство",
@@ -96,11 +114,11 @@ export const ru = {
visit: "Следующий визит к врачу",
medQuestion: "Вопрос о лекарстве",
medQuestionHint: "Ответ по официальной инструкции",
messageDoctor: "Сообщение врачу",
messageDoctorHint: "Врач ответит в рабочее время",
messageDoctor: "Связаться с врачом",
messageDoctorHint: "Запрос появится в кабинете врача",
week: "Приём на этой неделе",
weekTaken: (taken: number, total: number) => (total ? `Принято ${taken} из ${total}` : "Отметок пока нет"),
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.",
noDoctor: "Врач пока не подключён. Подключите его без личных данных, чтобы передавать ответы и получать сопровождение.",
newMessage: "Новое сообщение",
open: "Открыть",
continue: "Продолжить",
@@ -127,7 +145,7 @@ export const ru = {
tablet: "1 приём",
week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.",
empty: "Назначения появятся здесь после входа через ЕМИАС.",
empty: "Здесь появятся назначения вашего врача.",
question: "Вопрос о лекарстве",
reminderTitle: "Время принять препарат",
reminderLead: "По назначению врача",
@@ -166,11 +184,13 @@ export const ru = {
visit: "Следующий визит",
visitNone: "Дата следующего визита пока не назначена",
attending: "Ваш лечащий врач",
write: "Написать врачу",
writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.",
connect: "Подключить врача без личных данных",
connecting: "Подключаем врача…",
write: "Попросить врача связаться",
writeNote: "Запрос на связь появится в кабинете врача. Если срочно — 112.",
earlier: "Записаться к врачу раньше",
plan: "Мой план безопасности",
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.",
noDoctor: "Врач пока не подключён. Можно подключить его без имени, СНИЛС и полиса: он увидит обезличенные ответы и результаты.",
},
profile: {
title: "Профиль",
@@ -185,7 +205,7 @@ export const ru = {
revokeConfirm: "Да, отозвать",
cancel: "Отмена",
wipe: "Удалить данные с устройства",
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.",
wipeNote: "Удалятся ключ доступа и локальная копия переписки. Обезличенные ответы останутся на сервере; без ключа восстановить доступ к этому профилю не получится.",
logout: "Выйти",
messages: "Сообщения",
result: "Предварительный результат",
@@ -242,7 +262,21 @@ export const ru = {
drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.",
noticeTitle: "Опрос приостановлен",
},
voice: {
start: "Сказать голосом",
stop: "Остановить запись",
cancel: "Отменить",
recording: "Говорите — запись идёт",
transcribing: "Распознаём речь…",
check: "Проверьте распознанный текст — его можно исправить перед отправкой.",
privacy: "Запись не сохраняется: её видит только система распознавания, текст — специалисты вашей программы.",
noMic: "Нет доступа к микрофону. Разрешите его в настройках браузера или напишите текстом.",
noSpeech: "Не удалось разобрать речь. Попробуйте ещё раз или напишите текстом.",
failed: "Распознавание сейчас недоступно. Напишите, пожалуйста, текстом.",
tooLong: "Запись слишком длинная — до минуты за раз.",
},
messages: {
typing: "Помощник печатает",
title: "Сообщения",
inputLabel: "Сообщение",
inputPlaceholder: "Напишите сообщение",
+41 -4
View File
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { api } from "../api/client";
import { api, sendEventStream } from "../api/client";
import type { DialogEvent, Reply } from "../api/types";
import { useSession } from "../session/SessionContext";
import { sessionStore, type ChatEntry, type DialogSnapshot } from "../session/store";
@@ -17,8 +17,15 @@ let seq = 0;
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
const asEntries = (replies: Reply[]): ChatEntry[] => replies.map((reply) => ({ id: newId(), from: "assistant", reply }));
/** The agent at work: its steps and the answer as it is being written (cleared when the final reply lands). */
export interface LiveReply {
status: string[];
text: string;
}
interface DialogApi extends DialogSnapshot {
busy: boolean;
live: LiveReply | null;
error: boolean;
started: boolean;
/** Sends an event; `echo` is what the patient «said» (button label / text) for the history. */
@@ -36,6 +43,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
const [owner, setOwner] = useState(token);
const [snap, setSnap] = useState<DialogSnapshot>(() => (token ? sessionStore.loadDialog(token) : EMPTY));
const [busy, setBusy] = useState(false);
const [live, setLive] = useState<LiveReply | null>(null);
const [error, setError] = useState(false);
const tokenRef = useRef(token);
tokenRef.current = token;
@@ -58,7 +66,35 @@ export function DialogProvider({ children }: { children: ReactNode }) {
setBusy(true);
setError(false);
try {
const replies = await api.sendEvent(t, event);
let replies: Reply[];
if (event.type === "text") {
// Free text may go to the agent: stream its steps and the answer so the viewer sees it working.
const said = echo;
if (said) {
// Show the patient's message right away, while the agent is still working.
setSnap((prev) => ({
...prev,
entries: [...prev.entries, { id: newId(), from: "patient" as const, text: said }],
}));
echo = undefined;
}
setLive({ status: [], text: "" });
try {
replies = await sendEventStream(t, event, (e) =>
setLive((prev) => {
const cur = prev ?? { status: [], text: "" };
if (e.kind === "status") return { ...cur, status: [...cur.status, e.text] };
if (e.kind === "delta") return { ...cur, text: cur.text + e.text };
if (e.kind === "reset") return { ...cur, text: "" };
return cur;
}),
);
} catch {
replies = await api.sendEvent(t, event); // proxies without SSE support: plain request
}
} else {
replies = await api.sendEvent(t, event);
}
setSnap((prev) => ({
entries: [
...prev.entries,
@@ -73,6 +109,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
setError(true);
return false;
} finally {
setLive(null);
setBusy(false);
}
},
@@ -102,8 +139,8 @@ export function DialogProvider({ children }: { children: ReactNode }) {
}, [token]);
const value = useMemo<DialogApi>(
() => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }),
[snap, busy, error, send, start, markRead],
() => ({ ...snap, busy, live, error, started: snap.entries.length > 0, send, start, markRead }),
[snap, busy, live, error, send, start, markRead],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
+23 -30
View File
@@ -1,31 +1,35 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { Focus } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
const FOCUS: { id: Focus; icon: IconName }[] = [
{ id: "depression", icon: "smile" },
{ id: "ptsd", icon: "heart" },
];
// A3 · Анонимный вход: человек выбирает, что хочет проверить — от этого зависит стартовый опрос
// Anonymous means no identifying data, with the same screening and care path.
export function AnonymousSignIn() {
const t = ru.anonymous;
const navigate = useNavigate();
const { signIn } = useSession();
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const [screening, setScreening] = useState<boolean | null>(null);
const start = async (focus: Focus) => {
useEffect(() => {
let alive = true;
api.patientCapabilities()
.then((v) => { if (alive) setScreening(v.primary_screening); })
.catch(() => { if (alive) setError(true); });
return () => { alive = false; };
}, []);
const start = async () => {
setBusy(true);
setError(false);
try {
signIn(await api.signInAnonymous(focus));
// No self-selected diagnosis: the common intake determines the follow-up modules.
signIn(await api.signInAnonymous());
navigate("/flow", { replace: true });
} catch {
setError(true);
@@ -38,27 +42,16 @@ export function AnonymousSignIn() {
<div className="focus">
<TopBar onBack={() => navigate("/")} />
<div className="stack signin">
<span className="tile-icon tile-icon--teal"><Icon name="lock" size={26} /></span>
<h1 className="h1">{t.title}</h1>
<div className="entry-grid">
{FOCUS.map(({ id, icon }) => (
<button key={id} type="button" className="entry" onClick={() => start(id)} disabled={busy}>
<span className="entry__top">
<span className="tile-icon">
<Icon name={icon} size={26} />
</span>
</span>
<span className="entry__title">
{t.focus[id].title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.focus[id].text}</span>
</button>
))}
</div>
<p className="lead">{t.lead}</p>
<Notice>{t.note}</Notice>
{screening === false && <Notice>{t.unavailable}</Notice>}
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
<p className="muted small">{busy ? t.submitting : t.note}</p>
<button type="button" className="btn btn--primary" disabled={busy} onClick={() => void start()}>
{busy ? t.submitting : screening ? t.start : t.continue}
<Icon name="chevronRight" size={20} />
</button>
</div>
</div>
</AppShell>
+26 -2
View File
@@ -1,3 +1,6 @@
import { useState } from "react";
import { api } from "../api/client";
import { useSession } from "../session/SessionContext";
import { useNavigate } from "react-router-dom";
import { AppShell, Notice } from "../components/AppShell";
@@ -11,7 +14,22 @@ export function Doctor() {
const t = ru.doctor;
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const { plan, failed } = useToday();
const { session } = useSession();
const [connecting, setConnecting] = useState(false);
const [connectFailed, setConnectFailed] = useState(false);
const connect = async () => {
if (!session || connecting) return;
setConnecting(true);
setConnectFailed(false);
try {
await api.connectDoctor(session.token);
navigate("/today");
} catch {
setConnectFailed(true);
setConnecting(false);
}
};
const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
@@ -29,8 +47,14 @@ export function Doctor() {
<h1 className="h1">{t.title}</h1>
</header>
{plan && !plan.has_doctor ? (
{(failed || connectFailed) && <Notice tone="danger">{ru.common.networkError}</Notice>}
{!plan ? (!failed && <div className="loader" aria-label="Загрузка" />) : !plan.has_doctor ? (
<div className="stack stack--sm">
<Notice>{t.noDoctor}</Notice>
<button type="button" className="btn btn--primary" disabled={connecting} onClick={() => void connect()}>
{connecting ? t.connecting : t.connect}
</button>
</div>
) : (
<div className="cols">
<section className="card-list" aria-label={t.visit}>
+2
View File
@@ -5,6 +5,7 @@ import type { Catalog, DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { useToast } from "../components/Toast";
import { VoiceInput } from "../components/VoiceInput";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
@@ -259,6 +260,7 @@ function FreeText({
/>
<span className="textarea__count">{text.length}/500</span>
</div>
<VoiceInput disabled={busy} onText={(t) => setText((prev) => (prev.trim() ? `${prev.trim()} ${t}` : t).slice(0, 500))} />
<div className="btn-row">
{skip ? (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}>
+33 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { AppShell, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { VoiceInput } from "../components/VoiceInput";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
@@ -18,7 +19,7 @@ export function Messages() {
useEffect(() => {
dialog.markRead();
endRef.current?.scrollIntoView({ block: "end" });
}, [dialog.entries.length]);
}, [dialog.entries.length, dialog.live?.text, dialog.live?.status.length]);
const send = async (e?: FormEvent) => {
e?.preventDefault();
@@ -56,6 +57,32 @@ export function Messages() {
</li>
),
)}
{dialog.live && (
<li className="bubble bubble--bot bubble--live" aria-busy="true">
<span className="bubble__avatar">
<Icon name="smile" size={20} />
</span>
<div className="live">
{dialog.live.status.map((st, i) => (
<p key={i} className={`live__step${i === dialog.live!.status.length - 1 && !dialog.live!.text ? " is-active" : ""}`}>
{st}
</p>
))}
{dialog.live.text ? (
<p className="live__text">
{dialog.live.text}
<span className="live__cursor" aria-hidden="true" />
</p>
) : (
<p className="live__dots" aria-label={t.typing}>
<span />
<span />
<span />
</p>
)}
</div>
</li>
)}
</ol>
{quick.length > 0 && (
<div className="quickreplies">
@@ -86,6 +113,11 @@ export function Messages() {
onKeyDown={onKeyDown}
maxLength={2000}
/>
<VoiceInput
compact
disabled={dialog.busy}
onText={(text) => setDraft((prev) => (prev.trim() ? `${prev.trim()} ${text}` : text).slice(0, 2000))}
/>
<button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}>
<Icon name="send" size={20} />
</button>
+8 -3
View File
@@ -2,12 +2,12 @@ import { useNavigate } from "react-router-dom";
import type { TodayDose, TodayPrescription } from "../api/types";
import { AppShell, Notice } from "../components/AppShell";
import { Garden } from "../components/Garden";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify } from "../dialog/step";
import { useSession } from "../session/SessionContext";
import { WeekDots } from "./Meds";
import { humanDate, useToday } from "./useToday";
@@ -84,7 +84,6 @@ function WeekCard({ rx, onOpen }: { rx: TodayPrescription; onOpen: () => void })
export function Today() {
const t = ru.today;
const navigate = useNavigate();
const { session } = useSession();
const dialog = useDialog();
const { plan, failed } = useToday();
const catalog = useCatalog();
@@ -119,7 +118,7 @@ export function Today() {
!diaryDue &&
q?.intake !== "review";
const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0];
const noDoctor = session?.mode === "anonymous" || (plan !== null && !plan.has_doctor);
const noDoctor = plan !== null && !plan.has_doctor;
const now = plan?.now ?? "00:00";
const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`);
@@ -255,6 +254,7 @@ export function Today() {
</div>
)}
<Garden refreshKey={dialog.pending} />
</section>
<section className="dash__side">
@@ -292,7 +292,12 @@ export function Today() {
{plan?.prescriptions[0] && <WeekCard rx={plan.prescriptions[0]} onOpen={() => navigate("/meds")} />}
{noDoctor ? (
<div className="stack stack--sm">
<Notice>{t.noDoctor}</Notice>
<button type="button" className="btn btn--primary" onClick={() => navigate("/doctor")}>
{ru.doctor.connect}
</button>
</div>
) : (
<div className="quick">
<button
+42
View File
@@ -1,5 +1,9 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { useSession } from "../session/SessionContext";
import { telHref, useHelp } from "../components/HelpSheet";
import { Icon, LogoMark } from "../components/Icon";
import { ru } from "../content/ru";
@@ -7,10 +11,26 @@ import { ru } from "../content/ru";
// A1 · Вход: без сайдбара, одна карточка по центру. ЕМИАС — основной путь, анонимный — по желанию
export function Welcome() {
const navigate = useNavigate();
const { signIn } = useSession();
const [demoBusy, setDemoBusy] = useState(false);
const [demoError, setDemoError] = useState(false);
const { resources, open } = useHelp();
const t = ru.welcome;
const emergency = resources[0];
// Presentation: one click → a fresh private demo patient → the chat.
const demo = async () => {
setDemoBusy(true);
setDemoError(false);
try {
signIn(await api.signInDemoFresh());
navigate("/messages", { replace: true });
} catch {
setDemoError(true);
setDemoBusy(false);
}
};
return (
<div className="signin-page">
<main className="signin" id="main">
@@ -25,6 +45,28 @@ export function Welcome() {
<section className="signin__card">
<h1 className="h1">{t.title}</h1>
<button type="button" className="entry entry--emias" onClick={demo} disabled={demoBusy}>
<span className="entry__top">
<span className="tile-icon tile-icon--teal">
<Icon name="check" size={26} />
</span>
<span className="entry__title">
{demoBusy ? t.demo.busy : t.demo.title}
<Icon name="chevronRight" />
</span>
</span>
<span className="muted small">{t.demo.text}</span>
</button>
{demoError && (
<p className="muted small center" role="alert">
{t.demo.error}
</p>
)}
<div className="divider" role="separator">
<span>{t.or}</span>
</div>
<button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
<span className="entry__top">
<span className="tile-icon tile-icon--teal">
+5 -1
View File
@@ -13,7 +13,11 @@ interface SessionApi {
const Ctx = createContext<SessionApi | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(() => sessionStore.load());
// The old shared demo account ("tok-andrey") is retired: every visitor now gets a private copy.
const [session, setSession] = useState<Session | null>(() => {
const s = sessionStore.load();
return s && s.token === "tok-andrey" ? null : s;
});
const signIn = useCallback((auth: AuthResult) => {
const next: Session = { token: auth.token, mode: auth.mode, displayName: auth.display_name };
+242 -1
View File
@@ -103,7 +103,7 @@ button:disabled {
inset: auto 0 0 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
height: calc(var(--nav-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
background: var(--c-surface);
@@ -1566,6 +1566,231 @@ button.row-card:hover:not(:disabled) {
color: var(--c-text-3);
}
/* ---------------------------------------------------------------- voice input (#117, slide 11) */
.voice-btn {
display: inline-flex;
align-items: center;
gap: var(--s-2);
justify-self: start;
min-height: 44px;
padding: 0 var(--s-4);
border: 1.5px solid var(--c-border-strong);
border-radius: 999px;
background: var(--c-surface);
color: var(--c-teal-strong);
font-weight: 600;
font-size: var(--fs-14);
}
.voice-btn:hover:not(:disabled) {
background: var(--c-teal-soft);
}
.mic {
flex: none;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 1.5px solid var(--c-border);
border-radius: 50%;
background: var(--c-surface);
color: var(--c-teal-strong);
}
.mic:hover:not(:disabled) {
background: var(--c-teal-soft);
}
.voice {
display: grid;
gap: var(--s-3);
padding: var(--s-4);
border: 1px solid var(--c-border);
border-radius: var(--r-tile);
background: #fbf6ee; /* warm, as on the slide */
}
.voice--float {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + var(--s-2));
box-shadow: 0 8px 24px rgb(33 59 56 / 12%);
}
.voice__hint {
margin: 0;
font-weight: 600;
color: var(--c-text);
}
.voice__wave {
display: flex;
align-items: center;
gap: 5px;
height: 44px;
}
.voice__wave span {
flex: 1;
max-width: 6px;
border-radius: 3px;
background: #b5452f;
transition: height 90ms linear;
}
.voice__row {
display: grid;
grid-template-columns: auto 1fr 1.4fr;
align-items: center;
gap: var(--s-2);
}
.voice__time {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--c-text-2);
}
.voice__row .btn {
min-height: 44px;
padding: 0 var(--s-3);
font-size: var(--fs-14);
}
.voice__privacy {
margin: 0;
font-size: var(--fs-12);
color: var(--c-text-3);
}
/* ---------------------------------------------------------------- garden (#119, slide 13) */
.garden {
gap: var(--s-3);
}
.garden__eyebrow {
margin: 0 0 2px;
font-size: var(--fs-12);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--c-teal-strong);
}
.garden__scene {
display: block;
width: 100%;
height: auto;
max-height: 190px;
}
.garden__plant--fresh {
animation: garden-grow 1100ms var(--ease) both;
}
@keyframes garden-grow {
from {
transform: scaleY(0.2);
opacity: 0.3;
}
to {
transform: scaleY(1);
opacity: 1;
}
}
.garden__title {
margin: 0;
font-weight: 600;
color: var(--c-text);
}
.garden__progress {
display: grid;
gap: var(--s-2);
padding: var(--s-3);
border-radius: var(--r-btn);
background: var(--c-tile);
}
.garden__progress-head {
display: flex;
justify-content: space-between;
font-weight: 600;
font-size: var(--fs-14);
}
.garden__bar {
height: 8px;
border-radius: 999px;
background: var(--c-border);
overflow: hidden;
}
.garden__bar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--c-primary);
transition: width 600ms var(--ease);
}
.garden__today {
display: grid;
gap: var(--s-2);
margin: 0;
padding: 0;
list-style: none;
}
.garden__item {
display: flex;
align-items: center;
gap: var(--s-3);
font-size: var(--fs-14);
}
.garden__icon {
flex: none;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: var(--r-btn);
background: var(--c-teal-soft);
color: var(--c-teal-strong);
}
.garden__item-text {
flex: 1;
}
.garden__check {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 1.5px solid var(--c-border-strong);
border-radius: 50%;
color: #fff;
}
.garden__check--done {
border-color: var(--c-primary);
background: var(--c-primary);
}
.garden__note {
margin: 0;
text-align: center;
font-size: var(--fs-12);
color: var(--c-text-3);
}
/* ---------------------------------------------------------------- emergency */
.sosblock {
@@ -2937,3 +3162,19 @@ button.row-card:hover:not(:disabled) {
animation: none;
}
}
/* Live agent reply: steps + the answer as it is written (SSE) */
.bubble--live .live { display: grid; gap: 4px; }
.live__step { margin: 0; font-size: 13px; color: var(--muted, #6b7280); }
.live__step.is-active::after { content: ""; display: inline-block; width: 1em; animation: live-ellipsis 1.2s steps(4) infinite; overflow: hidden; vertical-align: bottom; }
.live__text { margin: 2px 0 0; white-space: pre-wrap; }
.live__cursor { display: inline-block; width: 7px; height: 1em; margin-left: 2px; background: currentColor; vertical-align: text-bottom; animation: live-blink 1s steps(2) infinite; }
.live__dots { display: inline-flex; gap: 4px; margin: 4px 0 0; }
.live__dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--muted, #9ca3af); animation: live-dot 1.2s infinite ease-in-out; }
.live__dots span:nth-child(2) { animation-delay: .15s; }
.live__dots span:nth-child(3) { animation-delay: .3s; }
@keyframes live-blink { 50% { opacity: 0; } }
@keyframes live-dot { 0%, 80%, 100% { opacity: .3; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
@keyframes live-ellipsis { from { width: 0; } to { width: 1em; } }
.tabbar__item span { font-size: 11px; }
+121
View File
@@ -0,0 +1,121 @@
/*
* Voice recording for the patient's messages (#117, slide 11): microphone → 16 kHz mono 16-bit WAV in the browser,
* so the server needs no ffmpeg and Chrome, Android and iOS Safari produce the same format (ADR-014).
* The audio lives only in memory until it is sent for transcription; nothing is stored on the device.
*/
export const TARGET_RATE = 16_000;
export const MAX_SECONDS = 60;
type WindowWithWebkit = Window & { webkitAudioContext?: typeof AudioContext };
export function voiceSupported(): boolean {
const w = window as WindowWithWebkit;
return (
typeof navigator.mediaDevices?.getUserMedia === "function" && Boolean(window.AudioContext || w.webkitAudioContext)
);
}
export class Recorder {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private node: ScriptProcessorNode | null = null;
private chunks: Float32Array[] = [];
private rate = 48_000;
/** `onLevel` gets the loudness 0..1 of each audio block (for the waveform). */
async start(onLevel: (level: number) => void): Promise<void> {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
const Ctx = window.AudioContext || (window as WindowWithWebkit).webkitAudioContext!;
this.ctx = new Ctx();
await this.ctx.resume();
this.rate = this.ctx.sampleRate;
const source = this.ctx.createMediaStreamSource(this.stream);
// ScriptProcessorNode is deprecated but works everywhere (incl. older iOS Safari) without a worklet file.
this.node = this.ctx.createScriptProcessor(4096, 1, 1);
this.node.onaudioprocess = (e) => {
const data = new Float32Array(e.inputBuffer.getChannelData(0));
this.chunks.push(data);
let sum = 0;
for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
};
source.connect(this.node);
this.node.connect(this.ctx.destination);
}
/** Stops the microphone and returns the WAV, or null if nothing was recorded. */
stop(): Blob | null {
const chunks = this.chunks;
const rate = this.rate;
this.release();
if (!chunks.length) return null;
return encodeWav(downsample(concat(chunks), rate, TARGET_RATE), TARGET_RATE);
}
cancel(): void {
this.release();
}
private release(): void {
this.node?.disconnect();
if (this.node) this.node.onaudioprocess = null;
this.stream?.getTracks().forEach((t) => t.stop());
void this.ctx?.close();
this.node = null;
this.stream = null;
this.ctx = null;
this.chunks = [];
}
}
function concat(chunks: Float32Array[]): Float32Array {
const out = new Float32Array(chunks.reduce((n, c) => n + c.length, 0));
let pos = 0;
for (const c of chunks) {
out.set(c, pos);
pos += c.length;
}
return out;
}
/** Averaging downsampler: good enough for speech, avoids aliasing better than dropping samples. */
function downsample(input: Float32Array, from: number, to: number): Float32Array {
if (from === to) return input;
const ratio = from / to;
const out = new Float32Array(Math.floor(input.length / ratio));
for (let i = 0; i < out.length; i++) {
const start = Math.floor(i * ratio);
const end = Math.min(input.length, Math.floor((i + 1) * ratio));
let sum = 0;
for (let j = start; j < end; j++) sum += input[j];
out[i] = sum / Math.max(1, end - start);
}
return out;
}
function encodeWav(samples: Float32Array, rate: number): Blob {
const buf = new ArrayBuffer(44 + samples.length * 2);
const v = new DataView(buf);
const str = (o: number, s: string) => [...s].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0)));
str(0, "RIFF");
v.setUint32(4, 36 + samples.length * 2, true);
str(8, "WAVE");
str(12, "fmt ");
v.setUint32(16, 16, true);
v.setUint16(20, 1, true); // PCM
v.setUint16(22, 1, true); // mono
v.setUint32(24, rate, true);
v.setUint32(28, rate * 2, true); // byte rate
v.setUint16(32, 2, true); // block align
v.setUint16(34, 16, true);
str(36, "data");
v.setUint32(40, samples.length * 2, true);
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
v.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return new Blob([buf], { type: "audio/wav" });
}