Compare commits

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

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

Refs #92

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:21:16 +03:00
Eleboba c09620e6d3 Merge pull request 'feat(ui): единый дизайн всех страниц по образцу кабинета врача' (#88) from feat/85-unified-design into dev
CI / ruff · pytest (push) Successful in 2m26s
Reviewed-on: #88
2026-10-04 16:20:10 +03:00
ElebobaandClaude Opus 5.5 a3180ad232 chore(devops): HTTPS for dev.meditron.tenvex.ru, meditron.tenvex.ru on HTTP until its DNS record
CI / ruff · pytest (pull_request) Successful in 2m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:19:19 +03:00
Soyavo 07652d044c Merge pull request 'feat(bot): prepare MAX notification adapter and setup handoff' (#89) from feat/83-max-bot into dev
CI / ruff · pytest (push) Successful in 2m21s
2026-10-04 16:17:18 +03:00
Soyavo ae4c76aa62 Merge pull request '[contract] Redact input values in settings validation errors' (#90) from fix/87-settings-privacy into dev
CI / ruff · pytest (push) Successful in 2m19s
2026-10-04 16:17:03 +03:00
Pavel Eliseev 7780b484f3 feat(bot): add MAX notifications adapter and operator handoff
CI / ruff · pytest (pull_request) Successful in 2m27s
Refs #83
2026-10-04 16:10:30 +03:00
Pavel Eliseev 28967c7c37 fix(contract): redact input values in settings errors
CI / ruff · pytest (pull_request) Successful in 2m22s
Refs #87
2026-10-04 16:10:30 +03:00
57 changed files with 1893 additions and 135 deletions
+55
View File
@@ -0,0 +1,55 @@
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
printf '%s\n' "${{ secrets.SERVICE_ENV }}" > ~/service.env
chmod 600 ~/service.env
- 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}"
export SERVICE_ENV_FILE="$HOME/service.env"
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
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: install up down logs seed demo-reset lint fmt test
.PHONY: install up down logs seed demo-reset lint fmt test env-sync deploy
install:
uv sync
@@ -30,3 +30,9 @@ fmt:
test:
uv run pytest -q
env-sync:
infra/deploy/env-sync.sh .env
deploy:
infra/deploy/deploy.sh origin/dev
+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
@@ -16,6 +16,7 @@ from app.api.schemas import (
PatientListItem,
SeriesPoint,
)
from app.core.safety import fill_phones
from app.integrations.base import Prescription
TZ = ZoneInfo("Europe/Moscow")
@@ -262,7 +263,7 @@ def andrey_card() -> PatientCard:
"Что помогает: прогулка с собакой, душ",
"Люди и места: брат Олег, спортзал",
"Кому звонить: жена, сослуживец Миша",
"Специалисты: д-р Смирнова, 8-800-2000-122, 112",
fill_phones("Специалисты: д-р Смирнова, {phone:helpline}, 112"),
"Безопасная среда: ружьё хранится у брата",
],
summary="За 13 дней: приверженность 12/13, эпизод намерения бросить на 5-й день (снят после звонка врача). "
+30 -7
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.
@@ -36,11 +36,13 @@ 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.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.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
from app.services import anonymous_care
log = logging.getLogger(__name__)
@@ -153,19 +155,40 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
@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()}
@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
+2 -1
View File
@@ -43,6 +43,7 @@ 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.scheduler import jobs as scheduler
from app.services.anonymous_care import display_name as anonymous_name
from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1")
@@ -66,7 +67,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
+150
View File
@@ -0,0 +1,150 @@
# Подключение будущего бота MAX
Серверная часть готова к подключению. Сам бот ещё не создан, секреты не заполнены,
реальная отправка не включена. Изменений интерфейса сайта в этой поставке нет.
Интеграция уведомляет **самого пациента**; врачебные оповещения остаются в существующем канале проекта.
## Что реализовано
- Одноразовая ссылка для привязки аккаунта MAX к текущему пациенту сайта, срок по умолчанию 10 минут.
- Подтверждение подключения, `/help`, `/stop`, отключение при остановке или удалении бота в MAX.
- Нейтральные напоминания из существующего планировщика сайта: тесты, дневник, приём лекарств и другие события outbox.
- Уведомление о завершении теста: сервер каждые примерно 30 секунд находит новые завершённые
`QuestionnaireRecord` и `ScaleResult`, начиная с момента подключения MAX. Незавершённые и старые тесты не отправляются.
- Постоянная очередь, восстановление после перезапуска, ограниченные повторы при сетевых ошибках и ответах 429/5xx.
- В сообщении только короткий текст и кнопка на сайт. Ответы, оценки, диагнозы, назначения и ключ входа на сайт
в MAX не передаются. Переписка с ботом не сохраняется и не отправляется в LLM.
## 1. Создать бота — будущему владельцу
1. Открыть [MAX для партнёров](https://business.max.ru/) и пройти регистрацию/проверку профиля.
Условия допуска зависят от типа владельца; актуальный порядок — в
[официальной инструкции подключения](https://dev.max.ru/help/platform_connection).
2. Создать чат-бота, оформить описание и пройти требуемую MAX модерацию.
3. В настройках бота получить токен и скопировать имя из его публичной ссылки `https://max.ru/ИМЯ`.
4. Передать администратору сервера имя бота, а токен — через защищённое хранилище секретов.
Токен не нужен в переписке, MR или репозитории.
## 2. Заполнить настройки сервера
Нужны доступная identity/clinical БД проекта, публичный HTTPS-адрес сайта и HTTPS-адрес API на порту 443.
Входящие POST от MAX должны доходить до API без браузерной CAPTCHA или страницы авторизации.
Прокси должен передавать заголовок `X-Max-Bot-Api-Secret` и не журналировать тело webhook/Authorization.
Дополнительные таблицы identity создаются существующим `init_db()` при запуске приложения.
В `.env` сервера (вне Git) заполнить:
```dotenv
MAX_ENABLED=false
MAX_BOT_TOKEN=
MAX_BOT_USERNAME=
MAX_WEBHOOK_SECRET=
MAX_WEBHOOK_URL=https://api.example.org/api/v1/webhooks/max
MAX_SITE_URL=https://patient.example.org/
MAX_LINK_TTL_SECONDS=600
# При необходимости — PEM-файл доверенного CA, доступный внутри контейнера:
MAX_CA_BUNDLE=
DEMO_CLOCK=false
```
- `MAX_BOT_USERNAME` — только имя, без `@`, домена или параметров ссылки.
- `MAX_WEBHOOK_SECRET` — отдельная случайная строка, не токен бота: 32–256 символов из `A–Z a–z 0–9 _ -`.
Сгенерировать локально, например `python -c 'import secrets; print(secrets.token_urlsafe(32))'`, сохранить в секреты.
- `MAX_SITE_URL` ведёт в обычный личный кабинет; не добавлять в адрес ключи доступа или данные пациента.
- Исходящие запросы идут на `https://platform-api2.max.ru`, токен передаётся заголовком `Authorization`.
Верификация TLS всегда включена. Если системе нужен корневой сертификат Минцифры, установить его по
официальной инструкции MAX и указать PEM-путь в `MAX_CA_BUNDLE`; не отключать проверку TLS.
- Переменные должны попасть именно в окружение API-контейнера/процесса, а не только панели сайта.
В текущем Compose сервис `app` уже получает `.env` через `env_file`.
После изменения окружения пересоздать процесс/контейнер API. Новые зависимости не требуются.
## 3. Проверить и включить
В каталоге проекта, в том же окружении, где работает API:
```sh
uv run python -m app.channels.max_setup check
```
Команда проверит токен и совпадение имени бота. Сообщений пользователям она не отправляет.
Затем установить `MAX_ENABLED=true`, перезапустить API и выполнить:
```sh
uv run python -m app.channels.max_setup subscribe
uv run python -m app.channels.max_setup status
```
`subscribe` регистрирует только заданный webhook с секретом и событиями
`bot_started`, `message_created`, `bot_stopped`, `dialog_removed`. Старые чужие подписки автоматически
не удаляются: при переносе сервера владелец должен проверить их отдельно через MAX API.
`status` проверяет регистрацию адреса; это ещё не проверка доступности сервера из MAX.
Команды не печатают токены, секрет webhook или сырые ответы платформы.
При запуске через Compose вместо `uv run python ...` использовать
`docker compose exec app python -m app.channels.max_setup check` (также `subscribe` и `status`).
После изменения `.env` пересоздать API: `docker compose up -d --build --force-recreate app`.
Запускать один процесс API с MAX на один токен бота: его worker отправляет до одного сообщения в секунду.
БД защищает очередь от параллельного захвата, но общий лимит скорости нескольких процессов не координируется.
Планировщик и MAX worker запускаются вместе с FastAPI, отдельный процесс бота не нужен.
При `MAX_ENABLED=false` сетевых вызовов MAX и фонового worker нет; сайт продолжает обычную работу.
## API для разработчика сайта
В проекте используется ключ текущего пациента, полученный при входе. Для этих ручек передавать его
как `Authorization: Bearer <patient_token>`; это существующая авторизация прототипа, не новая система SSO.
Не брать token другого пациента и не публиковать демонстрационные учётные записи для реального использования.
| Метод и путь | Запрос | Ответ |
|---|---|---|
| `GET /api/v1/me/notifications/max` | Authorization | `available`, `connected`, `last_delivery` |
| `POST /api/v1/me/notifications/max/link` | Authorization; JSON `{"consent": true}` | `url`, `expires_at` |
| `DELETE /api/v1/me/notifications/max` | Authorization | состояние с `connected: false` |
| `POST /api/v1/webhooks/max` | MAX JSON + `X-Max-Bot-Api-Secret` | `{"success": true}` после записи события |
Поток подключения: пациент явно соглашается на уведомления → сайт получает `url` → открывает ссылку MAX →
пользователь запускает бота → webhook подтверждает привязку → повторный GET показывает `connected: true`.
Ссылка одноразовая; новая ссылка отменяет предыдущую. Изначальный ключ пациента не входит в URL MAX.
Для привязки и доставки дополнительно нужны действующее согласие и подтверждение совершеннолетия в состоянии пациента.
Активную привязку нельзя молча переназначить другому аккаунту: сначала отключить её.
Коды ошибок: 401 — неизвестный/отсутствующий ключ пациента; 403 — нет согласия или неверный секрет webhook;
422 — неверный запрос; 413 — webhook больше 64 КиБ; 503 — MAX выключен или хранилище события недоступно.
Повтор webhook с тем же идентификатором события не создаёт новых отправок. Групповые чаты игнорируются.
`last_delivery`: `queued`, `sending`, `sent`, `failed`, `cancelled` или `null`.
`sent` означает принятие сообщения API MAX, а не прочтение человеком.
## Пробное подключение после создания бота
1. Войти на сайте синтетическим пациентом и дать необходимые согласия.
2. Получить ссылку через POST выше (Swagger `/docs` или будущий интерфейс сайта), перейти в MAX и запустить бота.
3. Убедиться, что GET показывает `connected: true` и приходит подтверждение.
4. Завершить тест на сайте; примерно через 30 секунд плюс очередь должно прийти сообщение без результатов.
5. Дождаться события планировщика и проверить нейтральное напоминание с кнопкой на сайт.
6. Отправить `/stop`: GET должен показать `connected: false`, ожидающие уведомления отменяются.
7. Повторить подключение новой ссылкой, проверить DELETE через API и остановку бота через интерфейс MAX.
При недоставке проверить настройки и подписку, доступность HTTPS, состояние очереди и коды ошибок в логах.
`http_401`/`http_403` требуют проверки токена/прав; `http_429` — ожидания; `transport` — сети/TLS;
`failed` после шести попыток требует вмешательства администратора. Детали ответов и идентификаторы в логи не выводятся.
Отключение отменяет очередь; уже принятый MAX запрос отозвать невозможно.
## Гарантии и ограничения
Очередь и подтверждения событий сохраняются в identity БД. Клинические записи остаются в clinical БД.
Для событий завершения тестов БД повторно просматривается после перезапуска; ключи уведомлений предотвращают
повторную постановку. Напоминания используют существующий механизм outbox/повторов планировщика.
При временных ошибках — до 6 попыток с увеличением задержки и учётом `Retry-After`; зависшая отправка получает
новую аренду через 60 секунд. Потеря процесса между принятием сообщения MAX и записью успеха в БД может
привести к повторному сообщению: MAX не предоставляет ключ идемпотентности отправки.
Не удалять строки дедупликации/очереди без согласованной политики хранения. При большом количестве пациентов
нужны пакетное сканирование завершённых тестов и общий ограничитель скорости; текущая реализация рассчитана на MVP.
Проверки разработчика: `make lint test`; тесты MAX работают с синтетикой и подменённым HTTP-транспортом.
Реальную доставку подтвердит только пробное подключение после выдачи токена и настройки публичного сервера.
Официальные контракты: [отправка сообщений](https://dev.max.ru/docs-api/methods/POST/messages),
[подписка webhook](https://dev.max.ru/docs-api/methods/POST/subscriptions),
[события](https://dev.max.ru/docs-api/objects/Update),
[ссылки запуска бота](https://dev.max.ru/docs/chatbots/bots-coding/prepare).
+224
View File
@@ -0,0 +1,224 @@
"""Notifications-only MAX adapter; no patient text enters the dialog engine or an LLM.
Webhook transactions persist receipts and replies together, then return immediately.
The worker delivers from identity DB with leases and bounded retries. MAX has no send
idempotency key: a crash after API acceptance and before commit can duplicate a message.
"""
import asyncio
import contextlib
import json
import logging
from functools import lru_cache
import yaml
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.channels.max_client import MaxApiError, MaxClient
from app.config import get_settings
from app.core import clock
from app.db.max_notifications import MaxBinding, MaxReceipt
from app.db.session import get_identity_session
from app.dialog.contracts import Reply
from app.integrations import max_store as store
from app.integrations.base import Outbox, integrations
log = logging.getLogger(__name__)
class Texts(BaseModel):
connected: str = Field(max_length=4000)
not_connected: str = Field(max_length=4000)
invalid: str = Field(max_length=4000)
stopped: str = Field(max_length=4000)
help: str = Field(max_length=4000)
notification: str = Field(max_length=4000)
completed: str = Field(max_length=4000)
open_site: str = Field(max_length=128)
@lru_cache
def texts() -> Texts:
path = get_settings().config_dir / "texts" / "max.yaml"
return Texts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
def _event(event: dict) -> tuple[str, int, str, str] | None:
"""Normalize only documented private-user events; unknown/malformed events are ignored."""
kind = event.get("update_type")
stamp = event.get("timestamp")
if type(stamp) is not int or stamp < 0:
return None
message_id = ""
if kind == "message_created":
message = event.get("message")
if not isinstance(message, dict):
return None
recipient, body = message.get("recipient"), message.get("body")
if not isinstance(recipient, dict) or recipient.get("chat_type") != "dialog" or not isinstance(body, dict):
return None
user, text, message_id = message.get("sender"), body.get("text"), body.get("mid")
if not isinstance(message_id, str) or not message_id or not isinstance(text, str):
return None
elif kind in ("bot_started", "bot_stopped", "dialog_removed"):
user, text = event.get("user"), event.get("payload") or ""
else:
return None
if not isinstance(user, dict) or user.get("is_bot") is not False:
return None
uid = user.get("user_id")
if type(uid) is not int or not 0 < uid < 2**63 or not isinstance(text, str) or len(text) > 4000:
return None
# Names, profile details and raw text are neither persisted nor logged.
receipt = store.digest(f"{kind}:{uid}:{message_id or stamp}")
return kind, uid, text.strip(), receipt
async def handle_update(event: dict) -> None:
if not get_settings().max_enabled or (parsed := _event(event)) is None:
return
kind, user_id, text, receipt = parsed
now = clock.now()
try:
async with get_identity_session() as session:
if await session.get(MaxReceipt, receipt):
return
session.add(MaxReceipt(digest=receipt, received_at=now))
await session.flush()
binding = await session.scalar(select(MaxBinding).where(MaxBinding.user_id == user_id))
reply_token = None
command, _, payload = text.partition(" ")
if kind in ("bot_stopped", "dialog_removed") or command.lower() == "/stop":
if binding:
await store.disconnect(session, binding.token)
# When MAX itself stops/deletes the bot, do not attempt a reply.
if kind != "message_created":
return
reply = "stopped"
elif kind == "bot_started" or command.lower() == "/start":
code = text if kind == "bot_started" else payload.strip()
if code:
token = await store.code_token(session, code, now) if len(code) <= 128 else None
linked = (
token and await store.active_patient(token) and await store.bind(session, code, user_id, now)
)
reply = "connected" if linked else "invalid"
reply_token = token if linked else None
else:
reply = "connected" if binding and binding.enabled else "not_connected"
reply_token = binding.token if binding and binding.enabled else None
else:
reply = "help"
await store.enqueue(session, key=receipt, user_id=user_id, token=reply_token, kind=reply, now=now)
except IntegrityError:
# A concurrent delivery of the same webhook can race on its receipt primary key.
async with get_identity_session() as session:
if await session.get(MaxReceipt, receipt) is None:
raise
class MaxOutbox:
def __init__(self, fallback: Outbox):
self.fallback = fallback
async def push(self, token: str, replies: list[Reply]) -> None:
if replies and get_settings().max_enabled and await store.active_patient(token):
async with get_identity_session() as session:
binding = await session.get(MaxBinding, token)
if binding and binding.enabled:
# Hash the event for retry deduplication; never store/transmit its clinical text.
event = json.dumps([r.model_dump() for r in replies], sort_keys=True, ensure_ascii=False)
key = store.digest(f"{token}:{binding.connected_at.isoformat()}:{clock.today()}:{event}")
await store.enqueue(
session, key=key, user_id=binding.user_id, token=token, kind="notification", now=clock.now()
)
await self.fallback.push(token, replies)
async def deliver_one(client: MaxClient) -> bool:
async with get_identity_session() as session:
row = await store.claim(session, clock.now())
if row is None:
return False
if row.token is not None:
async with get_identity_session() as session:
binding = await session.get(MaxBinding, row.token)
valid = (
binding
and binding.enabled
and binding.user_id == row.user_id
and binding.connected_at <= row.created_at
)
if not valid or not await store.active_patient(row.token):
async with get_identity_session() as session:
await store.finish(session, row, state="cancelled", now=clock.now())
return True
settings, messages = get_settings(), texts()
try:
await client.send(
row.user_id, getattr(messages, row.kind), site_url=settings.max_site_url, button=messages.open_site
)
except MaxApiError as exc:
retry = exc.retryable and row.attempts < store.MAX_ATTEMPTS
delay = max(exc.retry_after, min(300, 5 * 2 ** (row.attempts - 1)))
async with get_identity_session() as session:
await store.finish(
session,
row,
state="queued" if retry else "failed",
now=clock.now(),
error=exc.code,
retry_seconds=delay if retry else 0,
)
log.warning("MAX delivery deferred=%s code=%s", retry, exc.code)
else:
async with get_identity_session() as session:
await store.finish(session, row, state="sent", now=clock.now())
return True
async def _run(client: MaxClient) -> None:
tick = 0
while True:
try:
if tick % 30 == 0:
async with get_identity_session() as session:
await store.collect_completed(session, clock.now())
await deliver_one(client)
except Exception as exc:
# No exception text: SQL/HTTP errors may contain identifiers or parameters.
log.error("MAX worker failed type=%s", type(exc).__name__)
tick += 1
# One send per second per process, below MAX's two-per-second per-dialog limit.
await asyncio.sleep(1)
_client: MaxClient | None = None
_task: asyncio.Task | None = None
_wrapper: MaxOutbox | None = None
async def start() -> None:
global _client, _task, _wrapper
if not get_settings().max_enabled or _task is not None:
return
texts() # Validate templates before replacing a working outbox.
_client = MaxClient(get_settings())
_wrapper = MaxOutbox(integrations.outbox)
integrations.outbox = _wrapper
_task = asyncio.create_task(_run(_client), name="max-notifications")
async def stop() -> None:
global _client, _task, _wrapper
if _task:
_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _task
if _wrapper and integrations.outbox is _wrapper:
integrations.outbox = _wrapper.fallback
if _client:
await _client.close()
_client = _task = _wrapper = None
+101
View File
@@ -0,0 +1,101 @@
"""Small MAX Bot API client. Secrets stay in headers; failures never expose response bodies."""
import logging
import ssl
import httpx
from app.config import Settings
API_URL = "https://platform-api2.max.ru"
UPDATE_TYPES = ["bot_started", "message_created", "bot_stopped", "dialog_removed"]
class _HideRecipient(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
# httpx INFO normally logs query strings, which include a MAX user_id.
return "platform-api2.max.ru" not in record.getMessage()
logging.getLogger("httpx").addFilter(_HideRecipient())
class MaxApiError(Exception):
def __init__(self, code: str, *, retryable: bool = False, retry_after: float = 0):
self.code, self.retryable, self.retry_after = code, retryable, retry_after
super().__init__(code)
class MaxClient:
def __init__(self, settings: Settings, *, transport: httpx.AsyncBaseTransport | None = None):
context = ssl.create_default_context()
if settings.max_ca_bundle:
context.load_verify_locations(cafile=str(settings.max_ca_bundle))
self.http = httpx.AsyncClient(
base_url=API_URL,
headers={"Authorization": settings.max_bot_token.get_secret_value()},
timeout=httpx.Timeout(5),
verify=context,
follow_redirects=False,
transport=transport,
)
async def close(self) -> None:
await self.http.aclose()
async def request(self, method: str, path: str, **kwargs) -> dict:
try:
response = await self.http.request(method, path, **kwargs)
except httpx.RequestError:
raise MaxApiError("transport", retryable=True) from None
if not response.is_success:
try:
delay = min(3600, max(0, float(response.headers.get("Retry-After", "0"))))
except ValueError:
delay = 0
raise MaxApiError(
f"http_{response.status_code}",
retryable=response.status_code in (408, 429) or response.status_code >= 500,
retry_after=delay,
)
try:
data = response.json()
except ValueError:
raise MaxApiError("invalid_response", retryable=True) from None
if not isinstance(data, dict):
raise MaxApiError("invalid_response", retryable=True)
if data.get("success") is False:
raise MaxApiError("rejected")
return data
async def send(self, user_id: int, text: str, *, site_url: str, button: str) -> None:
data = await self.request(
"POST",
"/messages",
params={"user_id": user_id, "disable_link_preview": "true"},
json={
"text": text,
"notify": True,
"attachments": [
{
"type": "inline_keyboard",
"payload": {"buttons": [[{"type": "link", "text": button, "url": site_url}]]},
}
],
},
)
if not isinstance(data.get("message"), dict):
raise MaxApiError("missing_message", retryable=True)
async def subscribe(self, settings: Settings) -> None:
data = await self.request(
"POST",
"/subscriptions",
json={
"url": settings.max_webhook_url,
"update_types": UPDATE_TYPES,
"secret": settings.max_webhook_secret.get_secret_value(),
},
)
if data.get("success") is not True:
raise MaxApiError("subscription_not_confirmed")
+56
View File
@@ -0,0 +1,56 @@
"""Operator commands for a future bot owner. Never runs as part of application startup."""
import argparse
import asyncio
import sys
from pydantic import ValidationError
from app.channels.max_client import MaxApiError, MaxClient
from app.config import Settings
async def run(command: str) -> None:
# Validate the same configuration even while the deployed app is still disabled.
settings = Settings(max_enabled=True)
client = MaxClient(settings)
try:
bot = await client.request("GET", "/me")
if bot.get("is_bot") is not True:
raise MaxApiError("not_a_bot")
if bot.get("username") != settings.max_bot_username:
raise MaxApiError("bot_username_mismatch")
if command == "subscribe":
await client.subscribe(settings)
print("Webhook MAX зарегистрирован. Проверьте командой status, затем выполните пробное подключение.")
elif command == "status":
result = await client.request("GET", "/subscriptions")
subscriptions = result.get("subscriptions", [])
configured = any(isinstance(s, dict) and s.get("url") == settings.max_webhook_url for s in subscriptions)
print("Нужный webhook зарегистрирован." if configured else "Нужный webhook не найден.")
if not configured:
raise MaxApiError("webhook_missing")
else:
print("Токен принят MAX, имя бота совпадает с настройкой. Подписка не изменялась.")
finally:
await client.close()
def main() -> int:
parser = argparse.ArgumentParser(description="Подготовка MAX: параметры берутся только из .env/окружения.")
parser.add_argument("command", choices=("check", "subscribe", "status"))
args = parser.parse_args()
try:
asyncio.run(run(args.command))
except ValidationError:
print("Проверьте параметры MAX в .env по app/channels/MAX_SETUP.md.", file=sys.stderr)
return 1
except (MaxApiError, OSError) as exc:
code = exc.code if isinstance(exc, MaxApiError) else "tls_or_file_error"
print(f"Не удалось выполнить настройку MAX: {code}. Секреты и ответ сервера скрыты.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -14,7 +14,7 @@ ROOT_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
# Empty values in .env (e.g. `TG_BOT_TOKEN=`) fall back to defaults instead of failing validation.
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_ignore_empty=True)
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_ignore_empty=True, hide_input_in_errors=True)
# Two separate databases (ADR-003): identity holds direct identifiers, clinical holds pseudonymised data.
identity_db_url: str = "postgresql+asyncpg://identity_app:identity_app@localhost:5432/identity"
+4 -1
View File
@@ -13,7 +13,7 @@ from pydantic import BaseModel
from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian
from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts
from app.core.intake_questions import DIARY_PARTS, content, diary_config, red_flags, texts
from app.core.scales import load_scale
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
@@ -92,6 +92,9 @@ def catalog() -> Catalog:
),
)
flags = red_flags()
add(FormInfo(id="red_flags", title="Вопросы о безопасности", section="safety"), [i["id"] for i in flags["items"]])
cssrs = load_cssrs_russian()
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items])
+18 -4
View File
@@ -53,11 +53,25 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")}
def red_flags() -> dict:
return yaml.safe_load((get_settings().config_dir / "instruments/red_flags.yaml").read_text(encoding="utf-8"))
def red_flag_category(item_id: str) -> str | None:
return next((i["category"] for i in red_flags()["items"] if i["id"] == item_id), None)
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
"""Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
so a positive answer interrupts the questionnaire wherever it comes."""
"""Stage 1: the red-flag block first (config/instruments/red_flags.yaml), then the fixed order of «Опросники
для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, the context questions; stage 2 (rest of
PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. Items inside a scale keep the order of the source.
The risk stop rules run after every answer (flows/intake.py), so a positive answer interrupts the questionnaire
wherever it comes."""
flags = red_flags()
for n, item in enumerate(flags["items"]):
if item["id"] not in answers:
text = (flags["intro"] + "\n\n" if n == 0 else "") + item["text"]
return Question(item["id"], text, tuple((o["value"], o["label"]) for o in flags["options"]), "now")
for item in ("phq9_1", "phq9_2"):
if item not in answers:
return scale_question("phq9", item)
+2 -1
View File
@@ -12,6 +12,7 @@ import yaml
from app.config import get_settings
from app.core.intake_questions import content
from app.core.questionnaire import IntakeDecision, IntakePolicy
from app.core.safety import fill_phones
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
@@ -23,7 +24,7 @@ PCL5_IMPROVED_BELOW = 28
def texts() -> dict[str, Any]:
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))
return yaml.safe_load(fill_phones((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8")))
def render(interpretation: dict[str, Any]) -> str:
+6 -5
View File
@@ -50,7 +50,6 @@ class IntakePolicy(BaseModel):
phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
phq9_priority_at: int = Field(ge=0, le=27, strict=True)
pcl5_specialist_at: int = Field(ge=0, le=80, strict=True)
pcl5_priority_at: int = Field(ge=0, le=80, strict=True)
pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
@model_validator(mode="after")
@@ -65,8 +64,6 @@ class IntakePolicy(BaseModel):
raise ValueError("choose a total threshold OR a zone for alcohol expansion")
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
raise ValueError("PHQ-9 thresholds must be ordered")
if self.pcl5_specialist_at >= self.pcl5_priority_at:
raise ValueError("PCL-5 thresholds must be ordered")
return self
@@ -276,7 +273,9 @@ def evaluate_intake(
if c.positive:
flags.append("cssrs_positive")
flags.extend(c.conflicts)
if item9_positive or c.risk_lower_bound == "high":
# One crisis rule (ADR-013): a high C-SSRS risk. Item 9 alone is a crisis only while the screener is not complete;
# with a negative / low screener it is the «discrepancy» flag of the routing table, not the crisis screen.
if c.risk_lower_bound == "high" or (item9_positive and not c.complete):
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
@@ -327,9 +326,11 @@ def evaluate_intake(
# PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
phq_total = phq.total if phq.complete else 0
pcl_total = pcl.total if pcl.complete else 0
# Risk and flags first, scales second (clinical review 2026-10-04). A high PCL-5 alone is not a reason for a
# priority appointment: it goes to the planned specialist route.
if c.risk == "moderate":
primary = "urgent_psychiatrist"
elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at:
elif phq_total >= policy.phq9_priority_at:
primary = "priority_psychiatrist"
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
primary = "psychiatrist_or_psychotherapist"
+19
View File
@@ -395,6 +395,25 @@ def _crisis_cfg(path: Path = CRISIS_PATH) -> _CrisisCfg:
return _CrisisCfg.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
_PHONE_REF = re.compile(r"\{phone:([a-z_]+)\}")
def fill_phones(text: str, path: Path = CRISIS_PATH) -> str:
"""Replace `{phone:<resource>}` with the number from the single table in config/texts/crisis.yaml.
Every patient text and prompt refers to the table instead of repeating a number, so a number is changed in one
place. An unknown resource fails loudly: a wrong number on the crisis screen must never pass silently.
"""
resources = _crisis_cfg(path).resources
def number(m: re.Match[str]) -> str:
if m.group(1) not in resources:
raise ValueError(f"unknown crisis resource {m.group(1)!r}")
return resources[m.group(1)].phone
return _PHONE_REF.sub(number, text)
def _render(body: str, resources: list[CrisisResource], doctor_notified: str, grounding: str) -> str:
lines = [body.strip(), ""]
for r in resources:
+66 -6
View File
@@ -14,6 +14,7 @@ from app.core.intake_questions import (
diary_questions,
group_answers,
next_intake,
red_flag_category,
scale_question,
texts,
)
@@ -27,6 +28,7 @@ from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Step, on_enter, state, trigger
from app.services import questionnaire as service
from app.services.followup import schedule_post_crisis_call
def allowed(ctx):
@@ -105,6 +107,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
level=AlertLevel.ACUTE,
params={"record_id": row.id if row else None, "flags": flags},
)
await schedule_post_crisis_call(ctx.session, ctx.token, ctx.now, source="questionnaire:" + code)
step = open_crisis(ctx, categories or [], source="questionnaire:" + code)
step.data["questionnaire_record"] = None
step.data["intake_review_required"] = True
@@ -113,6 +116,41 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
return step
async def item9_discrepancy(ctx, row):
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
await service.alert(
ctx.session,
ctx.token,
row.id + ":phq9_cssrs_discrepancy",
"phq9_cssrs_discrepancy",
ctx.now,
level=AlertLevel.HIGH,
params={"record_id": row.id},
)
row.details = {**row.details, "item9_flagged": True}
async def item9_assessment(ctx, row):
"""Thoughts of death in PHQ-9 item 9 → the C-SSRS screener at once; the screener's level decides (graded)."""
await service.alert(
ctx.session,
ctx.token,
row.id + ":item9_assessment",
"phq9_item9_cssrs",
ctx.now,
level=AlertLevel.HIGH,
params={"record_id": row.id},
)
await service.finish(ctx, row, "complete", extra={"stop_reason": "phq9_item9_cssrs"})
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", row.id + ":cssrs", ctx.now)
return await start(ctx, "cssrs_since_contact", task)
async def manual_review(ctx, row, code):
await service.finish(ctx, row, "review_required", extra={"stop_reason": code})
await service.alert(
@@ -135,9 +173,24 @@ async def advance(ctx, row):
a = row.answers
c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy()
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high":
# 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:
category = red_flag_category(item_id)
return await urgent(ctx, row, code="red_flag_" + item_id[3:], categories=[category] if category else [])
# One crisis rule for the whole app (ADR-013): the crisis screen opens at a HIGH C-SSRS risk only. Moderate →
# stop + psychiatrist (about 24 h); low → flag, safety plan, continue.
if c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal")
if a.get("dphq_9", 0) > 0 and row.kind in DIARY_KINDS:
# Diary depression module, item 9 ≥1 → the C-SSRS right away («Этап 2»); its risk level decides.
return await item9_assessment(ctx, row)
if a.get("phq9_9", 0) > 0 and not c.complete:
# Item 9 without a completed screener (only in a scheduled PHQ-9): assess the risk right away.
return await item9_assessment(ctx, row)
if a.get("phq9_9", 0) > 0 and not row.details.get("item9_flagged"):
# Item 9 ≥1 with a negative / low C-SSRS = discrepancy (routing table): flag to the doctor, safety plan.
await item9_discrepancy(ctx, row)
if c.positive and policy.cssrs_positive_policy == "stop_any":
return await urgent(ctx, row, code="cssrs_positive")
if c.conflicts:
@@ -165,7 +218,7 @@ async def advance(ctx, row):
return await manual_review(ctx, row, "answer_conflict")
if q:
replies = [question_reply(row, q)]
if c.complete and c.risk == "low" and not row.details.get("plan_shown"):
if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"):
from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx))
@@ -225,11 +278,12 @@ async def advance(ctx, row):
if row.kind in ("phq9", "pcl5"):
sid = row.kind
total = extra["totals"][sid]["total"]
priority = getattr(policy, sid + "_priority_at")
# Only PHQ-9 has a priority threshold; PCL-5 alone never makes an appointment a priority.
priority = getattr(policy, sid + "_priority_at", None)
specialist = getattr(policy, sid + "_specialist_at")
extra["route"] = (
"priority_psychiatrist"
if total >= priority
if priority is not None and total >= priority
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
)
earlier = [
@@ -284,7 +338,13 @@ async def advance(ctx, row):
return step
from app.dialog.flows.core import menu_reply
return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None})
replies = [menu_reply(ctx, shown)]
if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"):
# The safety plan is never skipped because the risk signal came with the last question.
from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx))
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
# Diary flags that bring a full scale forward («Этап 2», p.4).
+3 -1
View File
@@ -7,6 +7,7 @@ import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
from app.core.safety import fill_phones
class _Strict(BaseModel):
@@ -91,6 +92,7 @@ class ReachOutTexts(_Strict):
silence: str
missed_doses: str
default: str
after_crisis: str
class VisitTexts(_Strict):
@@ -120,4 +122,4 @@ def dialog_texts_path() -> Path:
@lru_cache
def load_dialog_texts(path: Path | None = None) -> DialogTexts:
path = path or dialog_texts_path()
return DialogTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
return DialogTexts.model_validate(yaml.safe_load(fill_phones(path.read_text(encoding="utf-8"))))
+7 -2
View File
@@ -1,6 +1,8 @@
"""System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools."""
SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
from app.core.safety import fill_phones
_SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах.
Как работать:
@@ -32,4 +34,7 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия 8-800-2000-122, и что врач будет уведомлён."""
доверия {phone:helpline}, и что врач будет уведомлён."""
# Numbers come from the single table config/texts/crisis.yaml.
SYSTEM_PROMPT = fill_phones(_SYSTEM_PROMPT)
+12 -1
View File
@@ -22,11 +22,13 @@ from app.db import repo
from app.db.clinical import PatientProfile
from app.db.intake import QuestionnaireTask
from app.db.session import get_clinical_session
from app.dialog.contracts import Event, Reply, get_engine
from app.dialog.contracts import Button, Event, Reply, get_engine
from app.dialog.texts import load_dialog_texts
from app.integrations.base import integrations
from app.scheduler import demo
from app.services import monitoring
from app.services import questionnaire as service
from app.services.followup import next_day_check_due
log = logging.getLogger(__name__)
_worker = None
@@ -43,6 +45,15 @@ async def plan():
state = await repo.get_dialog_state(session, profile.token)
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
continue
crisis = state.data.get("crisis")
if next_day_check_due(crisis, now):
# The day after a crisis the app asks how the person is, the safety plan one tap away.
texts = load_dialog_texts()
plan_button = Button(label=texts.safety_plan.title.rstrip(":"), data="safety_plan")
await integrations.outbox.push(
profile.token, [Reply(text=texts.reach_out.after_crisis, buttons=[[plan_button]], kind="question")]
)
state.data = {**state.data, "crisis": {**crisis, "followup_sent": now.isoformat()}}
rows = await service.records(session, profile.token)
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
if initial is None:
+24
View File
@@ -0,0 +1,24 @@
"""Pseudonymous care routing; no identity fields or changes to clinical release gates."""
from hashlib import sha256
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
class AnonymousCare(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
doctor_id: str = Field(min_length=1, max_length=64)
def doctor_id() -> str:
path = get_settings().config_dir / "anonymous_care.yaml"
return AnonymousCare.model_validate(yaml.safe_load(path.read_text())).doctor_id
def display_name(token: str) -> str:
# Do not use a piece of the bearer token itself as the public patient label.
label = sha256(("anonymous-patient:" + token).encode()).hexdigest()[:10].upper()
return f"Пациент {label}"
+43
View File
@@ -0,0 +1,43 @@
"""Follow-up after a crisis (clinical review 2026-10-04: «план безопасности и звонок после кризиса — обязательно»).
Every crisis — from the safety lexicon, a rule (PHQ-9 item 9, C-SSRS high) or the questionnaire (red flags, high
risk) — leaves a task for the doctor on duty: call the patient back, about 24 hours as a guide. The task is an alert
on the dashboard; «позвонили» is its acknowledgement (`repo.ack_alert`, audited). One open task per patient: a
repeated crisis signal does not pile up callbacks. On the next day the app itself asks how the person is, with the
safety plan at hand (`app/scheduler/jobs.py`).
"""
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.types import AlertLevel
from app.db import repo
from app.db.clinical import Alert
POST_CRISIS_CALL = "post_crisis_call"
ON_DUTY = "on-duty"
CALL_WITHIN = timedelta(hours=24) # a guide, not a guarantee
NEXT_DAY_CHECK_AFTER = timedelta(hours=20)
async def schedule_post_crisis_call(session: AsyncSession, token: str, now: datetime, *, source: str) -> Alert | None:
if POST_CRISIS_CALL in await repo.open_alert_codes(session, token):
return None
return await repo.add_alert(
session,
token=token,
level=AlertLevel.HIGH,
code=POST_CRISIS_CALL,
reason="Перезвонить пациенту после кризиса — дежурный специалист, ориентир 24 ч. Отметка «позвонили» — "
"подтверждение этого сигнала.",
ts=now,
params={"due_at": (now + CALL_WITHIN).isoformat(), "assignee": ON_DUTY, "source": source},
)
def next_day_check_due(crisis: dict | None, now: datetime) -> bool:
"""The day after a crisis the app asks how the person is — once per crisis episode."""
if not crisis or not crisis.get("opened_at") or crisis.get("followup_sent"):
return False
return now - datetime.fromisoformat(crisis["opened_at"]) >= NEXT_DAY_CHECK_AFTER
+2
View File
@@ -30,6 +30,7 @@ from app.db.audit import audit
from app.db.session import get_clinical_session
from app.integrations.base import integrations
from app.services.contracts import register_monitoring
from app.services.followup import schedule_post_crisis_call
from app.services.snapshot import build_snapshot
log = logging.getLogger(__name__)
@@ -93,6 +94,7 @@ class RuleMonitoring:
ts=now,
)
notify.append((ON_DUTY_DOCTOR, level, action.reason_ru))
await schedule_post_crisis_call(s, token, now, source=f"rule:{action.code}")
await refresh_status(s, token)
log.info("monitoring token=%s actions=%s", token, [a.code for a in actions])
for doctor_id, level, text in notify:
+3
View File
@@ -0,0 +1,3 @@
# Routing for the current prototype: anonymous patients use the same doctor workspace,
# without an identity record. Set this to the responsible clinic's doctor when deploying.
doctor_id: doc-smirnova
+25
View File
@@ -0,0 +1,25 @@
# Red flags — asked first in stage 1, before any scale (clinical review 2026-10-04: «обязательно»).
# Any «Да» stops the questionnaire: an acute alert to the doctor, a callback task for the doctor on duty and the
# help screen (crisis script variant by `category`, config/texts/crisis.yaml). Wording — team draft, the doctor
# approves it (#8). These are not a scale: there is no score, only a stop rule.
version: red-flags-v1-2026-10-04
intro: "Сначала несколько коротких вопросов о безопасности. Отвечайте так, как есть сейчас."
options:
- {value: 1, label: Да}
- {value: 0, label: Нет}
items:
- id: rf_psychosis
text: Слышите ли Вы сейчас голоса или видите то, чего не видят и не слышат другие люди?
category: null
- id: rf_harm_others
text: Есть ли у Вас сейчас мысли причинить вред другому человеку?
category: harm_others
- id: rf_weapons
text: Есть ли у Вас дома или рядом доступ к оружию?
category: null
- id: rf_violence
text: Угрожает ли Вам сейчас кто-то дома или применяет к Вам насилие?
category: domestic_violence
- id: rf_intoxication
text: Находитесь ли Вы сейчас в состоянии сильного опьянения или тяжёлой отмены алкоголя либо других веществ?
category: null
+2 -2
View File
@@ -1,7 +1,8 @@
# Review candidate from the supplied MVP PDF, not a signed clinical protocol.
version: mvp-v2-review-2026-10-03
approval: null
cssrs_positive_policy: pending
# C01 decided by the team after the clinical review (2026-10-04, ADR-013): crisis screen at HIGH risk only.
cssrs_positive_policy: graded
rus_audit_s_expand_from_zone: null
phq2_expand_at: 2
tsq_expand_at: 6
@@ -9,5 +10,4 @@ phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3
-1
View File
@@ -17,5 +17,4 @@ phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3
+5 -3
View File
@@ -62,7 +62,7 @@ rules:
params: {}
actions:
- {type: alert, level: high}
reason_ru: "C-SSRS: умеренный риск, врачу в течение 24 ч"
reason_ru: "C-SSRS: умеренный риск, психиатр — ориентир 24 ч"
# ---------------------------------------------------------------- scale thresholds (yellow)
- id: phq9_severe
@@ -73,10 +73,12 @@ rules:
reason_ru: "PHQ-9 = {total} (тяжёлая депрессия, порог {threshold})"
- id: pcl5_severe
description: Latest PCL-5 total >= 50 → doctor within 24 h.
description: >
Latest PCL-5 total >= 50 → planned specialist review. Not a priority appointment by itself: risk and flags
come first, scales second (clinical review 2026-10-04).
params: {threshold: 50}
actions:
- {type: alert, level: high}
- {type: alert, level: moderate}
reason_ru: "PCL-5 = {total} (порог {threshold})"
- id: phq9_rise
+7
View File
@@ -1,4 +1,6 @@
# Кризисный скрипт (guardrail №2, issue #4). Отправляется ДОСЛОВНО при safety = acute; LLM не вызывается.
# `resources` — ЕДИНСТВЕННАЯ таблица телефонов приложения: остальные тексты и промпты ссылаются на неё через
# `{phone:<ключ>}` (app/core/safety.py::fill_phones). Источники и проверка — docs/clinical/crisis-contacts.md.
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Выбор варианта (app/core/safety.py::crisis_reply): `<audience>.<категория>` по порядку category_priority,
@@ -17,6 +19,11 @@ resources:
name: "Психологическая помощь МЧС России"
phone: "+7 (495) 989-50-50"
note: "круглосуточно; стоимость звонка по тарифу оператора"
# For minors only (age gate stop text); not a general adult crisis line (docs/clinical/crisis-contacts.md).
child_helpline:
name: "Детский телефон доверия"
phone: "8-800-2000-122"
note: "для детей, подростков и родителей; бесплатно, анонимно"
police:
name: "Полиция"
phone: "102"
+7 -5
View File
@@ -53,7 +53,7 @@ age_gate:
unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990."
minor_stop: |-
Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия 8-800-2000-122 (бесплатно, анонимно, круглосуточно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия {phone:child_helpline} (бесплатно, анонимно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если есть угроза жизни прямо сейчас — звоните 112.
idle:
@@ -79,9 +79,9 @@ revoked:
crisis:
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:"
repeat: "Я рядом. Если вам угрожает опасность прямо сейчас — позвоните:"
# Любое другое сообщение, пока кризис открыт (до подтверждения врача).
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50."
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}."
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
@@ -101,7 +101,7 @@ safety_plan:
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
- "Люди и места, которые отвлекают: к кому можно пойти."
- "Кому позвонить, если тяжело: близкий человек, сослуживец."
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112."
- "Специалисты: ваш врач, телефон доверия {phone:helpline}, экстренные службы 112."
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
@@ -109,10 +109,12 @@ reach_out:
silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке."
missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу."
default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно."
# The day after a crisis (app/services/followup.py); the doctor on duty also calls back.
after_crisis: "Как вы сегодня? Вчера было очень тяжело — мне важно знать, что вы в порядке. Ваш план безопасности под кнопкой ниже. С вами также свяжется дежурный специалист. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}."
visit:
button: "Записаться к врачу раньше"
unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия {phone:helpline}.
+8
View File
@@ -0,0 +1,8 @@
connected: "Уведомления подключены. Здесь будут появляться напоминания с сайта. Отключить их можно командой /stop."
not_connected: "Чтобы получать уведомления, откройте сайт и подключите MAX в настройках уведомлений."
invalid: "Не удалось подключить уведомления. Создайте новую ссылку на сайте. Если MAX уже подключён к другому аккаунту, сначала отключите прежнюю привязку."
stopped: "Уведомления отключены. Подключить их снова можно по новой ссылке с сайта."
help: "Этот бот присылает уведомления. Пройти тесты, посмотреть результаты и продолжить общение можно на сайте. Команда /stop отключает уведомления."
notification: "На сайте есть напоминание для вас. Откройте личный кабинет, чтобы продолжить."
completed: "Прохождение теста завершено. Подробности доступны в личном кабинете на сайте."
open_site: "Открыть сайт"
+2 -2
View File
@@ -32,13 +32,13 @@ findings:
text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом."
suicide_low:
title: "Тяжёлые мысли"
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия."
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия {phone:helpline}."
tbi:
title: "Контузия или ЧМТ"
text: "Рекомендуем консультацию невролога."
routes:
urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра."
urgent_psychiatrist: "Срочно свяжитесь с психиатром — ориентир: в течение 24 часов."
priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни."
psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту."
psychologist: "Рекомендуем консультацию психолога."
+11
View File
@@ -3,6 +3,17 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
## 2026-10-04 — ADR-013: Правки по клиническому ревью — красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров
**Контекст:** ревью опросников: обязательны блок красных флагов и план безопасности со звонком после кризиса; маршрутизация должна сначала смотреть на риск и флаги, потом на шкалы; в документе два противоречащих правила кризисного экрана («любое Да» против «только высокий риск»); кризисные номера разбросаны по текстам (и чат-агент давал взрослым детский телефон доверия).
**Решение:**
1. Красные флаги (`config/instruments/red_flags.yaml`) идут первыми в этапе 1: голоса/видения, мысли причинить вред другому, доступ к оружию, насилие дома, сильное опьянение или отмена. Любое «Да» останавливает опрос: острый алерт врачу и экран помощи нужного варианта (`harm_others`, `domestic_violence`).
2. Одно правило кризиса во всём приложении: кризисный экран открывается только при **высоком** риске C-SSRS. Умеренный риск — стоп опроса и психиатр, «ориентир 24 ч»; низкий — флаг врачу, план безопасности, опрос продолжается. Пункт 9 PHQ-9 при отрицательном C-SSRS — флаг «расхождение» и план безопасности; без пройденного C-SSRS (плановый PHQ-9, дневник) — сразу C-SSRS, решает его уровень. `config/questionnaire.yaml`: `cssrs_positive_policy: graded` (C01).
3. После любого кризиса дежурному создаётся задача «перезвонить пациенту» (алерт `post_crisis_call`, ориентир 24 ч; «позвонили» — подтверждение алерта), одна на пациента, пока открыта. На следующий день приложение само спрашивает «как вы» с кнопкой плана безопасности (`app/services/followup.py`).
4. Маршрут: риск и флаги → шкалы. PCL-5 ≥50 убран из приоритетной записи (и в правилах дашборда `pcl5_severe` — уровень moderate): высокий PCL-5 ведёт к плановой записи к специалисту.
5. Все телефоны — только в `config/texts/crisis.yaml`; остальные тексты и промпт агента ссылаются через `{phone:<ключ>}`, неизвестный ключ роняет загрузку, тест не пускает «голые» номера в код и тексты.
**Причина:** врач назвал пункты 1–2 обязательными; одно правило кризиса исключает расхождение демо с документом; неверный номер на кризисном экране недопустим.
**Последствия:** формулировки красных флагов — черновик команды, утверждает врач (#8). Старое правило дашборда `phq9_item9` (ADR-006, сценарий без опросника) по-прежнему поднимает кризис по п.9 — свести при следующем ревью правил.
## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату
**Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста».
**Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется.
+1 -1
View File
@@ -160,7 +160,7 @@ q6 за всю жизнь всем; при положительном q6 уто
| ID | Решение, которое нужно зафиксировать | Статус |
|---|---|---|
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending |
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | **решено 2026-10-04 (ADR-013):** кризисный экран только при высоком риске; умеренный — стоп и психиатр (ориентир 24 ч); низкий — флаг, план безопасности, продолжение. `cssrs_positive_policy: graded`. Подпись врача — в #8. |
| C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending |
| C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending |
| C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | pending |
+3
View File
@@ -1,5 +1,8 @@
# Проверка контактов: 3 октября 2026
> **Единая таблица номеров приложения — `config/texts/crisis.yaml` (`resources`).** Остальные тексты и промпт
> чат-агента ссылаются на неё через `{phone:<ключ>}` (ADR-013); номер меняется в одном месте.
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
или уведомлений службам/врачу не выполнялось.
+26 -6
View File
@@ -1,24 +1,44 @@
#!/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"
# Service variables managed via the SERVICE_ENV secret (infra/deploy/env-sync.sh) — merged before the restart.
if [ -n "${SERVICE_ENV_FILE:-}" ] && [ -s "$SERVICE_ENV_FILE" ]; then
# shellcheck disable=SC2086
retry scp -q $OPTS "$SERVICE_ENV_FILE" "$HOST:$DIR/service.env"
fi
# 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 app.new/infra/deploy/env_merge.py env_merge.py
if [ -s service.env ]; then rc=0; python3 env_merge.py .env service.env || rc=\$?; rm -f service.env; [ "\$rc" = 0 ] || [ "\$rc" = 3 ]; fi
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 "api ok @ $SHA"
SH
+1 -1
View File
@@ -23,7 +23,7 @@ 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
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Sync service variables from the local .env to the server — now and for every future deploy.
# make env-sync (or infra/deploy/env-sync.sh [path/to/.env])
# 1. Filters the local env: drops GITEA_*, empty values and server-managed keys (DB passwords, PSEUDO_KEY, URLs).
# 2. Stores the result as the Gitea Actions secret SERVICE_ENV → CD merges it into /srv/ptsr/.env on each deploy.
# 3. Applies it on the server right away and recreates app + dashboard if anything changed.
# Values are never printed; only key names.
set -euo pipefail
SRC=${1:-.env}
HOST=${DEPLOY_HOST:-vobot}
OPTS=${DEPLOY_SSH_OPTS:-}
DIR=/srv/ptsr
HERE=$(cd "$(dirname "$0")" && pwd)
[ -f "$SRC" ] || { echo "no $SRC"; exit 1; }
# Values without inline comments / trailing spaces ("KEY=value # коммент" → "value").
val() { grep -E "^$1=" "$SRC" | tail -1 | cut -d= -f2- | sed -E 's/[[:space:]]+#.*$//; s/[[:space:]]+$//' || true; }
GITEA_URL=$(val GITEA_URL)
GITEA_REPO=$(val GITEA_REPO)
GITEA_TOKEN=$(val GITEA_TOKEN)
export GITEA_URL GITEA_REPO GITEA_TOKEN
OUT=$(mktemp)
trap 'rm -f "$OUT"' EXIT
sed -E 's/[[:space:]]+#.*$//; s/[[:space:]]+$//' "$SRC" \
| grep -E '^[A-Z_][A-Z0-9_]*=.+' \
| grep -vE '^(GITEA_[A-Z_]*|POSTGRES_PASSWORD|IDENTITY_DB_PASSWORD|CLINICAL_DB_PASSWORD|PSEUDO_KEY|PUBLIC_URL|IDENTITY_DB_URL|CLINICAL_DB_URL|API_BASE_URL)=' \
> "$OUT" || true
echo "keys: $(cut -d= -f1 "$OUT" | tr '\n' ' ')"
[ -s "$OUT" ] || { echo "nothing to sync"; exit 0; }
if [ -n "$GITEA_TOKEN" ] && [ -n "$GITEA_URL" ] && [ -n "$GITEA_REPO" ]; then
python3 - "$OUT" <<'PY'
import json, os, sys, urllib.request
url = f"{os.environ['GITEA_URL']}/api/v1/repos/{os.environ['GITEA_REPO']}/actions/secrets/SERVICE_ENV"
body = json.dumps({"data": open(sys.argv[1]).read()}).encode()
req = urllib.request.Request(url, method="PUT", data=body,
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}",
"Content-Type": "application/json"})
print("gitea secret SERVICE_ENV:", urllib.request.urlopen(req).status)
PY
else
echo "GITEA_* not set in $SRC — skipping the CD secret (server-only sync)"
fi
retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; sleep $((n * 10)); done; }
# shellcheck disable=SC2086
retry scp -q $OPTS "$OUT" "$HOST:$DIR/service.env"
# shellcheck disable=SC2086
retry scp -q $OPTS "$HERE/env_merge.py" "$HOST:$DIR/env_merge.py"
# shellcheck disable=SC2086
retry ssh $OPTS "$HOST" "bash -s" <<SH
set -euo pipefail
cd $DIR
rc=0; python3 env_merge.py .env service.env || rc=\$?
rm -f service.env
if [ "\$rc" = 0 ]; then
cp .env app/.env
cd app && docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --force-recreate app dashboard </dev/null 2>&1 | tail -2
for i in \$(seq 1 30); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 2; done
curl -sf 127.0.0.1:8010/health >/dev/null && echo "api ok"
elif [ "\$rc" != 3 ]; then
exit "\$rc"
fi
SH
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Merge service variables into the server env file (runs on the server, stdlib only).
env_merge.py TARGET SOURCE
Keys from SOURCE overwrite / add to TARGET; keys only in TARGET stay (server-generated secrets: DB passwords,
PSEUDO_KEY, PUBLIC_URL). Protected keys are never taken from SOURCE. Prints changed key NAMES only (no values).
Exit code: 0 = changed, 3 = nothing changed.
"""
import os
import sys
import tempfile
PROTECTED = {
"POSTGRES_PASSWORD",
"IDENTITY_DB_PASSWORD",
"CLINICAL_DB_PASSWORD",
"PSEUDO_KEY",
"PUBLIC_URL",
"IDENTITY_DB_URL",
"CLINICAL_DB_URL",
"API_BASE_URL",
}
def parse(path: str) -> list[tuple[str | None, str]]:
"""Lines as (key, raw_line); comments/blank lines have key None. Keeps order and comments."""
out: list[tuple[str | None, str]] = []
with open(path, encoding="utf-8") as f:
for raw in f.read().splitlines():
line = raw.strip()
key = line.split("=", 1)[0].strip() if line and not line.startswith("#") and "=" in line else None
out.append((key, raw))
return out
def merge(target: str, source: str) -> list[str]:
lines = parse(target)
incoming = {
k: raw
for k, raw in parse(source)
if k and k not in PROTECTED and not k.startswith("GITEA_") and raw.split("=", 1)[1].strip()
}
changed: list[str] = []
seen: set[str] = set()
for i, (k, raw) in enumerate(lines):
if k in incoming:
seen.add(k)
if raw != incoming[k]:
lines[i] = (k, incoming[k])
changed.append(k)
for k, raw in incoming.items():
if k not in seen:
lines.append((k, raw))
changed.append(k)
if changed:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(target)))
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write("\n".join(raw for _, raw in lines) + "\n")
st = os.stat(target)
os.chmod(tmp, 0o600)
if os.geteuid() == 0:
os.chown(tmp, st.st_uid, st.st_gid)
os.replace(tmp, target)
return changed
def main() -> int:
changed = merge(sys.argv[1], sys.argv[2])
if not changed:
print("env: no changes")
return 3
print("env: updated " + ", ".join(sorted(changed)))
return 0
if __name__ == "__main__":
sys.exit(main())
+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;
+74 -9
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
@@ -122,7 +123,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:
@@ -169,8 +170,8 @@ async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeyp
await press()
await press("consent:yes")
buttons = await press("age:adult")
# Stage 1 starts with PHQ-2, in the order of the MVP questionnaire.
assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons)
# Stage 1 starts with the red-flag block, then the order of the MVP questionnaire.
assert any(b.startswith("iq:") and ":rf_psychosis:" in b for b in buttons)
assert "intake:help" in buttons and "intake:pause" in buttons
q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"]
assert q["enabled"] and q["intake"] == "active"
@@ -214,9 +215,73 @@ async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch
assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"]
await press()
await press("consent:yes")
assert any(":phq9_1:" in b for b in await press("age:adult"))
assert any(":rf_psychosis:" in b for b in await press("age:adult"))
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
body = (await client.get("/api/v1/patient/demo-personas")).json()
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
async def test_anonymous_is_visible_only_to_assigned_doctor(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()["token"]
second = (await client.post("/api/v1/auth/anonymous")).json()["token"]
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
names = {p["token"]: p["display_name"] for p in items}
assert names[first].startswith("Пациент ") and names[first] != names[second]
assert first not in names[first]
own = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": anonymous_doctor()})
assert own.status_code == 200 and own.json()["patient"]["display_name"] == names[first]
denied = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": "another-doctor"})
assert denied.status_code == 403
assert (await client.get("/api/v1/doctors/another-doctor/patients")).json() == []
async def test_old_anonymous_profile_requires_explicit_care_opt_in(client: AsyncClient) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
# Merely opening Today must not reveal an old private profile to a doctor.
assert not (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["has_doctor"]
assert (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json() == []
for _ in range(2):
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).json() == {"has_doctor": True}
async with session.get_clinical_session() as s:
p = await repo.get_profile(s, TOKEN)
assert p.doctor_id == anonymous_doctor() and p.modules == ["veteran"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, TOKEN) is None
async def test_connect_care_does_not_reassign_or_create_patients(client: AsyncClient) -> None:
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 404
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="own-doctor")
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 200
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "own-doctor"
async def test_screening_capability_respects_release_gate(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": False}
monkeypatch.setenv("INTAKE_DEMO", "1")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": 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
+21
View File
@@ -0,0 +1,21 @@
"""Startup validation errors must not print configuration credentials."""
import pytest
from pydantic import ValidationError
from app.config import Settings
def test_incomplete_max_configuration_does_not_expose_token():
with pytest.raises(ValidationError) as error:
Settings(_env_file=None, max_enabled=True, max_bot_token="SYNTHETIC_SECRET")
message = str(error.value)
assert "MAX_BOT_USERNAME" in message
assert "SYNTHETIC_SECRET" not in message and "input_value" not in message
def test_invalid_field_does_not_expose_accidentally_pasted_secret():
with pytest.raises(ValidationError) as error:
Settings(_env_file=None, max_enabled="SYNTHETIC_SECRET")
assert "max_enabled" in str(error.value)
assert "SYNTHETIC_SECRET" not in str(error.value)
+31
View File
@@ -0,0 +1,31 @@
"""Crisis numbers live in one table (config/texts/crisis.yaml); every other text refers to it by `{phone:<key>}`."""
import re
from pathlib import Path
import pytest
from app.core.safety import CRISIS_PATH, fill_phones
from app.llm.agent.prompts import SYSTEM_PROMPT
ROOT = Path(__file__).resolve().parents[1]
# Any phone number except the universal short emergency numbers 112 / 101–103.
NUMBER = re.compile(r"(\+7|\b8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}")
def test_no_number_outside_the_crisis_table():
texts = [p for p in (ROOT / "config" / "texts").glob("*.yaml") if p != CRISIS_PATH]
code = [*(ROOT / "app").rglob("*.py"), *(ROOT / "web" / "src").rglob("*.ts*")]
offenders = [
str(p.relative_to(ROOT))
for p in [*texts, *code]
if p.name != "privacy.py" # the PII scrubber documents the phone format with an example
and NUMBER.search("\n".join(line for line in p.read_text(encoding="utf-8").splitlines() if "#" not in line))
]
assert offenders == []
def test_references_resolve_and_unknown_keys_fail():
assert "989-50-50" in fill_phones("{phone:helpline}") and "989-50-50" in SYSTEM_PROMPT
with pytest.raises(ValueError):
fill_phones("{phone:no_such_line}")
+3 -1
View File
@@ -482,4 +482,6 @@ def test_dialog_texts_avoid_forbidden_words() -> None:
body = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#"))
for word in ("учёт", "учет", "контрол", "надзор", "диагноз:"):
assert word not in body
assert "152-фз" in body and "8-800-2000-122" in body
assert "152-фз" in body
# Numbers live in one table (config/texts/crisis.yaml); the minors' stop text gets the children's line from it.
assert "8-800-2000-122" not in body and "8-800-2000-122" in load_dialog_texts().age_gate.minor_stop
+68 -11
View File
@@ -13,6 +13,7 @@ from app.core import clock
from app.core.scales import clear_cache
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Alert
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Event
from app.dialog.engine import Engine
@@ -76,7 +77,8 @@ def answer_button(replies, choices=None):
for b in line:
if b.data.startswith("iq:"):
item = b.data.split(":")[2]
index = choices.get(item, 1 if item.startswith("cssrs_") else 0)
# «Нет» by default for the safety items (C-SSRS, red flags), the first option otherwise.
index = choices.get(item, 1 if item.startswith(("cssrs_", "rf_")) else 0)
for line2 in reply.buttons:
for other in line2:
if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"):
@@ -122,17 +124,17 @@ async def test_pause_restart_and_duplicate_callbacks(released):
old = answer_button(replies)
await asyncio.gather(press(released, old), press(released, old))
(row,) = await rows()
assert row.answers == {"phq9_1": 0}
assert row.answers == {"rf_psychosis": 0}
await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume")
assert "phq9_2" in answer_button(replies)
assert "rf_harm_others" in answer_button(replies)
await press(restarted, old)
assert (await rows())[0].answers == {"phq9_1": 0}
assert (await rows())[0].answers == {"rf_psychosis": 0}
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
@pytest.mark.parametrize("signal", ["help", "phq", "cssrs"])
@pytest.mark.parametrize("signal", ["help", "red_flag", "cssrs"])
async def test_immediate_stop_preserves_partial_answers(released, signal):
replies = await press(released, "intake:start")
if signal == "help":
@@ -142,13 +144,45 @@ async def test_immediate_stop_preserves_partial_answers(released, signal):
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0}))
else:
replies = await complete_existing(released, replies, {"trauma": 1, "phq9_1": 2, "phq9_9": 1})
replies = await press(released, answer_button(replies, {"rf_psychosis": 0}))
assert any(r.kind == "crisis" for r in replies)
(row,) = await rows()
assert row.status == "interrupted" and row.completed_at is None
notices = await rows(QuestionnaireNotice)
assert len(notices) == 1 and notices[0].status == "test_sink"
assert "уведомлён" not in replies[0].text
# Every crisis leaves a callback task for the doctor on duty (one per patient while it is open).
calls = [a for a in await rows(Alert) if a.code == "post_crisis_call"]
assert len(calls) == 1 and calls[0].params["assignee"] == "on-duty"
async def test_red_flag_violence_uses_its_crisis_variant(released):
replies = await press(released, "intake:start")
for _ in range(3): # psychosis, harm to others, weapons: «Нет»
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies, {"rf_violence": 0}))
crisis = next(r for r in replies if r.kind == "crisis")
assert "насили" in crisis.text.lower()
(row,) = await rows()
assert row.details["stop_reason"] == "red_flag_violence"
async def test_item9_with_a_negative_screener_is_a_flag_not_a_crisis(released):
# One crisis rule (ADR-013): C-SSRS was negative, so PHQ-9 item 9 is a discrepancy for the doctor.
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
choices.update(trauma=1, tbi=1, psychotropics=1, phq9_9=1)
replies = await press(released, "intake:start")
for _ in range(60):
button = answer_button(replies, choices)
if button is None or any(r.kind == "safety_plan" for r in replies):
break
replies = await press(released, button)
assert not any(r.kind == "crisis" for r in replies)
assert any(r.kind == "safety_plan" for r in replies)
codes = {a.code for a in await rows(Alert)}
assert "phq9_cssrs_discrepancy" in codes and "post_crisis_call" not in codes
(row,) = await rows()
assert row.status == "complete" and "phq9_cssrs_discrepancy" in row.details["decision"]["flags"]
async def complete_existing(e, replies, choices):
@@ -350,16 +384,24 @@ async def test_diary_morning_then_evening_with_the_ptsd_module(released):
assert any("уже заполнен" in r.text for r in replies)
async def test_depression_module_item9_stops_with_crisis(released, monkeypatch):
async def test_depression_module_item9_asks_the_screener_at_once(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
await run_diary(released, await press(released, "menu:checkin"))
replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1})
assert any(r.kind == "crisis" for r in replies)
replies = await press(released, "menu:checkin")
for _ in range(10):
button = answer_button(replies, {"medication": 2, "dphq_9": 1})
replies = await press(released, button)
if ":cssrs_" in (answer_button(replies) or ""):
break
# Item 9 → the C-SSRS right away («Этап 2»): no crisis screen before the screener says the risk is high.
assert not any(r.kind == "crisis" for r in replies) and any("последнего ответа" in r.text for r in replies)
evening = next(r for r in await rows() if r.kind == "diary_evening")
assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted"
assert evening.answers["dphq_9"] == 1 and evening.details["stop_reason"] == "phq9_item9_cssrs"
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0, "cssrs_q4": 0})
assert any(r.kind == "crisis" for r in replies)
async def test_unknown_sex_goes_to_review_without_a_zone(released):
@@ -380,7 +422,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released):
path.write_text(yaml.safe_dump(data, allow_unicode=True))
await press(released, "intake:resume")
(row,) = await rows()
assert row.status == "review_required" and row.answers == {"phq9_1": 0}
assert row.status == "review_required" and row.answers == {"rf_psychosis": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released):
@@ -434,6 +476,8 @@ async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:start")
for _ in range(5): # the red-flag block
replies = await press(released, answer_button(replies))
await press(released, answer_button(replies))
await press(released, "intake:pause")
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1))
@@ -472,3 +516,16 @@ async def test_without_a_doctor_the_screening_defines_the_modules(released):
assert any("не выявлено" in r.text for r in replies)
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).modules == []
async def test_next_day_after_a_crisis_the_app_asks_once(released, monkeypatch):
replies = await press(released, "intake:start")
await press(released, answer_button(replies, {"rf_psychosis": 0})) # red flag → crisis
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(hours=21))
await jobs.plan()
await jobs.plan()
sent = outbox.drain(TOKEN)
assert len(sent) == 1 and "Как вы сегодня" in sent[0].text
assert any(b.data == "safety_plan" for line in sent[0].buttons for b in line)
+21
View File
@@ -0,0 +1,21 @@
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location("env_merge", Path("infra/deploy/env_merge.py"))
env_merge = importlib.util.module_from_spec(spec)
spec.loader.exec_module(env_merge)
def test_merge_updates_adds_and_protects(tmp_path):
target = tmp_path / "server.env"
target.write_text("# server\nPSEUDO_KEY=server-secret\nCHAT_AGENT=off\nTZ=Europe/Moscow\n")
source = tmp_path / "service.env"
source.write_text("CHAT_AGENT=gigachat\nINTAKE_DEMO=true\nPSEUDO_KEY=evil\nGITEA_TOKEN=x\nEMPTY=\n")
changed = env_merge.merge(str(target), str(source))
assert sorted(changed) == ["CHAT_AGENT", "INTAKE_DEMO"]
text = target.read_text()
assert "PSEUDO_KEY=server-secret" in text and "evil" not in text
assert "CHAT_AGENT=gigachat" in text and "INTAKE_DEMO=true" in text and "TZ=Europe/Moscow" in text
assert "GITEA_TOKEN" not in text and "EMPTY" not in text
assert text.startswith("# server\n")
assert env_merge.merge(str(target), str(source)) == [] # idempotent
+281
View File
@@ -0,0 +1,281 @@
"""End-to-end server-only MAX flow, real SQLite transactions and mocked network."""
from datetime import timedelta
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.channels import max_bot
from app.channels.max_client import MaxApiError
from app.config import get_settings
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import ScaleResult
from app.db.intake import QuestionnaireRecord
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxReceipt
from app.dialog.contracts import Reply
from app.integrations import max_store as store
from app.integrations.base import MemoryOutbox, integrations
from app.main import app
TOKEN = "synthetic-patient"
SECRET = "synthetic-webhook-secret-" * 2
@pytest.fixture(autouse=True)
async def database(monkeypatch):
for key, value in {
"MAX_ENABLED": "true",
"MAX_BOT_TOKEN": "fake-token",
"MAX_BOT_USERNAME": "test_bot",
"MAX_WEBHOOK_SECRET": SECRET,
"MAX_WEBHOOK_URL": "https://api.example.org/api/v1/webhooks/max",
"MAX_SITE_URL": "https://example.org",
}.items():
monkeypatch.setenv(key, value)
get_settings.cache_clear()
max_bot.texts.cache_clear()
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="")
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
yield
await max_bot.stop()
await session.configure()
get_settings.cache_clear()
def event(kind="bot_started", text="", uid=100, *, stamp=1000):
user = {"user_id": uid, "is_bot": False, "first_name": "Synthetic"}
if kind == "message_created":
return {
"update_type": kind,
"timestamp": stamp,
"message": {
"sender": user,
"recipient": {"chat_type": "dialog", "chat_id": 123},
"body": {"mid": f"mid-{stamp}", "text": text},
},
}
return {"update_type": kind, "timestamp": stamp, "user": user, "chat_id": 123, "payload": text}
async def connect():
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
await max_bot.handle_update(event(text=code))
return code
async def test_http_connect_webhook_duplicate_notification_and_stop():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
auth = {"Authorization": f"Bearer {TOKEN}"}
linked = await client.post("/api/v1/me/notifications/max/link", headers=auth, json={"consent": True})
code = parse_qs(urlsplit(linked.json()["url"]).query)["start"][0]
headers = {"X-Max-Bot-Api-Secret": SECRET}
for _ in range(2):
assert (
await client.post("/api/v1/webhooks/max", headers=headers, json=event(text=code))
).status_code == 200
assert (await client.get("/api/v1/me/notifications/max", headers=auth)).json()["connected"]
async with session.get_identity_session() as s:
assert len((await s.scalars(select(MaxReceipt))).all()) == 1
assert len((await s.scalars(select(MaxDelivery))).all()) == 1
sender = AsyncMock()
assert await max_bot.deliver_one(sender)
assert sender.send.call_args.args == (100, max_bot.texts().connected)
await client.post("/api/v1/webhooks/max", headers=headers, json=event("message_created", "/stop", stamp=2000))
assert not (await client.get("/api/v1/me/notifications/max", headers=auth)).json()["connected"]
async def test_unbound_start_help_and_invalid_code_are_generic():
for e in (event(), event("message_created", "private patient text", stamp=2000), event(text="expired", stamp=3000)):
await max_bot.handle_update(e)
async with session.get_identity_session() as s:
rows = (await s.scalars(select(MaxDelivery))).all()
assert {r.kind for r in rows} == {"not_connected", "help", "invalid"}
assert all(r.token is None for r in rows)
assert not (await s.scalars(select(MaxBinding))).all()
@pytest.mark.parametrize("kind", ["bot_stopped", "dialog_removed", "message_created"])
async def test_stopping_cancels_pending_confirmation_and_reminders(kind):
await connect()
await max_bot.MaxOutbox(MemoryOutbox()).push(TOKEN, [Reply(text="private scheduled question")])
await max_bot.handle_update(event(kind, "/stop", stamp=2000))
async with session.get_identity_session() as s:
assert not (await s.get(MaxBinding, TOKEN)).enabled
rows = (await s.scalars(select(MaxDelivery).where(MaxDelivery.token == TOKEN))).all()
assert rows and all(r.state == "cancelled" for r in rows)
sender = AsyncMock()
await max_bot.deliver_one(sender)
assert sender.send.call_count == (1 if kind == "message_created" else 0)
async def test_private_patient_content_stays_in_fallback_only():
await connect()
fallback = MemoryOutbox()
wrapper = max_bot.MaxOutbox(fallback)
replies = [Reply(text="SYNTHETIC confidential result 42", kind="question")]
await wrapper.push(TOKEN, replies)
await wrapper.push(TOKEN, replies)
assert fallback.drain(TOKEN) == replies * 2
async with session.get_identity_session() as s:
rows = (await s.scalars(select(MaxDelivery).where(MaxDelivery.kind == "notification"))).all()
assert len(rows) == 1
assert "confidential" not in repr(rows[0].__dict__)
sender = AsyncMock()
while await max_bot.deliver_one(sender):
pass
assert any(c.args[1] == max_bot.texts().notification for c in sender.send.call_args_list)
assert all("confidential" not in c.args[1] and TOKEN not in str(c.kwargs) for c in sender.send.call_args_list)
async def test_revoked_consent_prevents_binding_and_delivery():
await connect()
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": False, "adult": True}, now=clock.now()
)
sender = AsyncMock()
await max_bot.deliver_one(sender)
sender.send.assert_not_called()
async with session.get_identity_session() as s:
await store.disconnect(s, TOKEN)
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
await max_bot.handle_update(event(text=code, stamp=2000))
async with session.get_identity_session() as s:
assert not (await s.get(MaxBinding, TOKEN)).enabled
@pytest.mark.parametrize(
"bad",
[
{},
{"update_type": "other", "timestamp": 1},
event() | {"timestamp": True},
event() | {"user": {"user_id": True, "is_bot": False}},
event() | {"user": {"user_id": 100, "is_bot": True}},
event() | {"payload": {"wrong": "type"}},
{"update_type": "message_created", "timestamp": 1, "message": {}},
],
)
async def test_malformed_or_bot_events_are_ignored(bad):
await max_bot.handle_update(bad)
async with session.get_identity_session() as s:
assert not (await s.scalars(select(MaxReceipt))).all()
async def test_group_cannot_claim_a_patient_link():
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
update = event("message_created", "/start " + code)
update["message"]["recipient"]["chat_type"] = "chat"
await max_bot.handle_update(update)
async with session.get_identity_session() as s:
assert await s.get(MaxBinding, TOKEN) is None
assert await store.code_token(s, code, clock.now()) == TOKEN
async def test_429_respects_retry_deadline_and_then_succeeds(monkeypatch):
await max_bot.handle_update(event())
now = clock.now()
monkeypatch.setattr(clock, "now", lambda: now)
sender = AsyncMock()
sender.send.side_effect = MaxApiError("http_429", retryable=True, retry_after=120)
assert await max_bot.deliver_one(sender)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.state == "queued" and row.next_attempt_at == now + timedelta(seconds=120)
assert not await max_bot.deliver_one(sender)
now += timedelta(seconds=121)
sender.send.side_effect = None
assert await max_bot.deliver_one(sender)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.state == "sent" and row.attempts == 2
async def test_permanent_error_does_not_retry():
await max_bot.handle_update(event())
sender = AsyncMock()
sender.send.side_effect = MaxApiError("http_403")
await max_bot.deliver_one(sender)
assert not await max_bot.deliver_one(sender)
async with session.get_identity_session() as s:
assert (await s.scalars(select(MaxDelivery))).one().state == "failed"
@pytest.mark.parametrize("source", ["scale", "questionnaire"])
async def test_completed_test_produces_one_neutral_notification(source):
await connect()
sender = AsyncMock()
await max_bot.deliver_one(sender)
sender.reset_mock()
async with session.get_clinical_session() as s:
now = clock.now()
if source == "scale":
s.add(ScaleResult(token=TOKEN, ts=now, scale="phq9", total=1, severity="synthetic", complete=True))
else:
s.add(
QuestionnaireRecord(
id="synthetic-form",
token=TOKEN,
kind="intake",
version="test",
policy_version="test",
started_at=now,
completed_at=now,
day=now.date(),
status="complete",
answers={},
details={},
)
)
for _ in range(2):
async with session.get_identity_session() as s:
await store.collect_completed(s, clock.now())
assert await max_bot.deliver_one(sender)
assert not await max_bot.deliver_one(sender)
sender.send.assert_awaited_once_with(
100, max_bot.texts().completed, site_url="https://example.org", button=max_bot.texts().open_site
)
async def test_private_start_command_accepts_issued_code():
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
await max_bot.handle_update(event("message_created", "/start " + code))
async with session.get_identity_session() as s:
assert (await s.get(MaxBinding, TOKEN)).user_id == 100
async def test_disabled_lifecycle_never_constructs_client(monkeypatch):
monkeypatch.setenv("MAX_ENABLED", "false")
get_settings.cache_clear()
monkeypatch.setattr(max_bot, "MaxClient", lambda _: pytest.fail("disabled MAX must not create a network client"))
before = integrations.outbox
await max_bot.start()
await max_bot.handle_update(event())
await max_bot.stop()
assert integrations.outbox is before
async def test_lifecycle_wraps_once_restores_and_closes(monkeypatch):
sender = AsyncMock()
monkeypatch.setattr(max_bot, "MaxClient", lambda _: sender)
before = integrations.outbox
await max_bot.start()
first = integrations.outbox
await max_bot.start()
assert integrations.outbox is first and first.fallback is before
await max_bot.stop()
assert integrations.outbox is before
sender.close.assert_awaited_once()
+95
View File
@@ -0,0 +1,95 @@
"""Wire contract with MAX, using an in-process HTTP fake (no real bot/token)."""
import json
import logging
import httpx
import pytest
from app.channels.max_client import API_URL, UPDATE_TYPES, MaxApiError, MaxClient
from app.config import Settings
def settings():
return Settings(
_env_file=None,
max_enabled=True,
max_bot_token="synthetic-secret-token",
max_bot_username="synthetic_bot",
max_webhook_secret="synthetic-webhook-secret-" * 2,
max_webhook_url="https://api.example.org/api/v1/webhooks/max",
max_site_url="https://example.org",
)
async def test_message_wire_contract_and_no_identifiers_in_logs(caplog):
def handler(request):
assert str(request.url).startswith(API_URL + "/messages?")
assert request.url.params["user_id"] == "1234567890123"
assert "access_token" not in request.url.params
assert request.headers["Authorization"] == "synthetic-secret-token"
body = json.loads(request.content)
assert body["text"] == "neutral reminder" and "format" not in body
button = body["attachments"][0]["payload"]["buttons"][0][0]
assert button == {"type": "link", "text": "Open", "url": "https://example.org"}
return httpx.Response(200, json={"message": {"body": {"mid": "synthetic-id"}}})
client = MaxClient(settings(), transport=httpx.MockTransport(handler))
with caplog.at_level(logging.INFO):
await client.send(1234567890123, "neutral reminder", site_url="https://example.org", button="Open")
await client.close()
assert "1234567890123" not in caplog.text and "synthetic-secret-token" not in caplog.text
@pytest.mark.parametrize(
"status,retry", [(429, True), (500, True), (503, True), (408, True), (401, False), (403, False)]
)
async def test_error_codes_are_redacted_and_classified(status, retry):
client = MaxClient(
settings(),
transport=httpx.MockTransport(
lambda r: httpx.Response(
status, json={"message": "sensitive provider text"}, headers={"Retry-After": "120"}
)
),
)
with pytest.raises(MaxApiError) as error:
await client.send(100, "neutral", site_url="https://example.org", button="Open")
assert error.value.retryable is retry and error.value.retry_after == 120
assert "sensitive" not in str(error.value)
await client.close()
@pytest.mark.parametrize("body", [{"success": False, "message": "private"}, {}, {"success": True}, []])
async def test_http_200_is_not_enough_to_mark_delivered(body):
client = MaxClient(settings(), transport=httpx.MockTransport(lambda r: httpx.Response(200, json=body)))
with pytest.raises(MaxApiError):
await client.send(100, "neutral", site_url="https://example.org", button="Open")
await client.close()
async def test_timeout_is_retryable_without_request_details():
def handler(request):
raise httpx.ReadTimeout("private URL", request=request)
client = MaxClient(settings(), transport=httpx.MockTransport(handler))
with pytest.raises(MaxApiError, match="^transport$") as error:
await client.request("GET", "/me")
assert error.value.retryable
await client.close()
async def test_subscription_includes_secret_and_exact_event_types():
def handler(request):
assert request.url.path == "/subscriptions"
body = json.loads(request.content)
assert body == {
"url": settings().max_webhook_url,
"update_types": UPDATE_TYPES,
"secret": settings().max_webhook_secret.get_secret_value(),
}
return httpx.Response(200, json={"success": True})
client = MaxClient(settings(), transport=httpx.MockTransport(handler))
await client.subscribe(settings())
await client.close()
+51
View File
@@ -0,0 +1,51 @@
"""Operator tools do not send messages or expose credentials."""
from unittest.mock import AsyncMock
import pytest
from app.channels import max_setup
from app.channels.max_client import MaxApiError
from app.config import Settings
@pytest.fixture
def client(monkeypatch):
settings = Settings(_env_file=None, max_bot_username="test_bot", max_webhook_url="https://api.example.org/hook")
sender = AsyncMock()
sender.request.return_value = {"is_bot": True, "username": "test_bot"}
monkeypatch.setattr(max_setup, "Settings", lambda **kwargs: settings)
monkeypatch.setattr(max_setup, "MaxClient", lambda cfg: sender)
return sender
async def test_check_only_reads_bot_identity(client):
await max_setup.run("check")
client.request.assert_awaited_once_with("GET", "/me")
client.subscribe.assert_not_called()
client.send.assert_not_called()
client.close.assert_awaited_once()
async def test_subscribe_is_explicit_and_checks_bot_first(client):
await max_setup.run("subscribe")
client.subscribe.assert_awaited_once()
client.send.assert_not_called()
async def test_mismatched_bot_does_not_change_webhooks(client):
client.request.return_value = {"is_bot": True, "username": "another_bot"}
with pytest.raises(MaxApiError, match="bot_username_mismatch"):
await max_setup.run("subscribe")
client.subscribe.assert_not_called()
client.close.assert_awaited_once()
async def test_status_checks_exact_configured_webhook(client):
client.request.side_effect = [
{"is_bot": True, "username": "test_bot"},
{"subscriptions": [{"url": "https://elsewhere.example.org"}]},
]
with pytest.raises(MaxApiError, match="webhook_missing"):
await max_setup.run("status")
client.subscribe.assert_not_called()
+8 -3
View File
@@ -172,12 +172,16 @@ def test_unapproved_policy_never_continues_even_a_negative_screen():
def test_immediate_safety_preempts_unfinished_or_invalid_unrelated_scales():
for overrides in [
dict(help_now=True, cssrs={"broken": "data"}),
dict(phq9={"phq9_9": 1}),
dict(phq9={"phq9_9": 1}, cssrs={}), # item 9 before the screener is complete
dict(cssrs={"q4": 1}, alcohol={"bad": 50}),
]:
assert intake(policy=load_policy(), **overrides).status == "crisis"
def test_item9_with_a_negative_screener_is_a_discrepancy_flag_not_a_crisis():
# One crisis rule (ADR-013): the crisis screen opens at a high C-SSRS risk only.
r = intake(phq9={"phq9_9": 1})
assert "phq9_cssrs_discrepancy" in r.flags
assert r.status != "crisis" and {"phq9_item9_positive", "phq9_cssrs_discrepancy"} <= set(r.flags)
def test_physician_must_choose_between_stop_and_graded():
@@ -208,7 +212,8 @@ def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative():
(30, "self_help"),
(31, "psychiatrist_or_psychotherapist"),
(49, "psychiatrist_or_psychotherapist"),
(50, "priority_psychiatrist"),
(50, "psychiatrist_or_psychotherapist"),
(80, "psychiatrist_or_psychotherapist"),
],
)
def test_pdf_pcl_boundaries_are_distinct_from_legacy_33(total, route):
+3 -1
View File
@@ -155,7 +155,9 @@ def test_severe_totals(scale: str, total: int, code: str, fires: bool) -> None:
acts = evaluate(s)
assert (code in codes(acts)) is fires
if fires:
assert next(a for a in acts if a.code == code).level == AlertLevel.HIGH
# PCL-5 alone is a planned specialist review, not a priority (clinical review 2026-10-04).
expected = AlertLevel.MODERATE if scale == "pcl5" else AlertLevel.HIGH
assert next(a for a in acts if a.code == code).level == expected
@pytest.mark.parametrize(
+2 -1
View File
@@ -178,6 +178,7 @@ async def test_andrey_13_days(db: MemoryNotifier) -> None:
"sleep_lt4_3n",
"alcohol_3d",
"phq9_item9", # same evaluation: YAML order
"post_crisis_call", # the crisis leaves a callback task for the doctor on duty
"phq9_rise",
]
assert len(crisis_audit) == 1 and crisis_audit[0].token == TOKEN and crisis_audit[0].actor == "system"
@@ -260,7 +261,7 @@ async def test_notifier_failure_keeps_alert(db: MemoryNotifier, monkeypatch: pyt
await scale("pcl5", 55)
await monitoring.RuleMonitoring().after_data_change(TOKEN)
async with session.get_clinical_session() as s:
assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "high")]
assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "moderate")]
assert await color() == "yellow"
+3 -1
View File
@@ -43,7 +43,9 @@ 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 }),
patientCapabilities: () => request<{ primary_screening: boolean }>("/patient/capabilities"),
connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`),
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"),
+17 -15
View File
@@ -52,7 +52,7 @@ export const ru = {
or: "или",
anonymous: {
title: "Войти анонимно",
text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.",
text: "Без имени, СНИЛС и полиса. Первичный опрос и связь с врачом — по обезличенному профилю.",
},
disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.",
},
@@ -71,12 +71,12 @@ export const ru = {
demoDay: (day: number) => `${day}-й день терапии`,
},
anonymous: {
title: "Что вы хотите проверить?",
note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.",
focus: {
depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" },
ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" },
},
title: "Помощь без личных данных",
note: "Врач видит ваши ответы и результаты под условным номером, без имени, СНИЛС и полиса. Ответы хранятся на сервере, ключ доступа к профилю — на этом устройстве.",
start: "Начать первичный опрос",
continue: "Продолжить без личных данных",
lead: "Пройдите единый первичный опрос: заранее выбирать предполагаемый диагноз не нужно.",
unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.",
submitting: "Создаём профиль…",
},
today: {
@@ -96,11 +96,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 +127,7 @@ export const ru = {
tablet: "1 приём",
week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.",
empty: "Назначения появятся здесь после входа через ЕМИАС.",
empty: "Здесь появятся назначения вашего врача.",
question: "Вопрос о лекарстве",
reminderTitle: "Время принять препарат",
reminderLead: "По назначению врача",
@@ -166,11 +166,13 @@ export const ru = {
visit: "Следующий визит",
visitNone: "Дата следующего визита пока не назначена",
attending: "Ваш лечащий врач",
write: "Написать врачу",
writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.",
connect: "Подключить врача без личных данных",
connecting: "Подключаем врача…",
write: "Попросить врача связаться",
writeNote: "Запрос на связь появится в кабинете врача. Если срочно — 112.",
earlier: "Записаться к врачу раньше",
plan: "Мой план безопасности",
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.",
noDoctor: "Врач пока не подключён. Можно подключить его без имени, СНИЛС и полиса: он увидит обезличенные ответы и результаты.",
},
profile: {
title: "Профиль",
@@ -185,7 +187,7 @@ export const ru = {
revokeConfirm: "Да, отозвать",
cancel: "Отмена",
wipe: "Удалить данные с устройства",
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.",
wipeNote: "Удалятся ключ доступа и локальная копия переписки. Обезличенные ответы останутся на сервере; без ключа восстановить доступ к этому профилю не получится.",
logout: "Выйти",
messages: "Сообщения",
result: "Предварительный результат",
+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}>
+6 -3
View File
@@ -7,7 +7,6 @@ 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 +83,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 +117,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}`);
@@ -292,7 +290,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