Compare commits

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

Refs #92

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

Refs #85
2026-10-04 16:14:37 +03:00
Pavel Eliseev 7780b484f3 feat(bot): add MAX notifications adapter and operator handoff
CI / ruff · pytest (pull_request) Successful in 2m27s
Refs #83
2026-10-04 16:10:30 +03:00
Pavel Eliseev 28967c7c37 fix(contract): redact input values in settings errors
CI / ruff · pytest (pull_request) Successful in 2m22s
Refs #87
2026-10-04 16:10:30 +03:00
Pavel Eliseev 2a2fdb171e feat(ui): extend minimal doctor design across all screens
CI / ruff · pytest (pull_request) Successful in 2m16s
Refs #85
2026-10-04 16:09:57 +03:00
Soyavo afbfbad19e Merge pull request 'feat(backend): prepare MAX linking, webhook API and durable notifications' (#86) from feat/82-max-backend into dev
CI / ruff · pytest (push) Successful in 2m16s
2026-10-04 16:09:13 +03:00
Pavel Eliseev 461c3d02ea fix(backend): redact retryable MAX storage failures
CI / ruff · pytest (pull_request) Successful in 2m4s
Refs #82
2026-10-04 16:04:38 +03:00
Pavel Eliseev 64655ad934 feat(backend): add durable MAX linking and notification queue
Refs #82
2026-10-04 16:04:38 +03:00
Soyavo db3072d759 Merge pull request '[contract] Add opt-in MAX configuration and lifecycle' (#84) from feat/81-max-contract into dev
CI / ruff · pytest (push) Successful in 2m4s
2026-10-04 16:02:52 +03:00
Pavel Eliseev b3e103954f feat(contract): add opt-in MAX configuration and lifecycle
CI / ruff · pytest (pull_request) Successful in 2m24s
Refs #81
2026-10-04 15:58:25 +03:00
Eleboba bde6ec6855 Merge pull request 'chore(devops): деплой на сервер + GigaChat для чат-агента + без 500 при ошибках провайдера' (#80) from chore/server-deploy into dev
CI / ruff · pytest (push) Successful in 2m13s
2026-10-04 15:48:56 +03:00
ElebobaandClaude Opus 5.5 ade93d959b feat(bot): GigaChat function-calling provider for the chat agent; never 500 on provider errors
CI / ruff · pytest (pull_request) Successful in 2m15s
OpenRouter returns 403 «Access denied by security policy» from the RF server, and parsing that string error
crashed the request (500 instead of the fallback answer). Error parsing is robust now and any agent exception
degrades to the extractive answer. GigaChat tool provider (GigaChat-2-Pro by default) converts the agent's
OpenAI-style history to function_call / role=function; auto mode prefers GigaChat when credentials are set.

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

Refs #77

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

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

ADR-012.

Refs #77

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

Refs #77

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:59:38 +03:00
Arxip222andClaude Opus 5.5 0195468edc docs(product): mockups brief and the design mockups of the patient app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:12:05 +03:00
Arxip222andClaude Opus 5.5 464b474abd feat(web): questionnaires, diary and medication module in the patient app
- Focus screen renders versioned questionnaire items (iq: buttons): section chip, position
  (adaptive forms without "of N"), recall date, pause / help-now actions, optional skip,
  sleep-hours input, 0-10 scale anchors, RUS-AUDIT item 2 standard-drink table.
- Paused / held-for-review questionnaires get their own screen.
- Today: intake and diary tasks, review notice, therapy-day card on days 1/7/14.
- Drug card /meds/:ref from data/meds/meds_reference.json (draft built from the Minzdrav
  guidelines: depression 2024, PTSD 2023) — expected effect, course, side effects, red flags;
  no doses shown. "Report a side effect" always available (med:side_effects:<ref>).
- API: /instruments/catalog, /meds/reference; today gains questionnaire state, reference id,
  indication and therapy day; demo persona seeds the dashboard dose history.
- INTAKE_DEMO=1 (demo clock only) opens the questionnaires on synthetic data without touching
  the clinical release gate in config/intake_runtime.yaml.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:57:00 +03:00
136 changed files with 15584 additions and 524 deletions
+14 -1
View File
@@ -9,7 +9,7 @@ GIGACHAT_MODEL=GigaChat
# --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only.
CHAT_AGENT=auto
OPENROUTER_API_KEY=
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5
OPENROUTER_MODEL=google/gemini-2.5-flash-lite
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY=
@@ -27,6 +27,9 @@ DOCTOR_NOTIFY_CHAT_ID=
# --- Demo virtual clock (ADR-005)
DEMO_CLOCK=true
# Questionnaires of the intake (#53) on synthetic data, demo only (needs DEMO_CLOCK). The clinical release gate in
# config/intake_runtime.yaml stays closed; without a signed routing policy every finished intake goes to review.
INTAKE_DEMO=false
TZ=Europe/Moscow
# --- Gitea (for Claude Code / team workflow, not used by the app)
@@ -34,3 +37,13 @@ GITEA_URL=https://gitea.24studbank.ru
GITEA_REPO=MedITron/PTSRaiAgent
GITEA_USER=
GITEA_TOKEN=
# MAX notifications: keep disabled until the backend/channel PRs and a real bot are installed.
MAX_ENABLED=false
MAX_BOT_TOKEN=
MAX_BOT_USERNAME=
MAX_WEBHOOK_SECRET=
MAX_WEBHOOK_URL=
MAX_SITE_URL=
# Optional path to a trusted PEM CA bundle; never disable TLS verification.
MAX_CA_BUNDLE=
MAX_LINK_TTL_SECONDS=600
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts run inside Linux containers: never check them out with CRLF (bash fails on the carriage return).
*.sh text eol=lf
+6 -6
View File
@@ -1,15 +1,15 @@
# Dashboard-only Streamlit settings (doctor dashboard + demo console).
[theme]
base = "light"
primaryColor = "#2A78D6"
backgroundColor = "#F6F7F9"
primaryColor = "#285E4E"
backgroundColor = "#F6F7F3"
secondaryBackgroundColor = "#FFFFFF"
textColor = "#1B1F24"
borderColor = "#E3E6EA"
textColor = "#213B38"
borderColor = "#E1E7E3"
baseRadius = "medium"
# Falls back to the system sans-serif when offline (ADR-004).
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap, sans-serif"
headingFontWeights = 700
font = "sans-serif"
headingFontWeights = 600
[client]
toolbarMode = "minimal"
+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,
+8 -1
View File
@@ -9,18 +9,25 @@ from zoneinfo import ZoneInfo
from app.api.schemas import (
AlertOut,
DemoPersona,
DiscontinuationRisk,
DoseOut,
PatientCard,
PatientListItem,
SeriesPoint,
)
from app.core.safety import fill_phones
from app.integrations.base import Prescription
TZ = ZoneInfo("Europe/Moscow")
TODAY = date(2026, 10, 3)
DOCTOR_ID = "doc-smirnova"
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
DEMO_PERSONAS: list[DemoPersona] = [
DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
]
def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
level = "high" if score >= 60 else "medium" if score >= 30 else "low"
@@ -256,7 +263,7 @@ def andrey_card() -> PatientCard:
"Что помогает: прогулка с собакой, душ",
"Люди и места: брат Олег, спортзал",
"Кому звонить: жена, сослуживец Миша",
"Специалисты: д-р Смирнова, 8-800-2000-122, 112",
fill_phones("Специалисты: д-р Смирнова, {phone:helpline}, 112"),
"Безопасная среда: ружьё хранится у брата",
],
summary="За 13 дней: приверженность 12/13, эпизод намерения бросить на 5-й день (снят после звонка врача). "
+115
View File
@@ -0,0 +1,115 @@
"""Server API for a future MAX connect UI; webhook processing is supplied by the channel adapter."""
import hmac
import json
import re
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings
from app.core import clock
from app.db import repo
from app.db.max_notifications import MaxBinding, MaxDelivery
from app.db.session import get_clinical_session, get_identity_session
from app.integrations import max_store
router = APIRouter(tags=["max-notifications"])
bearer = HTTPBearer(auto_error=False)
async def patient_token(auth: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> str:
if auth is None or auth.scheme.lower() != "bearer" or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", auth.credentials):
raise HTTPException(401, "patient authentication required", headers={"WWW-Authenticate": "Bearer"})
async with get_clinical_session() as session:
if await repo.get_profile(session, auth.credentials) is None:
raise HTTPException(401, "patient authentication required")
return auth.credentials
PatientToken = Annotated[str, Depends(patient_token)]
class MaxStatus(BaseModel):
available: bool
connected: bool
last_delivery: str | None = None
class LinkConsent(BaseModel):
model_config = ConfigDict(extra="forbid")
consent: Literal[True]
class MaxLink(BaseModel):
url: str
expires_at: datetime
@router.get("/me/notifications/max", response_model=MaxStatus)
async def status(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
binding = await session.get(MaxBinding, token)
last = await session.scalar(
select(MaxDelivery.state)
.where(MaxDelivery.token == token)
.order_by(MaxDelivery.created_at.desc(), MaxDelivery.id.desc())
.limit(1)
)
return MaxStatus(
available=get_settings().max_enabled, connected=bool(binding and binding.enabled), last_delivery=last
)
@router.post("/me/notifications/max/link", response_model=MaxLink)
async def link(body: LinkConsent, token: PatientToken) -> MaxLink:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
if not await max_store.active_patient(token):
raise HTTPException(403, "active consent and adult confirmation required")
async with get_identity_session() as session:
raw, expires = await max_store.issue_code(session, token, clock.now(), settings.max_link_ttl_seconds)
return MaxLink(url=f"https://max.ru/{settings.max_bot_username}?start={raw}", expires_at=expires)
@router.delete("/me/notifications/max", response_model=MaxStatus)
async def unlink(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
await max_store.disconnect(session, token)
return MaxStatus(available=get_settings().max_enabled, connected=False)
@router.post("/webhooks/max")
async def webhook(request: Request) -> dict[str, bool]:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
expected = settings.max_webhook_secret.get_secret_value().encode()
supplied = request.headers.get("X-Max-Bot-Api-Secret", "").encode()
if not expected or not hmac.compare_digest(supplied, expected):
raise HTTPException(403, "invalid webhook secret")
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 65536:
raise HTTPException(413, "event is too large")
try:
event = json.loads(raw)
except (ValueError, UnicodeError) as exc:
raise HTTPException(400, "invalid JSON") from exc
if not isinstance(event, dict):
raise HTTPException(422, "event must be an object")
from app.channels.max_bot import handle_update
try:
await handle_update(event)
except SQLAlchemyError:
# Let MAX retry; do not log DB exception parameters containing identity data.
raise HTTPException(503, "notification storage unavailable") from None
return {"success": True}
+429
View File
@@ -0,0 +1,429 @@
"""Patient web app: sign-in, the patient's own plan and public reference data (`/api/v1/auth/*`, `/me/*`,
`/crisis/resources`, `/instruments/catalog`, `/meds/reference`).
The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
Two ways in:
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
patient is linked to the attending doctor, who sees the data on the dashboard.
- **Anonymous**: a random pseudonymous token, no identity record, and access to the prototype care team.
Clinical answers are stored on the server and visible to the assigned doctor under a pseudonym.
Consent is kept in the dialog state. Existing doctor-less profiles require explicit care opt-in.
The token returned here is the session credential of the prototype. Production needs short-lived sessions
issued by the server instead of handing out the pseudonymous token itself.
"""
import logging
import secrets
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Any, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from app.api import fixtures
from app.api.schemas import DemoPersona
from app.core import clock
from app.core.instrument_catalog import Catalog, catalog
from app.core.intake_questions import DIARY_KINDS
from app.core.intake_runtime import intake_enabled, load_runtime
from app.core.interpretation import texts as results_texts
from app.core.meds_reference import find_drug, indication_for, load_reference
from app.core.safety import CrisisResource, crisis_reply
from app.db import 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__)
router = APIRouter(tags=["patient-web"])
_emias = EmiasMock()
class AuthOut(BaseModel):
token: str
mode: Literal["emias", "anonymous"]
display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design
class EmiasCodeIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
class AnonymousIn(BaseModel):
model_config = ConfigDict(extra="forbid")
focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module
# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010).
_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"}
class EmiasDemoIn(BaseModel):
model_config = ConfigDict(extra="forbid")
persona_token: str = Field(min_length=1, max_length=64)
async def _attach_doctor(token: str, doctor_id: str) -> None:
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
async def _seed_demo_persona(token: str) -> None:
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
doctor's card tell the same story.
"""
card = fixtures.card(token)
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
if card is None or item is None:
return
now = clock.now()
today = now.date()
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
await repo.upsert_profile(
s,
token=token,
doctor_id=fixtures.DOCTOR_ID,
modules=list(item.modules),
therapy_start=min((p.start_date for p in card.prescriptions if p.start_date), default=today),
next_visit=today + timedelta(days=15),
)
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
return
for p in card.prescriptions:
rx = Prescription(
token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=p.start_date or today
)
s.add(rx)
await s.flush()
# The dose history of the dashboard story (days before today), so the week marks match the doctor's card.
for d in card.doses:
if d.drug == p.drug and d.planned_at.astimezone(now.tzinfo).date() < today:
s.add(Dose(token=token, prescription_id=rx.id, planned_at=d.planned_at, status=d.status))
for t in p.schedule.get("times", []):
hh, mm = (int(x) for x in t.split(":"))
s.add(
Dose(
token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo)
)
)
@router.post("/auth/emias", response_model=AuthOut)
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
person = await _emias.verify(body.code)
if person is None:
log.info("web auth: emias code rejected")
raise HTTPException(401, "code not accepted")
await _attach_doctor(person.token, person.doctor_id)
log.info("web auth: emias tok=%s", person.token[:6])
return AuthOut(token=person.token, mode="emias")
@router.get("/patient/demo-personas", response_model=list[DemoPersona])
async def patient_demo_personas() -> list[DemoPersona]:
"""Synthetic ЕМИАС accounts for the patient app sign-in (the demo console has its own `/demo/personas`)."""
return fixtures.DEMO_PERSONAS
@router.post("/auth/emias/demo", response_model=AuthOut)
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/patient/demo-personas`)."""
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
if persona is None:
raise HTTPException(404, "persona not found")
await _seed_demo_persona(persona.token)
log.info("web auth: emias demo tok=%s", persona.token[:6])
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
@router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
"""Anonymous sign-in: a fresh random token; care without an identity record.
The clinical side gets a pseudonymous profile assigned to the prototype care team. It holds
no identity fields. Legacy clients may supply `focus`; the common stage 1 screening sets modules itself.
"""
token = secrets.token_hex(TOKEN_LEN // 2)
focus = body.focus if body is not None else None
async with get_clinical_session() as s:
await repo.upsert_profile(
s, token=token, doctor_id=anonymous_care.doctor_id(), modules=[_FOCUS_MODULE[focus]] if focus else []
)
log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous")
@router.get("/patient/capabilities")
async def patient_capabilities() -> dict[str, bool]:
"""Expose availability without leaking clinical policy or server configuration."""
return {"primary_screening": intake_enabled()}
@router.post("/me/{token}/doctor/connect")
async def connect_anonymous_doctor(token: str) -> dict[str, bool]:
"""Explicit opt-in for old doctor-less profiles; never reassign an existing doctor."""
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
if profile is None:
raise HTTPException(404, "patient not found")
if not profile.doctor_id:
profile.doctor_id = anonymous_care.doctor_id()
await audit(s, actor=token, action="anonymous_care_opt_in", token=token, ts=clock.now())
return {"has_doctor": True}
class DoseItem(BaseModel):
id: int
drug: str
dose: str
time: str # HH:MM, patient's local time
status: Literal["taken", "missed", "skipped", "pending"]
class WeekDay(BaseModel):
day: date
status: Literal["taken", "missed", "pending", "none"]
class PrescriptionItem(BaseModel):
drug: str
dose: str
times: list[str]
week: list[WeekDay] # Mon..Sun of the current week
ref_id: str | None = None # entry of /meds/reference, if the drug is covered
indication: Literal["depression", "ptsd"] | None = None
start_date: date | None = None
therapy_day: int | None = None # 1 on the start date (spec: M5, «ожидаемый эффект» cards on days 1/7/14)
class Questionnaires(BaseModel):
"""Questionnaire state for the home screen. Answers and scores are not exposed here."""
enabled: bool
intake: Literal["none", "active", "paused", "complete", "review"]
diary_done: bool # both parts of today's diary
diary_next: Literal["morning", "evening"] | None = None # the part still open today
class TodayOut(BaseModel):
today: date
now: str # HH:MM on the (demo) clock — the client never uses its own clock
has_doctor: bool
checkin_done: bool
next_visit: date | None
doses: list[DoseItem]
prescriptions: list[PrescriptionItem]
questionnaires: Questionnaires
def _intake_state(records: list[QuestionnaireRecord]) -> Literal["none", "active", "paused", "complete", "review"]:
intake = [r for r in records if r.kind == "intake"]
if any(r.status == "complete" for r in intake):
return "complete"
if not intake:
return "none"
last = max(intake, key=lambda r: r.started_at).status
if last in ("active", "paused"):
return last
return "review" if last in ("review_required", "interrupted") else "none"
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
if not statuses:
return "none"
if any(s in ("missed", "skipped") for s in statuses):
return "missed"
return "taken" if all(s == "taken" for s in statuses) else "pending"
@router.get("/me/{token}/today", response_model=TodayOut)
async def my_today(token: str) -> TodayOut:
"""The patient's own plan for today (home screen) and this week's dose marks (medication screen).
Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan.
"""
now = clock.now()
today = now.date()
week_start = today - timedelta(days=today.weekday())
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all()
doses = (
await s.scalars(
select(Dose)
.where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo))
.order_by(Dose.planned_at)
)
).all()
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
records = list((await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all())
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
today_doses: list[DoseItem] = []
marks: dict[tuple[int, date], list[str]] = defaultdict(list)
for d in doses:
local = d.planned_at.astimezone(now.tzinfo)
marks[(d.prescription_id, local.date())].append(d.status)
rx = by_rx.get(d.prescription_id)
if local.date() == today and rx is not None:
today_doses.append(
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
)
week = [week_start + timedelta(days=i) for i in range(7)]
modules = list(profile.modules or []) if profile else []
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"}
open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts]
diary_done = not open_parts
def item(p: Prescription) -> PrescriptionItem:
ref = find_drug(p.drug)
return PrescriptionItem(
drug=p.drug,
dose=p.dose,
times=list((p.schedule or {}).get("times", [])),
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
ref_id=ref["id"] if ref else None,
indication=indication_for(modules, ref) if ref else None,
start_date=p.start_date,
therapy_day=(today - p.start_date).days + 1 if p.start_date else None,
)
return TodayOut(
today=today,
now=now.strftime("%H:%M"),
has_doctor=profile is not None and bool(profile.doctor_id),
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
next_visit=profile.next_visit if profile else None,
doses=today_doses,
prescriptions=[
item(p)
for p in prescriptions
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
],
questionnaires=Questionnaires(
# Stage 1 needs no profile: the screening creates it (anonymous people have none before it).
enabled=intake_enabled(),
intake=_intake_state(records),
diary_done=diary_done,
diary_next=open_parts[0] if open_parts else None,
),
)
class ResultFinding(BaseModel):
code: str
title: str
class Conclusion(BaseModel):
"""Preliminary conclusion of stage 1 — a screening result, not a diagnosis."""
day: date
title: str
lines: list[str]
findings: list[ResultFinding]
modules: list[str]
disclaimer: str
class HistoryItem(BaseModel):
id: str
kind: str
kind_title: str
status: str
status_title: str
day: date
started_at: datetime
completed_at: datetime | None
title: str | None # interpretation shown to the patient when the form was finished
lines: list[str]
class ResultsOut(BaseModel):
conclusion: Conclusion | None
history: list[HistoryItem]
@router.get("/me/{token}/results", response_model=ResultsOut)
async def my_results(token: str) -> ResultsOut:
"""The patient's own questionnaire log: every administration with its date, status and the interpretation that
was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers."""
async with get_clinical_session() as s:
records = list(
(
await s.scalars(
select(QuestionnaireRecord)
.where(QuestionnaireRecord.token == token)
.order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id)
)
).all()
)
t = results_texts()
intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None)
conclusion = None
if intake and intake.details.get("interpretation"):
i = intake.details["interpretation"]
conclusion = Conclusion(
day=intake.day,
title=i["title"],
lines=i["lines"],
findings=[ResultFinding(**f) for f in i.get("findings", [])],
modules=i.get("modules", []),
disclaimer=i.get("disclaimer", t["disclaimer"]),
)
history = [
HistoryItem(
id=r.id,
kind=r.kind,
kind_title=t["kinds"].get(r.kind, r.kind),
status=r.status,
status_title=t["status"].get(r.status, r.status),
day=r.day,
started_at=r.started_at,
completed_at=r.completed_at,
title=(r.details.get("interpretation") or {}).get("title"),
lines=(r.details.get("interpretation") or {}).get("lines", []),
)
for r in records
]
return ResultsOut(conclusion=conclusion, history=history)
@router.get("/crisis/resources", response_model=list[CrisisResource])
async def crisis_resources() -> list[CrisisResource]:
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
return crisis_reply([], "patient").resources
@router.get("/instruments/catalog", response_model=Catalog)
async def instruments_catalog() -> Catalog:
"""Form, position and recall period of every questionnaire item, for the question screen header."""
return catalog()
@router.get("/meds/reference")
async def meds_reference() -> dict[str, Any]:
"""Patient drug reference (draft from the Minzdrav guidelines): expected effect, course, side effects, red flags.
The internal rule parameters stay on the server.
"""
ref = load_reference()
return {k: v for k, v in ref.items() if k != "rules"}
+6 -2
View File
@@ -15,7 +15,8 @@ from sqlalchemy import select
from app.api import demo as console
from app.api import doctor_views as views
from app.api import fixtures
from app.api import fixtures, max_notifications
from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import (
AckIn,
@@ -42,10 +43,13 @@ 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")
router.include_router(max_notifications.router)
router.include_router(questionnaire_router)
router.include_router(patient_web_router)
DEFAULT_CLINIC = "clinic-demo"
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand
@@ -63,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())
+36 -3
View File
@@ -1,10 +1,12 @@
"""Typed application settings. The only place that reads environment variables."""
import re
from functools import lru_cache
from pathlib import Path
from typing import Literal
from urllib.parse import urlsplit
from pydantic import Field
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT_DIR = Path(__file__).resolve().parent.parent
@@ -12,7 +14,7 @@ ROOT_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
# Empty values in .env (e.g. `TG_BOT_TOKEN=`) fall back to defaults instead of failing validation.
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_ignore_empty=True)
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_ignore_empty=True, hide_input_in_errors=True)
# Two separate databases (ADR-003): identity holds direct identifiers, clinical holds pseudonymised data.
identity_db_url: str = "postgresql+asyncpg://identity_app:identity_app@localhost:5432/identity"
@@ -33,8 +35,9 @@ class Settings(BaseSettings):
# data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII).
chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto"
openrouter_api_key: str = ""
openrouter_model: str = "anthropic/claude-sonnet-4.5"
openrouter_model: str = "google/gemini-2.5-flash-lite"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here
chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5
@@ -42,6 +45,36 @@ class Settings(BaseSettings):
tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None
# MAX is opt-in. Its adapter is delivered in the related backend/channel changes.
max_enabled: bool = False
max_bot_token: SecretStr = SecretStr("")
max_bot_username: str = ""
max_webhook_secret: SecretStr = SecretStr("")
max_webhook_url: str = ""
max_site_url: str = ""
max_ca_bundle: Path | None = None
max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800)
@model_validator(mode="after")
def validate_max(self) -> "Settings":
if not self.max_enabled:
return self
if not self.max_bot_token.get_secret_value().strip():
raise ValueError("MAX_BOT_TOKEN is required when MAX_ENABLED=true")
if not re.fullmatch(r"[A-Za-z0-9_]{1,128}", self.max_bot_username):
raise ValueError("MAX_BOT_USERNAME must be the bot nickname without @ or a URL")
if not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", self.max_webhook_secret.get_secret_value()):
raise ValueError("MAX_WEBHOOK_SECRET must contain 32..256 URL-safe characters")
for name, value in (("MAX_SITE_URL", self.max_site_url), ("MAX_WEBHOOK_URL", self.max_webhook_url)):
url = urlsplit(value)
if url.scheme != "https" or not url.hostname or url.username or url.password or url.port not in (None, 443):
raise ValueError(f"{name} must use HTTPS on port 443 without credentials")
if url.query or (name == "MAX_WEBHOOK_URL" and url.fragment):
raise ValueError(f"{name} must not contain a query string (or a fragment for webhook)")
if self.max_ca_bundle is not None and not self.max_ca_bundle.is_file():
raise ValueError("MAX_CA_BUNDLE must point to an existing trusted CA bundle")
return self
# Virtual clock for the demo (ADR-005).
demo_clock: bool = True
tz: str = "Europe/Moscow"
+142
View File
@@ -0,0 +1,142 @@
"""Read-only catalog of questionnaire items for patient clients (web app).
The dialog engine sends a questionnaire question as plain text plus buttons; the button payload carries the item id
(`iq:<nonce>:<item>:<i>`, `scale:<scale>:<item>:<v>`). A client looks the item up here to show which form it belongs
to, its position and recall period. Nothing here changes what is asked or how it is scored — the texts stay those of
the versioned sources in config/.
"""
from functools import lru_cache
from typing import Any, Literal
from pydantic import BaseModel
from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian
from app.core.intake_questions import DIARY_PARTS, content, diary_config, red_flags, texts
from app.core.scales import load_scale
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
# Forms whose item order depends on earlier answers: a client shows the position without «из N».
ADAPTIVE = {"cssrs", "rus_audit", "diary", "context"}
class FormInfo(BaseModel):
id: str
title: str
section: Section
recall_period: str | None = None
instruction: str | None = None
class ItemInfo(BaseModel):
form: str
index: int # 1-based position in the form
total: int
adaptive: bool
optional: bool = False
numeric: bool = False
class DrinksTable(BaseModel):
"""RUS-AUDIT item 2 standard-drink table, exactly as in config/texts/intake.yaml (the approved form)."""
title: str
header: list[str]
rows: list[tuple[str, list[str]]]
note: str
class Catalog(BaseModel):
forms: dict[str, FormInfo]
items: dict[str, ItemInfo]
standard_drinks: DrinksTable | None
def _drinks() -> DrinksTable | None:
raw = texts().get("standard_drinks")
if not raw:
return None
lines = [line.strip() for line in raw.splitlines() if line.strip()]
title, header_line, *rest = lines
rows = [line for line in rest if ":" in line]
note = " ".join(line for line in rest if ":" not in line)
return DrinksTable(
title=title,
header=[h.strip() for h in header_line.split("·")],
rows=[
(label.strip(), [c.strip() for c in cells.split("·")]) for label, cells in (r.split(":", 1) for r in rows)
],
note=note,
)
@lru_cache(maxsize=1)
def catalog() -> Catalog:
forms: dict[str, FormInfo] = {}
items: dict[str, ItemInfo] = {}
def add(form: FormInfo, ids: list[str], *, optional: set[str] = frozenset(), numeric: set[str] = frozenset()):
forms[form.id] = form
for i, item in enumerate(ids, 1):
items.setdefault(
item,
ItemInfo(
form=form.id,
index=i,
total=len(ids),
adaptive=form.id in ADAPTIVE,
optional=item in optional,
numeric=item in numeric,
),
)
flags = red_flags()
add(FormInfo(id="red_flags", title="Вопросы о безопасности", section="safety"), [i["id"] for i in flags["items"]])
cssrs = load_cssrs_russian()
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items])
intake_forms: dict[str, Any] = content()["forms"]
for sid, section in (("phq9", "mood"), ("tsq", "trauma"), ("pcl5", "trauma"), ("rus_audit", "alcohol")):
raw = intake_forms.get(sid) or {}
scale = load_scale(sid)
add(
FormInfo(
id=sid,
title=raw.get("title") or scale.title,
section=section,
recall_period=raw.get("recall_period") or scale.recall_period,
instruction=raw.get("instruction") or scale.instruction,
),
list(scale.item_ids),
)
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
diary = diary_config()
core = [item for part in DIARY_PARTS for item in diary[part]]
depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]]
ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]]
add(
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
[i["id"] for i in core] + depression + ptsd,
optional={i["id"] for i in core if i.get("optional")},
numeric={i["id"] for i in core if i.get("numeric")},
)
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
for path in sorted((get_settings().config_dir / "scales").glob("*.yaml")):
scale = load_scale(path.stem)
forms.setdefault(
scale.id,
FormInfo(
id=scale.id,
title=scale.title,
section="scale",
recall_period=scale.recall_period,
instruction=scale.instruction,
),
)
return Catalog(forms=forms, items=items, standard_drinks=_drinks())
+104 -41
View File
@@ -18,6 +18,7 @@ class Question:
period: str = "context"
numeric: bool = False
optional: bool = False
maximum: float = 24
def content() -> dict:
@@ -52,19 +53,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:
c = score_cssrs_recent(cssrs_answers(answers))
if c.missing:
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == c.missing[0])
period = texts()["periods"][item.recall_period]
# Guidance is interviewer-facing; retained in the source and record version.
return Question(
"cssrs_" + item.id,
period + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
item.recall_period,
)
"""Stage 1: the red-flag block first (config/instruments/red_flags.yaml), then the fixed order of «Опросники
для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, the context questions; stage 2 (rest of
PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. Items inside a scale keep the order of the source.
The risk stop rules run after every answer (flows/intake.py), so a positive answer interrupts the questionnaire
wherever it comes."""
flags = red_flags()
for n, item in enumerate(flags["items"]):
if item["id"] not in answers:
text = (flags["intro"] + "\n\n" if n == 0 else "") + item["text"]
return Question(item["id"], text, tuple((o["value"], o["label"]) for o in flags["options"]), "now")
for item in ("phq9_1", "phq9_2"):
if item not in answers:
return scale_question("phq9", item)
@@ -81,6 +88,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
for item in load_scale("rus_audit_s").item_ids:
if item not in answers:
return scale_question("rus_audit_s", item)
c = score_cssrs_recent(cssrs_answers(answers))
if c.missing:
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == c.missing[0])
period = texts()["periods"][item.recall_period]
# Guidance is interviewer-facing; retained in the source and record version.
return Question(
"cssrs_" + item.id,
period + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
item.recall_period,
)
if sex is None and "sex" not in answers:
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
for key in ("tbi", "psychotropics"):
@@ -108,34 +127,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
return None
def diary_questions(
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
) -> tuple[Question, ...]:
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
selected = ["core"]
detailed = [name for name in ("veteran", "depression") if name in modules]
if len(detailed) == 2:
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
if "veteran" in detailed:
selected.append("ptsd")
if "depression" in detailed:
selected.append("depression")
if suicide_monitoring or "depression" in modules:
selected.append("suicide")
questions = []
for section in selected:
for item in config[section]:
if item.get("weekly") and weekday != 0:
continue
questions.append(
Question(
item["id"],
item["text"],
tuple((o["value"], o["label"]) for o in item.get("options", [])),
"past_week" if item.get("weekly") else "past_day",
item.get("numeric", False),
item.get("optional", False),
def cssrs_since_contact_question(answers: dict) -> Question | None:
"""Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact».
The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the
items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact
is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies.
"""
c = score_cssrs_recent(cssrs_answers(answers))
missing = [k for k in c.missing if k != "q6_recent"]
if not missing:
return None
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == missing[0])
return Question(
"cssrs_" + item.id,
texts()["periods"]["since_last_contact"] + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
"since_last_contact",
)
DIARY_PARTS = ("morning", "evening")
DIARY_KINDS = ("diary_morning", "diary_evening")
def diary_config() -> dict:
return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]:
"""Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile.
PTSD («veteran») and depression run side by side when both are present. The depression module asks three
PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`)
so diary answers never mix with the full scales.
"""
if part not in DIARY_PARTS:
raise ValueError("unknown diary part")
config = diary_config()
questions = [
Question(
item["id"],
item["text"],
tuple((o["value"], o["label"]) for o in item.get("options", [])),
"past_day",
item.get("numeric", False),
item.get("optional", False),
item.get("max", 24),
)
for item in config[part]
]
if part == "evening":
forms = content()["forms"]
if "depression" in modules:
module = config["depression"]
items = forms[module["form"]]["items"]
per_day = module["items_per_day"]
start = (day_index % (len(items) // per_day)) * per_day
options = tuple((o["value"], o["label"]) for o in module["options"])
for item in items[start : start + per_day]:
number = item["id"].rsplit("_", 1)[1]
questions.append(
Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today")
)
if "veteran" in modules:
module = config["ptsd"]
form = forms[module["form"]]
by_id = {i["id"]: i["text"] for i in form["items"]}
options = tuple((o["value"], o["label"]) for o in form["options"])
for item_id in module["items"]:
number = item_id.rsplit("_", 1)[1]
questions.append(
Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today")
)
)
questions.sort(key=lambda q: q.id != "death_thoughts")
return tuple(questions)
+82 -30
View File
@@ -10,7 +10,7 @@ import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.core.questionnaire import Approval, load_policy
from app.core.questionnaire import Approval, demo_mode, load_policy
class RuntimePolicy(BaseModel):
@@ -21,13 +21,20 @@ class RuntimePolicy(BaseModel):
content_review: Approval | None = None
contact_review: Approval | None = None
timezone: str
daily_hour: int = Field(ge=0, le=23)
mood_baseline_days: int = Field(ge=2)
mood_bad_days: int = Field(ge=1)
mood_drop: int = Field(ge=1, le=10)
morning_hour: int = Field(ge=0, le=23)
evening_hour: int = Field(ge=0, le=23)
norm_days: int = Field(ge=2)
week_days: int = Field(ge=2)
min_norm_values: int = Field(ge=1)
min_week_values: int = Field(ge=1)
mood_drop: float = Field(gt=0, le=10)
depression_item_rise: float = Field(gt=0, le=3)
ptsd_sum_rise: float = Field(gt=0, le=16)
sleep_bad_days: int = Field(ge=1)
sleep_hours_below: float = Field(gt=0, le=24)
nightmares_at: int = Field(ge=0, le=4)
alcohol_heavy_portions: float = Field(gt=0)
alcohol_heavy_days: int = Field(ge=1)
adherence_below: float = Field(gt=0, le=1)
missed_medication_days: int = Field(ge=1)
high_risk_silence_days: int = Field(ge=1)
medication_days: tuple[int, ...]
@@ -44,6 +51,8 @@ class RuntimePolicy(BaseModel):
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
):
raise ValueError("all route and scale intervals must be defined")
if self.morning_hour >= self.evening_hour:
raise ValueError("the morning diary must come before the evening one")
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
raise ValueError("medication days must be nonempty and strictly increasing")
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
@@ -64,8 +73,17 @@ def load_runtime(path: Path | None = None) -> RuntimePolicy:
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text()))
def demo_intake() -> bool:
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): shows the questionnaires on synthetic data.
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
until the approvals are recorded there.
"""
return demo_mode()
def intake_enabled() -> bool:
return load_runtime().released and load_policy().approval is not None
return (load_runtime().released and load_policy().approval is not None) or demo_intake()
def month_before(day: date, months: int = 1) -> date:
@@ -115,39 +133,73 @@ def due_scales(
return tuple(sorted(due))
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
"""Consecutive calendar dates; missing data never become zero or missed doses.
def _mean(values: list[float]) -> float:
return sum(values) / len(values)
Baseline is the seven dates BEFORE the three-day deterioration window;
every baseline value is required. Nightmares OR short sleep must hold on
each of three nights. These candidate definitions require signed review.
def diary_flags(
history: dict[date, dict],
today: date,
policy: RuntimePolicy | None = None,
*,
medication_active: bool = False,
) -> tuple[str, ...]:
"""Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days.
`history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of
observation; trends are checked only after it. Missing data never become zero or missed doses.
"""
p = policy or load_runtime()
flags = []
flags: list[str] = []
current = history.get(today, {})
if current.get("death_thoughts") == 1:
flags.append("suicide_assessment_now")
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
baseline = [
history.get(today - timedelta(days=i), {}).get("mood")
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
]
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
mean = sum(baseline) / len(baseline)
if all(mean - x["mood"] >= p.mood_drop for x in recent):
flags.append("phq9_due_mood")
start = min(history) if history else today
norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
week_start = today - timedelta(days=p.week_days - 1)
week = [history[d] for d in sorted(history) if week_start <= d <= today]
trend = (week_start - start).days >= p.norm_days
def values(rows: list[dict], key) -> list[float]:
return [v for r in rows if (v := key(r)) is not None]
def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
before, now = values(norm, key), values(week, key)
if not trend or len(before) < min_norm or len(now) < min_week:
return False
change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before)
return change >= by
if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True):
flags.append("phq9_due_mood")
# Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item.
for item in range(1, 10):
if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2):
flags.append("phq9_due_depression")
break
# PTSD module: only the sum of the four items is interpreted, never single clusters.
ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18")
def ptsd_sum(r):
return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None
if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values):
flags.append("pcl5_due_ptsd")
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
if all(
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
(r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
for r in nights
):
flags.append("sleep_review")
heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions]
if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions:
flags.append("alcohol_on_medication")
if len(heavy) >= p.alcohol_heavy_days:
flags.append("rus_audit_due_alcohol")
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
if all(r.get("medication") == "none" for r in doses):
if all(r.get("medication") == "no" for r in doses):
flags.append("medication_contact")
if current.get("side_effects") == 1:
taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
flags.append("adherence_review")
if current.get("medication_reason") == "side_effects":
flags.append("side_effects_review")
if current.get("medication") == "partial":
flags.append("partial_medication_review")
return tuple(flags)
+166
View File
@@ -0,0 +1,166 @@
"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml.
Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2.
Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags.
Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3).
"""
from typing import Any
import yaml
from app.config import get_settings
from app.core.intake_questions import content
from app.core.questionnaire import IntakeDecision, IntakePolicy
from app.core.safety import fill_phones
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022).
PHQ9_REMISSION_BELOW = 5
PHQ9_IMPROVED_BY = 5
PCL5_RESPONSE_BY = 10
PCL5_IMPROVED_BELOW = 28
def texts() -> dict[str, Any]:
return yaml.safe_load(fill_phones((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8")))
def render(interpretation: dict[str, Any]) -> str:
"""Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any)."""
parts = [interpretation["title"], *interpretation.get("lines", [])]
if interpretation.get("disclaimer"):
parts.append(interpretation["disclaimer"])
return "\n\n".join(p for p in parts if p)
def _alcohol_zone(totals: dict) -> int | None:
full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {}
return full.get("zone") if full.get("complete") else short.get("zone")
def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]:
"""Screening findings of stage 1, in the order they are shown."""
found = []
phq = totals.get("phq9") or {}
if phq.get("complete"):
if phq["total"] >= policy.phq9_specialist_at:
found.append("depression_suspected")
elif phq["total"] >= policy.phq9_psychologist_at:
found.append("depression_mild")
pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {}
if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at:
found.append("ptsd_suspected")
elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at:
found.append("ptsd_signs")
zone = _alcohol_zone(totals)
if zone == 4:
found.append("alcohol_high")
elif zone in (2, 3):
found.append("alcohol_risky")
if (totals.get("cssrs_recent") or {}).get("risk") == "low":
found.append("suicide_low")
if answers.get("tbi") == 1:
found.append("tbi")
return found
def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict:
t = texts()
found = intake_findings(totals, answers, policy)
modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE]
head = t["intake"]["attention" if found else "ok"]
lines = [head["text"]]
lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found]
if decision.primary_route in t["routes"]:
lines.append(t["routes"][decision.primary_route])
names = ", ".join(t["modules"]["names"][m] for m in modules)
lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else ""))
return {
"kind": "intake",
"title": head["title"],
"lines": lines,
"codes": found or ["ok"],
"findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found],
"route": decision.primary_route,
"modules": modules,
"disclaimer": t["disclaimer"],
}
def _phq9_level(total: int) -> str:
band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"])
return band["code"]
def scale_interpretation(
kind: str,
totals: dict,
*,
baseline: int | None,
previous: int | None,
policy: IntakePolicy,
retriage_delta: int,
) -> dict:
"""A scheduled full scale: level, then the change against the start of observation and the last measurement."""
t = texts()["scales"]
if kind == "rus_audit_s":
zone = _alcohol_zone(totals)
result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"]
level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"]
line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level)
return {
"kind": kind,
"title": t[kind]["title"],
"lines": [line],
"codes": [f"zone_{zone}" if zone else "unknown"],
}
total = totals[kind]["total"]
pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below"
code = _phq9_level(total) if kind == "phq9" else pcl5_level
lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])]
change = None
if previous is not None and total - previous >= retriage_delta:
change = "worse"
elif baseline is not None:
if kind == "phq9":
if total < PHQ9_REMISSION_BELOW:
change = "remission"
elif baseline > 0 and total <= baseline / 2:
change = "response"
elif baseline - total >= PHQ9_IMPROVED_BY:
change = "improved"
else:
change = "stable"
else:
if baseline - total >= PCL5_RESPONSE_BY:
change = "response"
elif total < PCL5_IMPROVED_BELOW <= baseline:
change = "improved"
else:
change = "stable"
if change:
lines.append(t["change"][change])
return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]}
def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict:
t = texts()["diary"]
shown = [f for f in flags if f in t["flags"]]
return {
"kind": f"diary_{part}",
"title": t["saved"].format(part=t["parts"][part]),
"lines": [t["flags"][f] for f in shown],
"codes": list(flags),
}
def cssrs_interpretation(risk: str | None) -> dict:
t = texts()["cssrs"]
return {
"kind": "cssrs_since_contact",
"title": t["low" if risk == "low" else "none"],
"lines": [],
"codes": [risk or "none"],
}
+35
View File
@@ -0,0 +1,35 @@
"""Drug reference of the medication module (data/meds/meds_reference.json, built by scripts/build_meds_reference.py).
Read-only patient information collected from the Minzdrav clinical guidelines: when to expect the effect, how long
the course lasts, common side effects, red flags. It never suggests a drug or a dose — the app only follows the
doctor's prescription. Draft until reviewed by a psychiatrist (`status` in the file).
"""
import json
from functools import lru_cache
from typing import Any, Literal
from app.config import get_settings
Indication = Literal["depression", "ptsd"]
@lru_cache(maxsize=1)
def load_reference() -> dict[str, Any]:
path = get_settings().data_dir / "meds" / "meds_reference.json"
return json.loads(path.read_text(encoding="utf-8"))
def find_drug(name: str) -> dict[str, Any] | None:
"""Reference entry for a prescription's drug (INN in Russian or the latin id), or None if it is not covered."""
key = name.strip().split()[0].casefold() if name.strip() else ""
for drug in load_reference()["drugs"]:
if key in (drug["id"], drug["name_ru"].casefold()):
return drug
return None
def indication_for(modules: list[str], drug: dict[str, Any]) -> Indication | None:
"""The indication the patient is followed for, among those the drug has in the guidelines."""
preferred: list[Indication] = ["ptsd", "depression"] if "veteran" in modules else ["depression", "ptsd"]
return next((i for i in preferred if i in drug["indications"]), None)
+17 -6
View File
@@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@@ -49,7 +50,6 @@ class IntakePolicy(BaseModel):
phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
phq9_priority_at: int = Field(ge=0, le=27, strict=True)
pcl5_specialist_at: int = Field(ge=0, le=80, strict=True)
pcl5_priority_at: int = Field(ge=0, le=80, strict=True)
pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
@model_validator(mode="after")
@@ -64,13 +64,20 @@ class IntakePolicy(BaseModel):
raise ValueError("choose a total threshold OR a zone for alcohol expansion")
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
raise ValueError("PHQ-9 thresholds must be ordered")
if self.pcl5_specialist_at >= self.pcl5_priority_at:
raise ValueError("PCL-5 thresholds must be ordered")
return self
def demo_mode() -> bool:
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data.
It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the
real policy and the release gate in config/intake_runtime.yaml are never touched.
"""
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
def load_policy(path: Path | None = None) -> IntakePolicy:
path = path or get_settings().config_dir / "questionnaire.yaml"
path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml")
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
@@ -266,7 +273,9 @@ def evaluate_intake(
if c.positive:
flags.append("cssrs_positive")
flags.extend(c.conflicts)
if item9_positive or c.risk_lower_bound == "high":
# One crisis rule (ADR-013): a high C-SSRS risk. Item 9 alone is a crisis only while the screener is not complete;
# with a negative / low screener it is the «discrepancy» flag of the routing table, not the crisis screen.
if c.risk_lower_bound == "high" or (item9_positive and not c.complete):
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
@@ -317,9 +326,11 @@ def evaluate_intake(
# PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
phq_total = phq.total if phq.complete else 0
pcl_total = pcl.total if pcl.complete else 0
# Risk and flags first, scales second (clinical review 2026-10-04). A high PCL-5 alone is not a reason for a
# priority appointment: it goes to the planned specialist route.
if c.risk == "moderate":
primary = "urgent_psychiatrist"
elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at:
elif phq_total >= policy.phq9_priority_at:
primary = "priority_psychiatrist"
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
primary = "psychiatrist_or_psychotherapist"
+19
View File
@@ -395,6 +395,25 @@ def _crisis_cfg(path: Path = CRISIS_PATH) -> _CrisisCfg:
return _CrisisCfg.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
_PHONE_REF = re.compile(r"\{phone:([a-z_]+)\}")
def fill_phones(text: str, path: Path = CRISIS_PATH) -> str:
"""Replace `{phone:<resource>}` with the number from the single table in config/texts/crisis.yaml.
Every patient text and prompt refers to the table instead of repeating a number, so a number is changed in one
place. An unknown resource fails loudly: a wrong number on the crisis screen must never pass silently.
"""
resources = _crisis_cfg(path).resources
def number(m: re.Match[str]) -> str:
if m.group(1) not in resources:
raise ValueError(f"unknown crisis resource {m.group(1)!r}")
return resources[m.group(1)].phone
return _PHONE_REF.sub(number, text)
def _render(body: str, resources: list[CrisisResource], doctor_notified: str, grounding: str) -> str:
lines = [body.strip(), ""]
for r in resources:
+1
View File
@@ -5,6 +5,7 @@ The only module (besides registration code) that touches both schemas — and on
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
from app.db import intake as _intake # noqa: F401
from app.db import max_notifications as _max_notifications # noqa: F401
from app.db import scheduler as _scheduler # noqa: F401
from app.db.clinical import ClinicalBase
from app.db.identity import IdentityBase
+50
View File
@@ -0,0 +1,50 @@
"""MAX identifiers and delivery metadata live only in the identity DB; no medical message bodies."""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.identity import IdentityBase
from app.db.types import TZDateTime
class MaxBinding(IdentityBase):
__tablename__ = "max_bindings"
# No FK: anonymous web profiles intentionally have no patients row in identity.
token: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, unique=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
connected_at: Mapped[datetime] = mapped_column(TZDateTime)
class MaxLinkCode(IdentityBase):
__tablename__ = "max_link_codes"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
used_at: Mapped[datetime | None] = mapped_column(TZDateTime)
class MaxReceipt(IdentityBase):
__tablename__ = "max_webhook_receipts"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
received_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
class MaxDelivery(IdentityBase):
__tablename__ = "max_deliveries"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int] = mapped_column(BigInteger)
kind: Mapped[str] = mapped_column(String(24))
state: Mapped[str] = mapped_column(String(16), default="queued", index=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(TZDateTime)
next_attempt_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
lease: Mapped[str | None] = mapped_column(String(32))
error_code: Mapped[str | None] = mapped_column(String(32))
+3 -1
View File
@@ -70,7 +70,9 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.intake_runtime import intake_enabled
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
# Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
# person has no profile at all before it.
if after is None and intake_enabled():
return "intake.start"
if after in modules:
modules = modules[modules.index(after) + 1 :]
+193 -59
View File
@@ -4,12 +4,17 @@ import math
from dataclasses import asdict, replace
from zoneinfo import ZoneInfo
from app.core import interpretation
from app.core.intake_questions import (
DIARY_KINDS,
Question,
content,
cssrs_answers,
cssrs_since_contact_question,
diary_questions,
group_answers,
next_intake,
red_flag_category,
scale_question,
texts,
)
@@ -17,32 +22,36 @@ from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale
from app.core.types import AlertLevel
from app.db import repo
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Step, on_enter, state, trigger
from app.services import questionnaire as service
from app.services.followup import schedule_post_crisis_call
def allowed(ctx):
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
def side_effects_question():
"""FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3)."""
return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week")
def next_question(row):
a = row.answers
if row.kind == "intake":
return next_intake(a, load_policy(), row.details.get("sex"))
if row.kind == "diary":
for q in diary_questions(
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday()
):
if row.kind == "cssrs_since_contact":
return cssrs_since_contact_question(a)
if row.kind in DIARY_KINDS:
part = row.kind.removeprefix("diary_")
for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)):
if q.id in a:
continue
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"):
continue
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
continue
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
if q.id == "medication_reason" and a.get("medication") != "no":
continue
return q
return None
@@ -63,11 +72,7 @@ def next_question(row):
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
return q
if row.details.get("medication_active") and "side_effects" not in a:
return next(
q
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
if q.id == "side_effects"
)
return side_effects_question()
return None
@@ -89,7 +94,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
if profile:
profile.enhanced_monitoring = True
flags = list(service.decision(row).flags) if row else []
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0):
if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0):
flags.append("cssrs_since_contact_required")
if row:
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
@@ -102,6 +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
@@ -110,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(
@@ -132,8 +173,24 @@ async def advance(ctx, row):
a = row.answers
c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy()
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high":
# Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category.
for item_id, value in a.items():
if item_id.startswith("rf_") and value == 1:
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:
@@ -153,9 +210,6 @@ async def advance(ctx, row):
ctx.now,
params={"record_id": row.id},
)
if row.kind == "cssrs_since_contact":
# Recent and Since Last Contact are different instruments. No silent substitution.
return await manual_review(ctx, row, "cssrs_since_contact_required")
try:
q = next_question(row)
# Validate preserved short answers against a newly answered full-form item 1.
@@ -164,7 +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))
@@ -187,6 +241,8 @@ async def advance(ctx, row):
extra = {"decision": asdict(result), "totals": service.summary(row)}
if a.get("recent_trauma") == 1:
extra["recent_trauma"] = True
extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy)
await apply_modules(ctx, extra["interpretation"]["modules"])
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
await service.alert(
ctx.session,
@@ -196,54 +252,71 @@ async def advance(ctx, row):
ctx.now,
params={"record_id": row.id, **extra},
)
elif row.kind == "diary":
history = {
r.day: r.answers
for r in await service.records(ctx.session, ctx.token)
if r.kind == "diary" and r.status == "complete"
}
history[row.day] = a
flags = diary_flags(history, row.day)
elif row.kind == "cssrs_since_contact":
extra["totals"] = {"cssrs_recent": asdict(c)}
extra["interpretation"] = interpretation.cssrs_interpretation(c.risk)
elif row.kind in DIARY_KINDS:
# Morning and evening answers of one date form one diary day.
history: dict = {}
for r in await service.records(ctx.session, ctx.token):
if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id:
history.setdefault(r.day, {}).update(r.answers)
history.setdefault(row.day, {}).update(a)
flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False))
extra["flags"] = flags
for flag in flags:
await service.alert(
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
)
if flag == "phq9_due_mood":
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now)
if flag in DIARY_SCALE_TASKS:
sid = DIARY_SCALE_TASKS[flag]
await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now)
extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags)
else:
extra["totals"] = service.summary(row)
runtime = load_runtime()
if row.kind in ("phq9", "pcl5"):
sid = row.kind
total = extra["totals"][sid]["total"]
priority = getattr(policy, sid + "_priority_at")
# Only PHQ-9 has a priority threshold; PCL-5 alone never makes an appointment a priority.
priority = getattr(policy, sid + "_priority_at", None)
specialist = getattr(policy, sid + "_specialist_at")
extra["route"] = (
"priority_psychiatrist"
if total >= priority
if priority is not None and total >= priority
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
)
prior = next(
(
r
for r in reversed(await service.records(ctx.session, ctx.token))
if r.id != row.id
and r.status == "complete"
and r.version == row.version
and r.details.get("totals", {}).get(sid, {}).get("complete")
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
),
None,
)
earlier = [
r
for r in await service.records(ctx.session, ctx.token)
if r.id != row.id
and r.status == "complete"
and r.version == row.version
and r.details.get("totals", {}).get(sid, {}).get("complete")
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
]
prior = earlier[-1] if earlier else None
if prior:
change = total - prior.details["totals"][sid]["total"]
extra["delta"] = {
"baseline_record": prior.id,
"value": change,
"retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"),
"retriage": change >= getattr(runtime, sid + "_retriage_delta"),
}
extra["interpretation"] = interpretation.scale_interpretation(
sid,
extra["totals"],
baseline=earlier[0].details["totals"][sid]["total"] if earlier else None,
previous=prior.details["totals"][sid]["total"] if prior else None,
policy=policy,
retriage_delta=getattr(runtime, sid + "_retriage_delta"),
)
if row.kind == "rus_audit_s" and not row.details.get("sex"):
return await manual_review(ctx, row, "alcohol_sex_unknown")
if row.kind == "rus_audit_s":
extra["interpretation"] = interpretation.scale_interpretation(
"rus_audit_s", extra["totals"], baseline=None, previous=None, policy=policy, retriage_delta=0
)
if extra["totals"]["rus_audit"].get("zone") == 4:
extra["parallel_routes"] = ["narcologist"]
retriage = extra.get("delta", {}).get("retriage", False)
@@ -257,15 +330,56 @@ async def advance(ctx, row):
params={"record_id": row.id, **extra},
)
await service.finish(ctx, row, "complete", extra=extra)
shown = interpretation.render(extra["interpretation"]) if extra.get("interpretation") else texts()["done"]
if row.kind == "cssrs_since_contact" and not await done_today(ctx, "diary_evening"):
# The risk questions open the evening diary; continue straight into it.
step = await start(ctx, "diary_evening")
step.replies.insert(0, Reply(text=shown, kind="info"))
return step
from app.dialog.flows.core import menu_reply
replies = [menu_reply(ctx, texts()["done"])]
alcohol_total = extra.get("totals", {}).get("rus_audit", {})
if alcohol_total.get("zone") in (2, 3):
replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info"))
replies = [menu_reply(ctx, shown)]
if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"):
# The safety plan is never skipped because the risk signal came with the last question.
from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx))
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
# Diary flags that bring a full scale forward («Этап 2», p.4).
DIARY_SCALE_TASKS = {
"phq9_due_mood": "phq9",
"phq9_due_depression": "phq9",
"pcl5_due_ptsd": "pcl5",
"rus_audit_due_alcohol": "rus_audit_s",
}
async def apply_modules(ctx, modules):
"""Stage 1 result → diary modules of stage 2. Without a doctor the screening defines them; a doctor's modules
(diagnosis from ЕМИАС) are kept and only extended."""
profile = await ctx.profile()
if profile is None:
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id="", modules=list(modules))
return
current = list(profile.modules or [])
merged = [*current, *(m for m in modules if m not in current)] if profile.doctor_id else list(modules)
await repo.upsert_profile(ctx.session, token=ctx.token, modules=merged)
def local_day(ctx):
return ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
async def done_today(ctx, kind):
day = local_day(ctx)
return any(
r.kind == kind and r.day == day and r.status == "complete"
for r in await service.records(ctx.session, ctx.token)
)
async def start(ctx, kind="intake", task=None):
if not allowed(ctx) or crisis_open(ctx.data):
if task:
@@ -288,6 +402,8 @@ async def start(ctx, kind="intake", task=None):
if task:
return Step()
return Step(replies=[Reply(text=texts()["review"], kind="info")])
# A manual start takes over the scheduled task of the same form, so the task does not stay outstanding.
task = task or await service.outstanding_task(ctx.session, ctx.token, kind)
row = await service.create_record(ctx, kind, task=task)
return await advance(ctx, row)
@@ -316,14 +432,28 @@ async def checkin(ctx, event):
existing = await service.records(ctx.session, ctx.token)
if not any(r.kind == "intake" and r.status == "complete" for r in existing):
return await start(ctx)
day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing):
return Step(replies=[Reply(text=texts()["done"], kind="info")])
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
if risk_task.status in ("pending", "active"):
await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now)
return await start(ctx, "cssrs_since_contact", risk_task)
return await start(ctx, "diary")
return await next_diary(ctx)
async def next_diary(ctx):
"""The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS
«since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk)."""
day = local_day(ctx)
done = {r.kind for r in await service.records(ctx.session, ctx.token) if r.day == day and r.status == "complete"}
for kind in DIARY_KINDS:
if kind in done:
continue
profile = await ctx.profile()
if kind == "diary_evening" and profile and profile.enhanced_monitoring and "cssrs_since_contact" not in done:
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
return await start(ctx, "cssrs_since_contact", risk_task)
return await start(ctx, kind)
return Step(replies=[Reply(text=texts()["diary_done"], kind="info")])
@trigger("button:diary:next")
async def diary_button(ctx, event):
return await next_diary(ctx)
@state("intake")
@@ -365,12 +495,16 @@ async def collect(ctx, event: Event):
elif event.type == "text" and q.numeric:
try:
value = float(str(ctx.text).replace(",", "."))
valid = math.isfinite(value) and 0 <= value <= 24
valid = math.isfinite(value) and 0 <= value <= q.maximum
except ValueError:
pass
elif event.type == "text" and q.optional:
value, valid = str(ctx.text)[:2000], True
if not valid:
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)])
hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric")
return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)])
service.save_answer(row, q, value, ctx.now)
if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime":
# Behaviour since the last contact is recent by definition (see cssrs_since_contact_question).
service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True)
return await advance(ctx, row)
+9 -3
View File
@@ -47,6 +47,8 @@ from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa
QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
# A client sends this button when the person submits a free-text step without writing anything.
NO_COMMENT = "text:skip"
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
@@ -379,7 +381,8 @@ def _side_effect_code(code: str) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if code == "other":
return Step(
replies=[Reply(text=load_meds_texts().side_effects.describe)],
# kind="question": the engine waits for free text here (clients show an input, optional).
replies=[Reply(text=load_meds_texts().side_effects.describe, kind="question")],
next_state=SIDE_EFFECT_TEXT_STATE,
data={"med_se_ref": ref},
)
@@ -394,12 +397,15 @@ for _code in SIDE_EFFECT_CODES:
@state(SIDE_EFFECT_TEXT_STATE)
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
ref = ctx.data.get("med_se_ref")
if event.type == "button" and event.button == NO_COMMENT:
# The description is optional: «other» side effect without words.
return await _after_side_effects(ctx, ["other"], str(ref) if ref else None)
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event, {"med_se_ref": None})
facts = await ctx.gateway.extract_checkin(ctx.text)
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
ref = ctx.data.get("med_se_ref")
return await _after_side_effects(ctx, codes, str(ref) if ref else None)
@@ -429,7 +435,7 @@ async def _menu_question(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().question
if await current_prescription(ctx) is None:
return Step(replies=[menu_reply(ctx, t.no_prescription)])
return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE)
return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE)
async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
+3 -1
View File
@@ -448,7 +448,9 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
await save_checkin(ctx, **fields)
if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx))
if text is None and not fields and looks_like_question(ctx.text):
# A question is answered even if it also mentioned alcohol/side effects (those are recorded above).
# Quit intent gets the rule reaction (delayed-onset explanation + doctor notified) — no second answer.
if text is None and not facts.quit_intent and looks_like_question(ctx.text):
text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64)
if text is None:
text = await ctx.gateway.phrase("support")
+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"))))
+35
View File
@@ -0,0 +1,35 @@
"""ЕМИАС sign-in mock for the patient web app (SPEC §9 `esia_mock`: registration by the doctor's code).
Real ЕМИАС / ЕСИА OAuth needs a registered information system, so the prototype accepts the one-time connect
code the doctor hands out after the visit (323-FZ art. 36.2): code → patient token + attending doctor. The
class implements `IdentityProvider`, so the real OAuth client replaces it without touching the web channel.
It is deliberately NOT installed into `integrations.identity`: the Telegram channel verifies a code twice
(on `/start` and in the core flow), which a one-time redeeming provider would break. Lane B wires the
production provider for all channels (#14).
This module may use the identity DB: it is the registration boundary (guardrail #5).
"""
from app.core import clock
from app.db import identity_repo
from app.db.identity import ConnectCode
from app.db.session import get_identity_session
from app.integrations.base import VerifiedPerson
CODE_MAX_LEN = 32
class EmiasMock:
async def verify(self, auth_code: str) -> VerifiedPerson | None:
code = auth_code.strip().upper()
if not code or len(code) > CODE_MAX_LEN:
return None
async with get_identity_session() as s:
row = await s.get(ConnectCode, code)
if row is None:
return None
token = await identity_repo.redeem_connect_code(s, code=code, now=clock.now())
if token is None:
return None
return VerifiedPerson(token=token, doctor_id=row.doctor_id)
+201
View File
@@ -0,0 +1,201 @@
"""Registration boundary and durable MAX queue. Callers own the identity transaction.
Only this boundary joins a pseudonymous profile to a MAX recipient. Clinical modules never import it.
Queue rows contain a template name, never replies, scores, prescriptions or patient-auth tokens in URLs.
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import repo
from app.db.clinical import ScaleResult
from app.db.intake import QuestionnaireRecord
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.db.session import get_clinical_session
MAX_ATTEMPTS = 6
LEASE_SECONDS = 60
def digest(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
async def active_patient(token: str) -> bool:
"""Same consent/adult gate used by the scheduler, checked again at delivery time."""
async with get_clinical_session() as session:
state = await repo.get_dialog_state(session, token)
return bool(
await repo.get_profile(session, token)
and state
and state.data.get("consent") is True
and state.data.get("adult") is True
)
async def issue_code(session: AsyncSession, token: str, now: datetime, ttl: int) -> tuple[str, datetime]:
raw = secrets.token_urlsafe(32)
expires = now + timedelta(seconds=ttl)
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
session.add(MaxLinkCode(digest=digest(raw), token=token, expires_at=expires))
await session.flush()
return raw, expires
async def code_token(session: AsyncSession, raw: str, now: datetime) -> str | None:
row = await session.get(MaxLinkCode, digest(raw))
return row.token if row and row.used_at is None and row.expires_at > now else None
async def bind(session: AsyncSession, raw: str, user_id: int, now: datetime) -> str | None:
"""Atomically consume a code. Existing active identities cannot be silently reassigned."""
token = await code_token(session, raw, now)
if token is None:
return None
owner = await session.scalar(select(MaxBinding).where(MaxBinding.user_id == user_id))
binding = await session.get(MaxBinding, token)
if owner and owner.token != token and owner.enabled:
return None
if binding and binding.user_id != user_id and binding.enabled:
return None
used = await session.execute(
update(MaxLinkCode)
.where(MaxLinkCode.digest == digest(raw), MaxLinkCode.used_at.is_(None), MaxLinkCode.expires_at > now)
.values(used_at=now)
.returning(MaxLinkCode.token)
)
if used.scalar_one_or_none() is None:
return None
if owner and owner.token != token:
await session.delete(owner)
await session.flush()
if binding is None:
binding = MaxBinding(token=token, user_id=user_id, connected_at=now)
session.add(binding)
# Cancel stale deliveries even when the same account reconnects later.
await cancel_pending(session, token)
binding.user_id, binding.enabled, binding.connected_at = user_id, True, now
await session.flush()
return token
async def cancel_pending(session: AsyncSession, token: str) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.token == token, MaxDelivery.state.in_(("queued", "sending")))
.values(state="cancelled", lease=None)
)
async def disconnect(session: AsyncSession, token: str) -> None:
binding = await session.get(MaxBinding, token)
if binding:
binding.enabled = False
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
await cancel_pending(session, token)
async def enqueue(
session: AsyncSession, *, key: str, user_id: int, kind: str, now: datetime, token: str | None = None
) -> None:
if await session.get(MaxDelivery, key) is not None:
return
try:
async with session.begin_nested():
session.add(
MaxDelivery(id=key, user_id=user_id, token=token, kind=kind, created_at=now, next_attempt_at=now)
)
await session.flush()
except IntegrityError:
# Another worker may enqueue the same event between SELECT and INSERT.
if await session.get(MaxDelivery, key) is None:
raise
async def collect_completed(session: AsyncSession, now: datetime) -> None:
"""Recover completion events from committed clinical records, including after a restart.
No clinical hooks depend on identity. Read only IDs/dates, then enqueue a generic template.
A binding's activation time bounds history; repeated scans are idempotent.
"""
bindings = (await session.scalars(select(MaxBinding).where(MaxBinding.enabled.is_(True)))).all()
for binding in bindings:
if not await active_patient(binding.token):
continue
async with get_clinical_session() as clinical:
forms = (
await clinical.scalars(
select(QuestionnaireRecord.id).where(
QuestionnaireRecord.token == binding.token,
QuestionnaireRecord.status == "complete",
QuestionnaireRecord.completed_at >= binding.connected_at,
)
)
).all()
scales = (
await clinical.scalars(
select(ScaleResult.id).where(
ScaleResult.token == binding.token,
ScaleResult.complete.is_(True),
ScaleResult.ts >= binding.connected_at,
)
)
).all()
for source, ids in (("form", forms), ("scale", scales)):
for record_id in ids:
key = digest(f"completed:{binding.token}:{binding.connected_at.isoformat()}:{source}:{record_id}")
await enqueue(session, key=key, token=binding.token, user_id=binding.user_id, kind="completed", now=now)
async def claim(session: AsyncSession, now: datetime) -> MaxDelivery | None:
"""One lease per row; recovery after a worker crash is bounded by the lease deadline."""
ready = (MaxDelivery.state.in_(("queued", "sending"))) & (MaxDelivery.next_attempt_at <= now)
row = await session.scalar(
select(MaxDelivery)
.where(ready)
.order_by(MaxDelivery.created_at, MaxDelivery.id)
.with_for_update(skip_locked=True)
.limit(1)
)
if row is None:
return None
if row.attempts >= MAX_ATTEMPTS:
row.state, row.error_code, row.lease = "failed", "attempt_limit", None
return None
lease = secrets.token_hex(16)
result = await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, ready)
.values(
state="sending",
attempts=MaxDelivery.attempts + 1,
lease=lease,
next_attempt_at=now + timedelta(seconds=LEASE_SECONDS),
)
.returning(MaxDelivery.id)
)
if result.scalar_one_or_none() is None:
return None
await session.refresh(row)
return row
async def finish(
session: AsyncSession,
row: MaxDelivery,
*,
state: str,
now: datetime,
error: str | None = None,
retry_seconds: float = 0,
) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, MaxDelivery.lease == row.lease, MaxDelivery.state == "sending")
.values(state=state, lease=None, error_code=error, next_attempt_at=now + timedelta(seconds=retry_seconds))
)
+38 -7
View File
@@ -22,6 +22,7 @@ from app.config import Settings, get_settings
from app.core.types import ScrubbedText
from app.llm.agent.prompts import SYSTEM_PROMPT
from app.llm.agent.providers import (
GigaChatToolProvider,
MockAgentProvider,
OpenRouterProvider,
ProviderError,
@@ -34,6 +35,7 @@ from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200
NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"}
@@ -48,7 +50,7 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
s = settings or get_settings()
mode = s.chat_agent
if mode == "auto":
mode = "openrouter" if s.openrouter_api_key else "off"
mode = "gigachat" if s.gigachat_credentials else "openrouter" if s.openrouter_api_key else "off"
if mode == "openrouter":
if not s.openrouter_api_key:
log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled")
@@ -57,12 +59,32 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
if mode == "mock":
return MockAgentProvider()
if mode == "gigachat":
# TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call).
log.warning("chat agent: gigachat provider not implemented yet — disabled")
return None
if not s.gigachat_credentials:
log.warning("chat agent: gigachat selected without GIGACHAT_CREDENTIALS — disabled")
return None
return GigaChatToolProvider(s)
return None
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
"""One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError)."""
wire = _wire(messages) if provider.name == "openrouter" else messages # gigachat needs the tool `name`
try:
return await provider.chat(wire, TOOL_SPECS)
except ProviderError as exc:
log.warning("chat agent provider=%s retry after %s", provider.name, exc)
return await provider.chat(wire, TOOL_SPECS)
async def _patient_brief(ctx: ToolContext) -> str:
"""One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers)."""
rec = await ctx.record()
if rec is None or not rec.current_prescriptions:
return "\n\nТекущее назначение психиатра: нет данных."
drugs = ", ".join(p.drug for p in rec.current_prescriptions)
return f"\n\nТекущее назначение психиатра: {drugs}. «Мои таблетки» = это назначение."
def _wire(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip internal keys (tool `name`) before sending."""
return [{k: v for k, v in m.items() if k in _ALLOWED_MSG_KEYS} for m in messages]
@@ -82,14 +104,20 @@ async def answer_chat(
return None
ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)},
{"role": "user", "content": str(question)},
]
nudged = False
try:
async with asyncio.timeout(s.chat_agent_timeout_s * 2):
for _ in range(s.chat_agent_max_steps):
turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS)
turn = await _chat_with_retry(provider, messages)
if not turn.tool_calls:
if not (turn.content or "").strip() and not nudged:
# Some models (Gemini Flash) occasionally end with an empty message after tool results.
nudged = True
messages.append({"role": "user", "content": NUDGE})
continue
return _finish(turn.content or "", ctx, provider.name)
messages.append(
{
@@ -111,7 +139,10 @@ async def answer_chat(
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
return None
except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s", provider.name, type(exc).__name__ or "timeout")
log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
return None
except Exception: # noqa: BLE001 — the agent is optional: any bug must degrade to the extractive answer, not a 500
log.exception("chat agent provider=%s crashed", provider.name)
return None
+19 -5
View File
@@ -1,16 +1,27 @@
"""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 = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах.
Как работать:
1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда
вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос
о «моих таблетках», для текущего назначения). Для общих вопросов о лечении вызывай search_guidelines.
о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими
средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через
check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия).
Вопросы «когда подействует», «что будет, если бросить», «как принимать», побочные эффекты — смотри поле
instruction в check_drug_for_patient (инструкция ГРЛС) и дополняй search_guidelines.
Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул находки — назови их простыми словами и посоветуй обсудить с врачом
до приёма. Пустой список не значит «безопасно».
3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми
словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно».
Находки (findings) упоминай, когда вопрос о приёме, сочетании или безопасности препарата; на вопросы о
сроках действия, отмене, побочных эффектах отвечай по существу, без посторонних находок.
Про состав и непереносимости (intolerance_check) говори, только если о них спросили.
Про алкоголь бери текст из general_interactions.
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
«в вашей карте указано».
@@ -23,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)
+93 -1
View File
@@ -52,7 +52,10 @@ class OpenRouterProvider:
self._client = client
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
body = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1, "max_tokens": 900}
body: dict[str, Any] = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1}
body["max_tokens"] = 900
if not any(m["role"] == "tool" for m in messages):
body["tool_choice"] = "required" # never answer from the model's own memory: ground first
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
try:
if self._client is not None:
@@ -63,6 +66,13 @@ class OpenRouterProvider:
except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc
if resp.status_code != 200:
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
try:
err = resp.json().get("error", "")
detail = str(err.get("message", "") if isinstance(err, dict) else err)[:200]
except (ValueError, AttributeError):
detail = resp.text[:200]
log.error("openrouter http %s: %s", resp.status_code, detail)
raise ProviderError(f"http {resp.status_code}")
try:
msg = resp.json()["choices"][0]["message"]
@@ -81,6 +91,88 @@ class OpenRouterProvider:
return AssistantTurn(content=msg.get("content"), tool_calls=calls)
class GigaChatToolProvider:
"""GigaChat function calling (RF provider, 152-FZ compliant). Converts the OpenAI-style history the agent keeps:
assistant `tool_calls` → `function_call`, `tool` results → role `function` with the function name.
GigaChat returns at most one function call per turn — the agent loop handles that naturally."""
name = "gigachat"
def __init__(self, settings: Settings, client: Any | None = None) -> None:
self._settings = settings
self._client = client
def _get_client(self) -> Any:
if self._client is None:
from gigachat import GigaChat
s = self._settings
self._client = GigaChat(
credentials=s.gigachat_credentials,
scope=s.gigachat_scope,
model=s.gigachat_agent_model,
verify_ssl_certs=s.gigachat_verify_ssl,
timeout=s.chat_agent_timeout_s,
)
return self._client
@staticmethod
def to_messages(messages: list[dict[str, Any]]) -> list[Any]:
from gigachat.models import FunctionCall, Messages, MessagesRole
out = []
for m in messages:
role = m["role"]
if role == "system":
out.append(Messages(role=MessagesRole.SYSTEM, content=m["content"]))
elif role == "user":
out.append(Messages(role=MessagesRole.USER, content=m["content"]))
elif role == "assistant":
calls = m.get("tool_calls") or []
for c in calls or [None]:
fc = None
if c is not None:
fc = FunctionCall(name=c["function"]["name"], arguments=json.loads(c["function"]["arguments"]))
out.append(Messages(role=MessagesRole.ASSISTANT, content=m.get("content") or "", function_call=fc))
elif role == "tool":
out.append(Messages(role=MessagesRole.FUNCTION, name=m.get("name"), content=m["content"]))
return out
@staticmethod
def to_functions(tools: list[dict[str, Any]]) -> list[Any]:
from gigachat.models import Function
return [Function.model_validate(t["function"]) for t in tools]
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
from gigachat.models import Chat
chat = Chat(
messages=self.to_messages(messages),
functions=self.to_functions(tools),
function_call="auto",
temperature=0.1,
max_tokens=900,
)
try:
resp = await self._get_client().achat(chat)
except Exception as exc: # noqa: BLE001 — network/auth/HTTP errors of the SDK
raise ProviderError(type(exc).__name__) from exc
msg = resp.choices[0].message
calls = []
if msg.function_call is not None:
args = msg.function_call.arguments or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {}
calls.append(ToolCall(id=msg.function_call.name, name=msg.function_call.name, arguments=args))
usage = getattr(resp, "usage", None)
log.info("gigachat agent ok calls=%d tokens=%s", len(calls), getattr(usage, "total_tokens", None))
return AssistantTurn(content=msg.content or None, tool_calls=calls)
# ------------------------------------------------------------------------------------------------ offline mock
_DRUG_WORDS = re.compile(
+45 -5
View File
@@ -10,6 +10,7 @@ import re
from dataclasses import dataclass, field
from typing import Any
from app.integrations.base import integrations
from app.integrations.emias_record import PatientRecord, emias_records
from app.llm.agent.drug_safety import check_patient, find_drug, load_db
from app.llm.rag import retrieve
@@ -77,8 +78,9 @@ TOOL_SPECS: list[dict[str, Any]] = [
),
"parameters": {
"type": "object",
"properties": {"drug": {"type": "string"}},
"required": ["drug"],
"properties": {
"drug": {"type": "string", "description": "Название препарата; пусто — текущее назначение пациента"}
},
},
},
},
@@ -157,7 +159,12 @@ def _drug_info(ctx: ToolContext, name: str) -> dict[str, Any]:
async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name)
entry = find_drug(name) if name.strip() else None
if entry is None:
# "мои таблетки" / no name → the current psychiatric prescription from the record.
rec = await ctx.record()
current = [find_drug(p.drug) for p in (rec.current_prescriptions if rec else [])]
entry = next((e for e in current if e is not None), None) if not find_drug(name) else None
if entry is None:
return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."}
rec = await ctx.record()
@@ -165,15 +172,48 @@ async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
return {"found": True, "inn": entry.inn, "record": False, "findings": []}
ctx.cite(entry.source)
ctx.cite("ЕМИАС: медицинская карта")
findings = check_patient(entry, rec, load_db())
db = load_db()
findings = check_patient(entry, rec, db)
comp = entry.composition
intolerances = [a.name for a in rec.allergies]
intolerance_check = None
if comp is not None and intolerances:
hit = {t for a in rec.allergies for t in a.tags} & set(comp.excipient_tags)
intolerance_check = f"Непереносимость из карты ({', '.join(intolerances)}): " + (
"вещество ЕСТЬ в составе."
if hit
else "в примере состава этого вещества нет; состав зависит от производителя."
)
instruction = await _instruction(ctx, entry.inn)
return {
"found": True,
"inn": entry.inn,
"record": True,
"instruction": instruction, # ГРЛС excerpts: onset, side effects, discontinuation, administration
"findings": [f.model_dump() for f in findings],
"note": "Пустой список не означает, что препарат безопасен: решение о приёме принимает врач.",
"intolerance_check": intolerance_check,
"composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None,
"general_interactions": [i.text for i in entry.interactions], # incl. alcohol — for questions about it
"contraindications": entry.contraindications,
"note": "Пустой findings не означает, что препарат безопасен: решение о приёме принимает врач.",
}
INSTRUCTION_SECTIONS = ("onset", "side_effects", "discontinuation", "administration", "alcohol")
async def _instruction(ctx: ToolContext, inn: str) -> dict[str, str]:
"""ГРЛС excerpts for the drug (no doses in mg — see data/meds/grls)."""
try:
sections = await integrations.drug_info.sections(inn)
except Exception as exc: # noqa: BLE001
log.error("drug_info unavailable: %s", type(exc).__name__)
return {}
out = {s.section: s.text for s in sections if s.section in INSTRUCTION_SECTIONS}
if out:
ctx.cite(sections[0].source.split(",")[0] + ": инструкция к препарату")
return out
def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False)
+18 -6
View File
@@ -30,13 +30,25 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# In compose Postgres is required and healthy before the app starts; this branch is for running the
# API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable.
log.error("db init failed, continuing without db: %s", type(exc).__name__)
if settings.max_enabled:
raise # MAX requires persistent bindings and a delivery queue.
# Lanes fill these in (#13, #11); main.py itself stays frozen (#34).
await scheduler.start()
await telegram.start()
yield
await telegram.stop()
await scheduler.stop()
await dispose()
max_channel = None
try:
await telegram.start()
if settings.max_enabled:
from app.channels import max_bot
max_channel = max_bot
await max_channel.start()
await scheduler.start()
yield
finally:
await scheduler.stop()
if max_channel is not None:
await max_channel.stop()
await telegram.stop()
await dispose()
app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan)
+34 -10
View File
@@ -15,16 +15,20 @@ from sqlalchemy import select, text
from sqlalchemy.exc import SQLAlchemyError
from app.core import clock
from app.core.intake_questions import DIARY_KINDS
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
from app.core.questionnaire import load_policy
from app.db import repo
from app.db.clinical import PatientProfile
from app.db.intake import QuestionnaireTask
from app.db.session import get_clinical_session
from app.dialog.contracts import Event, Reply, get_engine
from app.dialog.contracts import Button, Event, Reply, get_engine
from app.dialog.texts import load_dialog_texts
from app.integrations.base import integrations
from app.scheduler import demo
from app.services import monitoring
from app.services import questionnaire as service
from app.services.followup import next_day_check_due
log = logging.getLogger(__name__)
_worker = None
@@ -41,20 +45,31 @@ async def plan():
state = await repo.get_dialog_state(session, profile.token)
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
continue
crisis = state.data.get("crisis")
if next_day_check_due(crisis, now):
# The day after a crisis the app asks how the person is, the safety plan one tap away.
texts = load_dialog_texts()
plan_button = Button(label=texts.safety_plan.title.rstrip(":"), data="safety_plan")
await integrations.outbox.push(
profile.token, [Reply(text=texts.reach_out.after_crisis, buttons=[[plan_button]], kind="question")]
)
state.data = {**state.data, "crisis": {**crisis, "followup_sent": now.isoformat()}}
rows = await service.records(session, profile.token)
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
if initial is None:
continue
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day])
diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"]
last_activity = max([*diaries, initial.day])
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
continue
if local.hour >= p.daily_hour and not any(
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows
):
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now)
done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"}
# Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2).
for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)):
if local.hour >= hour and kind not in done:
await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now)
last = {}
for row in rows:
if row.status != "complete":
@@ -82,12 +97,21 @@ async def plan():
after_crisis=crisis_date,
policy=p,
)
if scales or local.hour >= p.daily_hour:
# Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring.
if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done:
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now)
totals = initial.details.get("totals", {})
modules = set(profile.modules or [])
policy = load_policy()
# PHQ-9 only with depression or a positive PHQ-2; PCL-5 only with PTSD or a positive TSQ («Этап 2», p.3).
wanted = {
"phq9": "depression" in modules or bool(totals.get("phq9", {}).get("complete")),
"pcl5": "veteran" in modules or (totals.get("tsq", {}).get("total") or 0) >= policy.tsq_expand_at,
"rus_audit_s": True,
}
for sid in scales:
if sid == "pcl5" and initial.answers.get("trauma") != 1:
continue
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
if wanted[sid]:
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
async def deliver():
+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:
+17 -4
View File
@@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None):
sex = profile.sex if profile else None
if sex is None and prior:
sex = prior.details.get("sex")
day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date()
row = QuestionnaireRecord(
id=uuid4().hex,
token=ctx.token,
@@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None):
version=content()["version"],
policy_version=load_policy().version,
started_at=ctx.now,
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(),
day=day,
status="active",
answers={},
details={
@@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None):
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
"answer_metadata": {},
"task_id": task.id if task else None,
# Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day).
"day_index": (day - prior.day).days if prior else 0,
},
)
ctx.session.add(row)
@@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None):
return row
def save_answer(row, question, value, now):
def save_answer(row, question, value, now, *, derived=False):
"""Anchor recall windows to administration, never to the day of a resumed answer."""
day = row.day
starts = {
@@ -105,8 +108,8 @@ def save_answer(row, question, value, now):
"period": question.period,
"period_start": starts[question.period].isoformat() if question.period in starts else None,
"period_end": day.isoformat(),
"source": "patient",
"derived": False,
"source": "derived" if derived else "patient",
"derived": derived,
},
},
}
@@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now):
return row
async def outstanding_task(session, token, kind):
return await session.scalar(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token,
QuestionnaireTask.kind == kind,
QuestionnaireTask.status.in_(("pending", "active")),
)
)
async def lock_patient(session, token):
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
# use the engine's in-process lock. Consent and answers share this transaction.
+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
+73 -178
View File
@@ -1,189 +1,84 @@
version: diary-v2-2026-10-03
core:
- id: mood
text: 'Какое у Вас сегодня настроение?
# Stage 2 daily diary — «Этап 2: наблюдение после диагноза» (@Andrey, 2026-10-03), p.1–3.
# Every block comes from an instrument checked for frequent use; there are no own questions. No validated thresholds
# exist for daily data: the diary shows a trend and brings a full scale forward, routes come from full scales only.
# Modules follow the screening result of stage 1 (profile modules): depression → «depression», PTSD → «veteran».
version: diary-v3-2026-10-04
0 — самое плохое, 10 — самое хорошее.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
# Core — everyone. Morning block after waking (the sleep diary is built for it), evening block at 20:00.
morning:
- id: sleep_hours
text: Сколько часов Вы спали прошлой ночью?
source: Consensus Sleep Diary
text: Сколько часов Вы спали этой ночью?
numeric: true
- id: rested
text: 'Насколько Вы выспались?
0 — совсем не выспались, 4 — полностью выспались.'
max: 24
- id: sleep_quality
source: Consensus Sleep Diary
text: Как Вы оцениваете качество своего сна?
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- {value: 0, label: Очень плохо}
- {value: 1, label: Плохо}
- {value: 2, label: Удовлетворительно}
- {value: 3, label: Хорошо}
- {value: 4, label: Очень хорошо}
- id: alcohol_portions
source: принцип Timeline Followback; порция по RUS-AUDIT
text: |-
Сколько стандартных порций алкоголя Вы выпили вчера?
Одна порция — 100 мл вина, 250 мл пива или 30 мл крепкого алкоголя. Если не пили — введите 0.
numeric: true
max: 50
evening:
- id: mood
source: ежедневная оценка настроения (недельные средние коррелируют с PHQ-9)
text: |-
Как Вы оцениваете своё настроение сегодня?
0 — самое плохое, 10 — самое хорошее.
options:
- {value: 0, label: '0'}
- {value: 1, label: '1'}
- {value: 2, label: '2'}
- {value: 3, label: '3'}
- {value: 4, label: '4'}
- {value: 5, label: '5'}
- {value: 6, label: '6'}
- {value: 7, label: '7'}
- {value: 8, label: '8'}
- {value: 9, label: '9'}
- {value: 10, label: '10'}
- id: medication
text: Приняли ли Вы сегодня лекарства по схеме?
source: КР ПТСР — дневник приёма лекарств
text: Приняли ли Вы сегодня лекарства, назначенные врачом?
options:
- value: full
label: По схеме
- value: partial
label: Частично
- value: none
label: Нет
- value: not_prescribed
label: Не назначены
- {value: 'yes', label: Да}
- {value: 'no', label: Нет}
- {value: not_prescribed, label: Лекарства не назначены}
- id: medication_reason
text: Почему не приняли лекарства по схеме?
source: КР ПТСР — дневник приёма лекарств
text: Почему не приняли?
options:
- value: forgot
label: Забыл(а)
- value: side_effects
label: Побочные эффекты
- value: declined
label: Не хочу
- value: ran_out
label: Закончились
- value: other
label: Другая причина
- id: side_effects
text: Беспокоили ли Вас сегодня побочные эффекты лекарств?
options: &id001
- value: 1
label: Да
- value: 0
label: Нет
- id: substances
text: Употребляли ли Вы сегодня алкоголь или другие вещества?
options: *id001
- id: comment
text: Что хотите передать врачу?
optional: true
ptsd:
- id: intrusions
text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?'
options: &id002
- value: 0
label: Совсем нет
- value: 1
label: Немного
- value: 2
label: Умеренно
- value: 3
label: Сильно
- value: 4
label: Крайне сильно
- id: nightmares
text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?'
options: *id002
- id: avoidance
text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?'
options: *id002
- id: hypervigilance
text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?'
options: *id002
- id: irritability
text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?'
options: *id002
- {value: forgot, label: Забыл(а)}
- {value: side_effects, label: Побочные эффекты}
- {value: declined, label: Решил(а) не принимать}
- {value: ran_out, label: Закончились}
# Depression module: PHQ-9 items with the official Russian text (config/instruments/intake_forms.yaml), answer
# options adapted to one day. Three items a day in rotation: day 1 — items 1–3, day 2 — 4–6, day 3 — 7–9.
# Item 9 ≥1 → stop and the crisis protocol (stage 1 rule).
depression:
- id: interest
text: 'Насколько Вам сегодня было интересно и приятно что-то делать?
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: depressed
text: Насколько Вы сегодня чувствовали подавленность или безнадёжность?
options: *id002
- id: energy
text: 'Насколько у Вас сегодня были силы и энергия?
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: planned_activity
text: Сделали ли Вы сегодня хотя бы одно запланированное дело?
options: *id001
suicide:
- id: death_thoughts
text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить?
options: *id001
- id: thoughts_intensity
text: 'Если да — насколько сильными они были?
0 — совсем слабые, 10 — максимально сильные.'
form: phq9
items_per_day: 3
question: 'Насколько сегодня Вас беспокоило:'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: hope
text: 'Насколько Вы верите, что станет лучше?
- {value: 0, label: Совсем нет}
- {value: 1, label: Немного}
- {value: 2, label: Умеренно}
- {value: 3, label: Сильно}
0 — совсем не верю, 10 — полностью верю.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: burden
text: Чувствовали ли Вы себя обузой для близких?
options: *id001
weekly: true
# PTSD module: short 4-item PCL-5 (Price et al., 2016), one item per cluster B, C, D, E; «today» instead of «the
# past month», options 0–4 of PCL-5. Item numbers must still be checked against the article (PDF p.7, «Медику»).
ptsd:
form: pcl5
items: [pcl5_1, pcl5_7, pcl5_10, pcl5_18]
question: 'Насколько сильно сегодня Вас беспокоили:'
+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
+17 -7
View File
@@ -1,17 +1,27 @@
version: intake-v2-2026-10-03
version: intake-v3-2026-10-04
enabled: false
approval: null
# Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness.
content_review: null
contact_review: null
timezone: Europe/Moscow
daily_hour: 19
mood_baseline_days: 7
mood_bad_days: 3
mood_drop: 3
sleep_bad_days: 3
# Stage 2 diary («Этап 2: наблюдение после диагноза»): morning block after waking, evening block at 20:00.
morning_hour: 8
evening_hour: 20
# Personal norm = the first `norm_days` of observation; compared with the mean of the last `week_days`.
# Flag numbers are the team's candidates (PDF «Этап 2», p.4) — agree with the doctor.
norm_days: 14
week_days: 7
min_norm_values: 7
min_week_values: 4
mood_drop: 3 # weekly mood mean fell ≥3 below the norm → unscheduled PHQ-9
depression_item_rise: 1.0 # weekly mean of a PHQ-9 diary item rose ≥1 above its norm → PHQ-9 earlier
ptsd_sum_rise: 3.0 # weekly mean of the 4-item PCL-5 sum rose ≥3 above the norm → PCL-5 earlier
sleep_bad_days: 3 # <4 h or «очень плохо» three nights in a row → flag to the doctor
sleep_hours_below: 4
nightmares_at: 3
alcohol_heavy_portions: 6 # ≥6 standard drinks a day = heavy day (volumes of RUS-AUDIT item 3)
alcohol_heavy_days: 2 # heavy days in a week → full RUS-AUDIT
adherence_below: 0.8 # share of days with medication taken over a week
missed_medication_days: 3
high_risk_silence_days: 2
medication_days: [7, 14, 28]
+2 -2
View File
@@ -1,7 +1,8 @@
# Review candidate from the supplied MVP PDF, not a signed clinical protocol.
version: mvp-v2-review-2026-10-03
approval: null
cssrs_positive_policy: pending
# C01 decided by the team after the clinical review (2026-10-04, ADR-013): crisis screen at HIGH risk only.
cssrs_positive_policy: graded
rus_audit_s_expand_from_zone: null
phq2_expand_at: 2
tsq_expand_at: 6
@@ -9,5 +10,4 @@ phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3
+20
View File
@@ -0,0 +1,20 @@
# DEMO ONLY (INTAKE_DEMO=1 + demo clock, synthetic data). NOT a clinical approval: the real policy is
# config/questionnaire.yaml and stays closed until the doctor's decisions C01–C09 (docs/clinical/README.md).
# Values follow the MVP PDF so the demo can reach a result and stage 2:
# C01 → graded: low C-SSRS risk shows the safety plan and continues (PDF p.11, routing table);
# C02 → full RUS-AUDIT from zone II of RUS-AUDIT-S (WHO zones, Bunova 2022).
version: mvp-v2-demo-2026-10-04
approval:
reviewer: "DEMO — синтетические данные, не клиническое согласование"
decision_reference: "INTAKE_DEMO"
approved_at: "2026-10-04T00:00:00+03:00"
policy_version: mvp-v2-demo-2026-10-04
cssrs_positive_policy: graded
rus_audit_s_expand_from_zone: 2
phq2_expand_at: 2
tsq_expand_at: 6
phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_behavior_flag_at: 3
+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}.
+4
View File
@@ -14,7 +14,11 @@ sex: "Укажите пол для интерпретации алкогольн
tbi: "Были ли у Вас контузия или черепно-мозговая травма?"
psychotropics: "Принимаете ли Вы сейчас психотропные препараты?"
missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже."
side_effects: "Беспокоили ли Вас за последнее время побочные эффекты лекарств?"
numeric_portions: "Введите число стандартных порций, например 0 или 2."
diary_done: "Дневник на сегодня уже заполнен. Спасибо!"
periods:
since_last_contact: "С момента Вашего последнего ответа на эти вопросы."
past_month: "За последний месяц."
lifetime: "За всю жизнь."
past_3_months: "За последние три месяца."
+8
View File
@@ -0,0 +1,8 @@
connected: "Уведомления подключены. Здесь будут появляться напоминания с сайта. Отключить их можно командой /stop."
not_connected: "Чтобы получать уведомления, откройте сайт и подключите MAX в настройках уведомлений."
invalid: "Не удалось подключить уведомления. Создайте новую ссылку на сайте. Если MAX уже подключён к другому аккаунту, сначала отключите прежнюю привязку."
stopped: "Уведомления отключены. Подключить их снова можно по новой ссылке с сайта."
help: "Этот бот присылает уведомления. Пройти тесты, посмотреть результаты и продолжить общение можно на сайте. Команда /stop отключает уведомления."
notification: "На сайте есть напоминание для вас. Откройте личный кабинет, чтобы продолжить."
completed: "Прохождение теста завершено. Подробности доступны в личном кабинете на сайте."
open_site: "Открыть сайт"
+120
View File
@@ -0,0 +1,120 @@
# Patient-facing interpretation of questionnaire results (ADR-008: texts in config). The rules that pick a text are
# deterministic (app/core/interpretation.py) and use the thresholds of config/questionnaire*.yaml, PDF «Опросники для
# MVP» (stage 1) and «Этап 2» (follow-up). The app never diagnoses: every result is a screening result.
disclaimer: "Это результат скрининга, а не диагноз. Диагноз ставит врач после очной консультации."
intake:
ok:
title: "Признаков депрессии и ПТСР не выявлено"
text: "По Вашим ответам сейчас нет признаков, которые требуют обращения к специалисту. Можно спать спокойно: продолжайте короткий дневник — так Вы заметите изменения раньше."
attention:
title: "Есть признаки, которые стоит обсудить со специалистом"
text: "Это не диагноз, но ответы похожи на те, что бывают при состояниях ниже. Ниже — что именно мы заметили и что делать дальше."
findings:
depression_suspected:
title: "Предположение депрессии"
text: "Ответы указывают на выраженные симптомы депрессии за последние 2 недели. Включаем модуль «Депрессия» в ежедневном дневнике."
depression_mild:
title: "Лёгкие симптомы депрессии"
text: "Есть отдельные симптомы сниженного настроения. Они часто проходят сами, но за ними стоит последить."
ptsd_suspected:
title: "Склонность к ПТСР"
text: "Ответы указывают на выраженные последствия пережитого травматического события за последний месяц. Включаем модуль «ПТСР» в ежедневном дневнике."
ptsd_signs:
title: "Отдельные признаки посттравматического стресса"
text: "После травматического события есть отдельные реакции, но их выраженность ниже порога вероятного ПТСР."
alcohol_risky:
title: "Рискованное употребление алкоголя"
text: "Употребление алкоголя выше безопасного уровня. Стоит сократить его и обсудить это с врачом."
alcohol_high:
title: "Высокий риск зависимости от алкоголя"
text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом."
suicide_low:
title: "Тяжёлые мысли"
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия {phone:helpline}."
tbi:
title: "Контузия или ЧМТ"
text: "Рекомендуем консультацию невролога."
routes:
urgent_psychiatrist: "Срочно свяжитесь с психиатром — ориентир: в течение 24 часов."
priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни."
psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту."
psychologist: "Рекомендуем консультацию психолога."
self_help: "Достаточно самопомощи и короткого ежедневного дневника."
modules:
title: "Что дальше"
diary: "Каждый день — короткий дневник: 3 вопроса утром и 2 вечером{modules}. Полные опросники — по расписанию."
names:
depression: "«Депрессия»"
veteran: "«ПТСР»"
with: ", плюс модуль {names}"
scales:
phq9:
title: "Опросник PHQ-9 (депрессия)"
levels:
minimal: "минимальные симптомы"
mild: "лёгкие симптомы"
moderate: "умеренные симптомы"
moderately_severe: "умеренно тяжёлые симптомы"
severe: "тяжёлые симптомы"
pcl5:
title: "Опросник PCL-5 (ПТСР)"
levels:
below: "ниже порога вероятного ПТСР"
probable: "выше порога вероятного ПТСР"
rus_audit_s:
title: "Опросник RUS-AUDIT-S (алкоголь)"
levels:
zone_1: "относительно низкий риск"
zone_2: "опасное употребление"
zone_3: "пагубное употребление"
zone_4: "риск зависимости"
unknown: "нельзя оценить без указания пола"
score: "{title}: {total} — {level}."
change:
remission: "Это ремиссия по шкале — отличный результат."
response: "По сравнению с началом наблюдения — ответ на лечение."
improved: "По сравнению с началом наблюдения — клинически значимое улучшение."
stable: "По сравнению с началом наблюдения существенных изменений нет."
worse: "По сравнению с прошлым замером состояние ухудшилось. Врач получит сигнал и пересмотрит план."
diary:
saved: "Спасибо, дневник за {part} сохранён."
parts:
morning: "утро"
evening: "вечер"
flags:
phq9_due_mood: "Настроение за неделю заметно ниже Вашей обычной нормы — предложим полный опросник PHQ-9 раньше срока."
phq9_due_depression: "Симптомы депрессии за неделю выше Вашей нормы — предложим полный опросник PHQ-9 раньше срока."
pcl5_due_ptsd: "Симптомы ПТСР за неделю выше Вашей нормы — предложим полный опросник PCL-5 раньше срока."
sleep_review: "Несколько ночей подряд очень плохой или короткий сон. Врач увидит это в сводке."
alcohol_on_medication: "Много алкоголя при приёме лекарств опасно. Врач получит отметку."
rus_audit_due_alcohol: "За неделю было несколько дней с большим количеством алкоголя — предложим опросник об алкоголе."
medication_contact: "Несколько дней подряд без лекарств. Не прекращайте приём без врача — мы сообщим ему."
adherence_review: "За неделю лекарства пропущены чаще, чем в 1 из 5 дней. Обсудите это с врачом."
side_effects_review: "Вы отметили побочные эффекты — врач получит отметку."
cssrs:
none: "Спасибо за ответы."
low: "Спасибо, что ответили честно. Откройте план безопасности, если станет тяжело."
kinds:
intake: "Первичный опрос"
diary_morning: "Дневник — утро"
diary_evening: "Дневник — вечер"
cssrs_since_contact: "Вопросы о безопасности"
phq9: "PHQ-9 по расписанию"
pcl5: "PCL-5 по расписанию"
rus_audit_s: "Алкоголь по расписанию"
diary: "Дневник"
status:
complete: "пройден"
review_required: "остановлен для оценки специалиста"
interrupted: "прерван: открыта экстренная помощь"
active: "в процессе"
paused: "на паузе"
cancelled: "отменён"
+11 -8
View File
@@ -42,7 +42,10 @@ def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str:
f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>'
"</div>"
)
return f'<div class="mt-doses" style="grid-template-columns:{cols}">{"".join(cells)}</div>{legend}'
return (
f'<div class="mt-dose-scroll"><div class="mt-doses" style="grid-template-columns:{cols}">'
f"{''.join(cells)}</div></div>{legend}"
)
def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str:
@@ -181,7 +184,7 @@ def render(card: PatientCard, today: date | None) -> None:
main, side = st.columns([1.75, 1], gap="large")
with main:
with st.container(border=True):
with st.container(border=True, key="panel-scales"):
c1, c2 = st.columns(2)
for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")):
fig = charts.scale_figure(card.series, scale_id, title, start)
@@ -191,22 +194,22 @@ def render(card: PatientCard, today: date | None) -> None:
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}")
st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.")
with st.container(border=True):
with st.container(border=True, key="panel-wellbeing"):
fig = charts.wellbeing_figure(card.series, start)
if fig is None:
st.caption("Ежедневных чек-инов пока нет")
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing")
st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.")
with st.container(border=True):
with st.container(border=True, key="panel-doses"):
st.html('<div class="mt-panel-title">Календарь приёма</div>')
st.html(dose_strip_html(grid, start))
with side:
with st.container(border=True):
with st.container(border=True, key="panel-alerts"):
_alerts_feed(card.alerts, today)
with st.container(border=True):
with st.container(border=True, key="panel-summary"):
_summary(card.summary)
with st.container(border=True):
with st.container(border=True, key="panel-safety"):
_safety_plan(card.safety_plan)
with st.container(border=True):
with st.container(border=True, key="panel-prescriptions"):
_prescriptions(card)
+1 -1
View File
@@ -51,7 +51,7 @@ def render_playback_panel(persona: dict[str, Any]) -> None:
token = persona["token"]
scenario, source = w.scenario_for(persona)
state = w.playback_state(token)
st.subheader("🎬 Сценарий демо")
st.subheader("Сценарий демо")
_scenario_source_controls(persona, source)
if scenario.errors:
with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days):
+2 -3
View File
@@ -368,10 +368,9 @@ def render_doctor_panel(highlight_token: str | None, *, key: str = "doc") -> Non
]
for a in fresh_acute[:1]:
st.markdown(
"<div style='background:#c62828;color:#fff;padding:18px 20px;border-radius:12px;"
"font-size:1.35rem;font-weight:700;margin-bottom:8px'>"
"<div class='mt-banner t-red mt-console-alert'><div>"
f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>"
f"<span style='font-size:1.05rem;font-weight:500'>{_escape(a.get('reason', ''))}</span></div>",
f"<div class='mt-sub'>{_escape(a.get('reason', ''))}</div></div></div>",
unsafe_allow_html=True,
)
c1, c2 = st.columns(2)
@@ -68,7 +68,7 @@ def _reset() -> None:
def _result(res: ConnectPatientOut, name: str) -> None:
link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501"))
with st.container(border=True):
with st.container(border=True, key="panel-connect-result"):
st.html(
f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>'
'<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит '
@@ -108,7 +108,7 @@ def main() -> None:
st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,))
left, right = st.columns(2, gap="large")
with left, st.container(border=True):
with left, st.container(border=True, key="panel-connect-person"):
st.html('<div class="mt-panel-title">Пациент</div>')
st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО")
a, b = st.columns(2)
@@ -128,7 +128,7 @@ def main() -> None:
key="cp_modules",
format_func=lambda m: h.MODULE_LABELS[m],
)
with right, st.container(border=True):
with right, st.container(border=True, key="panel-connect-prescription"):
st.html('<div class="mt-panel-title">Назначение</div>')
d1, d2 = st.columns([1.4, 1])
with d1:
+17 -7
View File
@@ -14,21 +14,31 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
from console.playback_ui import render_playback_panel # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
st.html(theme.CSS)
st.html(
ui.page_header(
"Демо-консоль",
"Чат пациента и сценарий сопровождения. Прототип на синтетических данных.",
kicker="Демонстрация",
)
)
persona = w.select_persona()
w.render_connect_hero()
w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}")
st.title("Демо-консоль")
st.caption("Прототип на синтетических данных. Время — виртуальные демо-часы (ADR-005).")
w.render_clock_bar(persona, key="console-clock")
st.divider()
with st.container(border=True, key="panel-console-clock"):
w.render_clock_bar(persona, key="console-clock")
chat_col, play_col = st.columns([1.05, 1], gap="large")
with chat_col:
st.subheader(f"💬 Чат пациента — {persona.get('display_name') or persona['token']}")
with chat_col, st.container(border=True, key="panel-console-chat"):
st.subheader(f"Чат пациента — {persona.get('display_name') or persona['token']}")
w.render_chat(persona["token"], height=560, key="console-chat")
with play_col:
with play_col, st.container(border=True, key="panel-console-scenario"):
render_playback_panel(persona)
+10 -10
View File
@@ -15,12 +15,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed")
st.markdown(
"<style>.block-container{padding-top:1.2rem;max-width:100%} "
"[data-testid='stMarkdownContainer'] p, [data-testid='stMarkdownContainer'] li{font-size:1.08rem}</style>",
unsafe_allow_html=True,
)
st.html(theme.CSS)
st.html(ui.page_header("Сплит-экран", "Пациент и врач: сопровождение в реальном времени.", kicker="Демонстрация"))
persona = w.select_persona()
w.show_flash()
@@ -44,15 +44,15 @@ if clicked and nxt is not None:
st.rerun()
left, right = st.columns([1.1, 1], gap="large")
with left:
with left, st.container(border=True, key="panel-split-patient"):
url = w.patient_web_url()
if url:
st.markdown(f"#### 📱 Пациент — {persona.get('display_name') or token}")
st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
st.iframe(url, height=760)
st.caption(f"Веб-приложение пациента: {url}")
else:
st.markdown(f"#### 💬 Пациент — {persona.get('display_name') or token}")
st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
w.render_chat(token, height=600, key="split-chat")
with right:
st.markdown("#### 🩺 Кабинет врача — д-р Смирнова Е. А.")
with right, st.container(border=True, key="panel-split-doctor"):
st.markdown("#### Кабинет врача — д-р Смирнова Е. А.")
w.polled_doctor_panel(token, key="split-doc")
+23 -6
View File
@@ -138,8 +138,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-empty p {{ font-size:.85rem; margin:8px 0 0; }}
/* ---------- chips, badges, bars ---------- */
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px;
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }}
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 5px; margin-right: 4px;
background: #edf3ee; color: #38634b; font-weight: 500; }}
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
border-radius: 5px; white-space: normal; }}
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
@@ -166,7 +166,7 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-banner b {{ color: inherit; }}
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
box-shadow: 0 0 0 4px #faeeee; }}
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650;
.mt-panel-title {{ font-size: .82rem; text-transform: uppercase; letter-spacing: .03em; font-weight: 550;
color: var(--ink-3); margin: 2px 0 8px; }}
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
.mt-alert.t-red {{ border-left-color: var(--red); }}
@@ -175,9 +175,9 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }}
.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }}
.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }}
.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink);
line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }}
.mt-summary {{ background: #f4f6f2; border:1px solid var(--line); border-radius: 12px; padding: 14px 16px;
color: var(--ink); line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--ink-2); font-size: .76rem; margin-top: 8px; }}
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
@@ -195,6 +195,21 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
vertical-align: -1px; }}
/* ---------- internal pages ---------- */
div[class*="st-key-panel-"] {{ background:var(--surface); border:1px solid var(--line); border-radius:12px;
padding:20px; }}
[data-testid="stExpander"] {{ background:var(--surface); border-color:var(--line); border-radius:12px; }}
[data-testid="stChatMessage"] {{ background:#f4f6f2; border:1px solid var(--line); border-radius:12px; }}
[data-testid="stChatInput"] {{ background:var(--surface); border:1px solid var(--line); border-radius:8px; }}
[data-testid="stNumberInputContainer"], [data-testid="stDateInput"] input {{
background:var(--surface); color:var(--ink); border-color:var(--line); border-radius:8px; }}
[data-testid="stApp"] [data-variant="pills"][data-selected] {{ background:#edf3ee; color:#285e4e;
border-color:#b4c6ba; }}
.mt-console-alert {{ font-size:1.1rem; font-weight:600; }}
.mt-console-alert .mt-sub {{ color:var(--red-ink); font-weight:400; }}
.mt-dose-scroll {{ overflow-x:auto; padding-bottom:4px; }}
.mt-doses {{ min-width:max-content; }}
/* ---------- connect result ---------- */
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
font-variant-numeric: tabular-nums; }}
@@ -222,6 +237,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }}
.mt-row-details {{ grid-template-columns:1fr; gap:12px; }}
.mt-list-footer {{ flex-wrap:wrap; }}
.mt-head .mt-side {{ text-align:left; }}
div[class*="st-key-panel-"] {{ padding:16px; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }}
}}
@media (prefers-reduced-motion: reduce) {{
File diff suppressed because it is too large Load Diff
+17
View File
@@ -3,6 +3,23 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`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`, помеченный «не клиническое согласование», а реальная политика не меняется.
**Причина:** пациенту нужен понятный итог и путь дальше, а не тупик; решения о риске и маршруте при этом остаются детерминированными (ADR-007).
**Последствия:** отступления от присланного PDF сохранены по `docs/clinical/README.md`: TSQ вместо PC-PTSD-5 (v2 PDF), RUS-AUDIT-S — пункты 3/9/10 (ВОЗ). Открыто: номера 4 пунктов короткой PCL-5 (Price et al., 2016), официальная форма C-SSRS Since Last Contact (сейчас — тексты Recent с периодом «с последнего ответа»), пороги «роста» модулей дневника — согласовать с врачом.
## 2026-10-04 — ADR-011: База знаний (RAG) — лексический поиск BM25 и экстрактивные ответы с цитатами
**Контекст:** агенту нужны ответы по клиническим рекомендациям (КР ПТСР 2023, КР Депрессия 2024) со ссылкой на источник (#60). Эмбеддинги требуют либо сети (GigaChat Embeddings), либо тяжёлых зависимостей (torch, модель на сотни МБ). При этом демо должно работать офлайн (ADR-004), а `pyproject.toml` заморожен (#34).
**Решение:** PDF переводятся в txt один раз, офлайн (`docs/source/rag/pdf_to_txt.py`: таблицы → markdown, маркеры страниц, текст блок-схем перенесён вручную). Корпус лежит в `config/rag/`. Поиск — BM25 по основам слов (свой стеммер Snowball) с синонимами и переранжированием по самому плотному отрывку. Ответ **экстрактивный**: показываются только предложения из найденного фрагмента с цитатой «[КР ПТСР 2023, стр. N]». LLM может лишь упростить их для пациента, а перефразировка с новым содержанием отбраковывается. Если отрывки покрывают слишком малую долю вопроса (`min_coverage`), ответ — «ответа нет, уточните у врача». Пациенту не показываются предложения о назначении и дозах. Внутренние документы команды (`audiences: [team]`) не доходят ни до пациента, ни до врача.
+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); номер меняется в одном месте.
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
или уведомлений службам/врачу не выполнялось.
+142
View File
@@ -0,0 +1,142 @@
# RAG по клиническим рекомендациям Минздрава
> Статус: черновик подхода, 2026-10-04. Реализация — отдельной задачей (lane B, `app/llm/rag.py` по SPEC §структура).
> Пример исходника: КР «Посттравматическое стрессовое расстройство», ID 753, 2023 (Российское общество психиатров).
## Принцип
Файлов мало, глубина нужна максимальная, поэтому мы **не** делаем классический RAG «нарезка по N токенов → эмбеддинги → top-k».
Каждая КР один раз превращается в **размеченную и вычитанную базу знаний** из смысловых единиц. Поиск по ней почти тривиален, а
каждый ответ проверяется по цитате.
## 1. Кому отвечает RAG
КР написаны для врачей: дозы, препараты off-label (`#`), показания к госпитализации. Пересказывать это пациенту нельзя —
это противоречит нашим guardrails (`app/llm/med_qa.py`: о препаратах — только по инструкции ГРЛС и без доз).
| Потребитель | Что можно отдавать из КР |
|---|---|
| Пациент (бот) | Только `Приложение В. Информация для пациента` и психообразование. Без доз и названий препаратов |
| Врач (дашборд) | Всё, с цитатой, уровнем убедительности (УУР) и достоверности доказательств (УДД) |
| Команда (design-time) | Обоснование порогов в `config/` (TSQ ≥ 6 — Прил. Г1, градации шкалы Бека — Г10): ссылки на источник, а не runtime-RAG |
У каждой единицы есть поле `audience`. Фильтр по нему применяется **до** поиска, а не через промпт LLM.
## 2. Разбивка на смысловые единицы
Структура КР Минздрава стабильна, используем её как схему.
| `type` | Пример из КР ПТСР | Как хранить |
|---|---|---|
| `recommendation` | «Пациентам с ПТСР … рекомендуется начинать терапию с СИОЗС…» | Тезис + УУР/УДД + комментарий + номера источников |
| `definition` / `criteria` | Критерии МКБ-10 А–Д, кластеры МКБ-11 | По одному критерию на единицу |
| `dose_row` | Таблица «Рекомендуемые дозы» (Прил. А3) | Строка: препарат, диапазон, `off_label`, `zhnvlp` (**) |
| `scale` | TSQ, CAPS, IES-R, BHS, TOP-8 (Прил. Г1–Г11) | Пункты + ключ интерпретации как данные |
| `algorithm` | Прил. Б и Б1 (в PDF это картинки) | Переписать вручную в шаги и ветвления |
| `patient_info` | Прил. В | Абзацы с `audience: patient` |
| `quality_criterion` | «Критерии оценки качества медицинской помощи» | По строке таблицы |
Пример единицы:
```json
{
"id": "kr753.3.2.rec01",
"doc": {"kr_id": 753, "title": "ПТСР", "approved": "2023", "revise_by": "2025"},
"path": ["3. Лечение", "3.2. Психофармакотерапия", "Терапия первой линии"],
"type": "recommendation",
"text": "Пациентам с ПТСР в качестве препаратов первой линии ... рекомендуется начинать терапию с СИОЗС ...",
"comment": "После установления диагноза ... эффективность оценивается через 4–6 недель ...",
"uur": "A",
"udd": 1,
"refs": [51, 53, 302],
"drugs": ["пароксетин", "сертралин", "флуоксетин", "венлафаксин"],
"contains_dose": true,
"off_label": ["флуоксетин", "венлафаксин"],
"audience": ["doctor"],
"pages": [17, 18]
}
```
Комментарий хранится вместе с тезисом: по отдельности они теряют смысл, а основная глубина КР — именно в комментариях.
## 3. Пайплайн подготовки: автоматика, затем вычитка
Файлов мало, поэтому ручная вычитка реальна и даёт больше всего качества. Повторяем практику `docs/clinical/`: хеши
исходников, `review-status.json`.
1. **Источник.** Первоисточник — cr.minzdrav.gov.ru, а не перевёрстки (пример выше — перевёрстка с disuria.ru). Храним sha256.
2. **Очистка.** Убрать колонтитулы («Страница N из 117», рекламу сайта), склеить переносы и разрывы страниц
(слипания вида «клиникопсихопатологических»), собрать таблицы, разорванные между страницами.
3. **Парсер по шаблону.** Абзац «- Пациентам…» до строки «Уровень убедительности рекомендаций X (уровень достоверности
доказательств N)» — одна единица `recommendation`. Следующий за ней блок «Комментарии:» — её поле `comment`.
4. **Список литературы не индексируем.** Это шум для поиска; номера источников остаются в `refs`.
5. **Ручная вычитка.** В первую очередь — единицы для пациента и всё, где есть числа. В КР ПТСР уже видны ошибки самого
источника, которые автоматика пропустит:
- в таблице критериев качества перепутаны колонки УДД и УУР;
- раздел 6.1 встречается дважды;
- опечатка «ценить суицидальный риск» вместо «оценивать».
6. **Словарь аббревиатур и синонимов** — из списка сокращений в начале каждой КР. Нужен для расширения запросов:
СИОЗС ↔ сертралин/пароксетин, ДПДГ ↔ EMDR, ТФ-КПТ и т.д.
Раскладка по ADR-008:
```
data/kb/sources/ исходные PDF + sha256
data/kb/units/*.jsonl вычитанные единицы (в git)
data/kb/review-status.json статус вычитки по единицам
data/eval/kb_questions.yaml эталонные вопросы
```
## 4. Поиск
Корпус маленький, тяжёлая инфраструктура не нужна.
- **Фильтр по метаданным до поиска:** `audience`, `doc`, `type`. Вопрос пациента о дозе — отказ без поиска.
- **Гибридный поиск:**
- BM25 с русской лемматизацией (pymorphy3) и расширением запроса по словарю аббревиатур;
- эмбеддинги — только GigaChat Embeddings или локальная модель (guardrails 4 и 9; для офлайн-демо — только локальная).
- **Small-to-big.** Найден тезис — в контекст LLM уходит тезис с комментарием и заголовками родительского раздела. При
нашем объёме допустимо отдавать раздел целиком.
- **Порог уверенности.** Лучшая единица ниже порога — ответ «В клинических рекомендациях ответа на этот вопрос нет»
(по аналогии с `fallback` в `config/texts/med_qa.yaml`).
## 5. Генерация строго по источнику
Переиспользуем механизмы `app/llm/med_qa.py`:
- LLM получает пронумерованные единицы и обязана ссылаться на `id` в каждом утверждении.
- **Детерминированная проверка после генерации:**
- каждое число в ответе встречается в процитированных единицах;
- доля «новых» слов ограничена (`MAX_NOVEL_STEM_SHARE`).
Не прошло — вместо пересказа отдаём саму цитату.
- Ссылка на источник («КР ПТСР 2023, разд. 3.2, УУР A») и фраза «решение принимает врач» добавляются кодом, не через LLM.
- До поиска действуют общие guardrails: проверка безопасности (`acute` → фиксированный кризисный скрипт), затем `scrub()`,
затем вызов модели только через `app/llm/gateway.py`.
## 6. Оценка качества
Эталонный набор в `data/eval/` на 50–100 вопросов с ожидаемыми `id` единиц. Вопросы трёх видов:
- прямые;
- перефразированные;
- ловушки, на которые в КР ответа нет.
Метрики:
- recall@k по `id` (есть ли нужные единицы среди первых k найденных);
- доля ответов, прошедших проверку по цитате;
- доля правильных отказов на ловушках.
Оценка запускается обычным pytest-тестом, чтобы правки разметки не ломали поиск незаметно.
## Риски и ограничения
- **Актуальность.** КР ПТСР указывает «пересмотр не позднее 2025». Перед использованием проверить в рубрикаторе Минздрава
наличие новой редакции. `approved` / `revise_by` храним в метаданных и показываем в ответе.
- **Объём к дедлайну (05.10.2026, 13:00).** Реалистичный MVP:
- 2–3 КР (ПТСР, депрессия), размеченные полуавтоматически и вычитанные;
- BM25 + фильтр по `audience`;
- строгие цитаты.
Эмбеддинги и реранкер — по остаточному принципу: глубину даёт качество разметки, а не векторная база.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+323
View File
@@ -0,0 +1,323 @@
# Запрос на мокапы: приложение пациента «Сопровождение пациента»
Хакатон MedITron, кейс SberMed AI «ИИ-помощник пациента». По мокапам сразу пишется код (React, затем мобильное
приложение через Capacitor), поэтому важны точные токены, реальные тексты и все состояния экранов.
---
## 1. О продукте
**Что это.** Сервис сопровождения между визитами к врачу: напоминает о лекарствах, раз в день спрашивает о
самочувствии, по расписанию проводит короткие медицинские анкеты и сообщает врачу, если состоянию нужно
внимание. ИИ не ставит диагнозы и не лечит.
**Для кого.** Ветераны боевых действий 25–55 лет с ПТСР и депрессией, позже — их семьи. Часто уставшие,
недоверчивые к «приложениям», могут быть в тяжёлом состоянии. Пользуются в основном телефоном.
**Главная задача интерфейса.** Ежедневная отметка самочувствия и приёма лекарства занимает меньше минуты и не
вызывает тревоги. Человеку хочется её заполнять.
**Два способа входа.**
- Через ЕМИАС: врач видит ответы и получает сигнал при ухудшении.
- Анонимно: без имени и полиса, врач ничего не видит.
---
## 2. Требования к мокапам
### Формат
- Мобильные кадры **390×844**, по одному экрану на кадр.
- Для A1 и C1 дополнительно десктоп **1440×1000**.
- Отдать **PNG** и, если есть, ссылку на Figma с открытым просмотром.
- Имена файлов по ID экрана: `B3-scale.png`, `C1-today.png`, `C1-today-desktop.png`.
- Состояния — отдельными кадрами с суффиксом: `D1-mood-selected.png`, `S2-offline.png`.
### Содержание
- **Только реальные тексты на русском** из этого документа. Никаких lorem ipsum и «Заголовок».
- **Без логотипов ЕМИАС, mos.ru, Госуслуг и Минздрава.** Вход — текстовая кнопка «Войти через ЕМИАС».
- **Баллы анкет, диагнозы и уровни тяжести пациенту не показываются нигде.**
- **Кнопка «Нужна помощь»** (звонок 112) есть в шапке каждого экрана.
- Без иллюстраций людей, стоковых фото, эмодзи, градиентов, стеклянных эффектов и 3D.
- Учесть вырез экрана сверху и home indicator снизу.
### Состояния (там, где они есть у экрана)
Обычное · выбранный вариант · кнопка неактивна · загрузка · ошибка с понятным действием.
---
## 4. Общий промпт
Вставляется перед промптом любого экрана из раздела 5.
```
Спроектируй экран мобильного приложения (390×844) «Сопровождение пациента» — сервиса сопровождения
между визитами к врачу. Пользователи — ветераны боевых действий 25–55 лет с ПТСР и депрессией, часто
уставшие и недоверчивые. Цель — чтобы ежедневные отметки занимали меньше минуты и не вызывали тревоги.
Стиль: строгий государственный медицинский сервис уровня mos.ru и Госуслуг, но тёплый и спокойный.
Без логотипов ЕМИАС, mos.ru и Госуслуг. Без иллюстраций людей, стоковых фото, эмодзи, градиентов,
стекла и 3D. Много воздуха, чёткая иерархия, крупный текст.
Правила: кнопки не ниже 48 px, основная кнопка на всю ширину внизу; один вопрос — один экран,
варианты ответа — крупные карточки на всю ширину, выбранный подсвечивается цветом направления;
в шапке красная кнопка «Нужна помощь»; нижняя навигация «Сегодня · Лекарства · Сообщения · Профиль»
(кроме экранов входа и анкет). Баллы, диагнозы и оценки тяжести не показывать. Тексты на русском,
короткие, на «вы», без медицинского жаргона и без давления. Учти вырез экрана и home indicator.
```
---
## 5. Экраны и промпты
Приоритет: **волна 1** нужна в первую очередь — это ядро ежедневного сценария.
### Волна 1
**C1 · Сегодня (главная)**
```
Экран «Сегодня». Приветствие «Добрый день» и дата «Воскресенье, 4 октября». Список задач дня карточками
с временем слева и цветной полосой направления: «09:00 · Утренний приём» (лекарства, отмечено
«Принято»), «14:00 · Как вы сегодня?» (самочувствие, кнопка «Ответить», «≈ 1 минута»),
«Анкета о сне и настроении» (самочувствие, «≈ 5 минут», «до 11 октября»). Ниже блок
«Следующий визит: 12 октября, 10:30, врач Смирнова Е. А.». Быстрые действия: «Вопрос о лекарстве»,
«Написать врачу». Нижняя навигация, активна вкладка «Сегодня». Отдельно десктоп 1440×1000.
```
**C2 · Всё сделано**
```
Экран «Сегодня», когда задачи выполнены: спокойное пустое состояние «На сегодня всё. Следующее
напоминание — завтра в 09:00». Все карточки дня отмечены как выполненные. Без конфетти и наград.
```
**D1 · Настроение**
```
Первый шаг ежедневной отметки. Прогресс «1 из 5», кнопка «Назад». Вопрос крупно: «Как вы себя
чувствуете сегодня?». Шкала 0–10 крупными кнопками с цветовой градацией от тёплого красного к
зелёному, подписи по краям «Очень тяжело» и «Отлично». Состояния: ничего не выбрано; выбрано 6.
Кнопка «Дальше» внизу.
```
**D2 · Лекарство сегодня**
```
Шаг 2 из 5. «Вы приняли лекарство сегодня?» Карточки: «Да, принял(а)», «Нет, пропущу», «Не помню».
Цвет выбора — бирюзовый (лекарства).
```
**D3 · Сон и кошмары**
```
Шаг 3 из 5. «Сколько часов вы спали этой ночью?» — четыре карточки «Меньше 4», «4–6», «6–8»,
«Больше 8». Ниже второй вопрос на том же экране: «Снились ли кошмары?» — «Да» / «Нет».
```
**D4 · Алкоголь**
```
Шаг 4 из 5. «Выпивали ли вы алкоголь вчера?» — «Нет», «1–2 порции», «Больше». Нейтральный тон,
без осуждения, подпись мелко: «Ответ видит только ваш врач».
```
**D5 · Пару слов**
```
Шаг 5 из 5. «Как прошёл день? Пару слов» — поле ввода на несколько строк, подсказка «Например:
плохо спал, болит голова». Кнопки «Отправить» и «Пропустить». Клавиатура открыта.
```
**D6 · Готово**
```
Итог отметки: «Спасибо, записали». Короткая сводка ответов без оценок: «Настроение 6 из 10 ·
Лекарство принято · Сон 6–8 часов». Строка «Ваш врач увидит отметку в карточке». Пример ответа
сервиса: карточка «Эффект препарата наступает не сразу — обычно через 2–4 недели». Кнопка «На главную».
```
**E1 · Напоминание о приёме**
```
Экран, открывающийся по уведомлению. Бирюзовый акцент. «09:00 · Время утреннего приёма». Название
препарата и «1 таблетка после еды» (без дозировок в мг). Три кнопки: основная «Принял(а)»,
вторичные «Пропущу» и «Есть побочные эффекты».
```
**B3 · Вопрос анкеты (универсальный экран)**
```
Экран одного вопроса анкеты. Сверху «Вопрос 3 из 20» с полосой прогресса, «Назад» и «Пауза».
Мелко период: «За последний месяц». Вопрос крупно: «Как часто вас беспокоили повторяющиеся
тревожные воспоминания о пережитом?». Пять карточек-вариантов: «Совсем нет», «Немного»,
«Умеренно», «Сильно», «Очень сильно». Без баллов рядом с ответами. Состояния: не выбрано; выбрано
«Умеренно». Отдельно экран паузы: «Можно продолжить позже — ответы сохранены».
```
**G2 · Кризисный экран**
```
Полноэкранный экран поддержки. Спокойный, не пугающий: белый фон, красный используется только для
кнопок звонка. Текст: «Мне очень жаль, что вам сейчас так тяжело. Вы не одни». Две крупные
кнопки звонка: «112 — если есть угроза жизни прямо сейчас» и «+7 (495) 989-50-50 — Психологическая
помощь МЧС, круглосуточно». Блок дыхания: круг, который плавно расширяется и сжимается, подпись
«Вдох на 4 счёта — выдох на 6». Внизу «Я рядом».
```
### Волна 2
**A1 · Выбор входа** (уже свёрстан, нужен улучшенный вариант)
```
Стартовый экран. Заголовок «Лечение продолжается и между приёмами», подзаголовок «Напомним о
лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание».
Две карточки выбора: «Через ЕМИАС» (метка «Рекомендуем», плюсы: назначения подгружаются
автоматически; врач видит ответы и получает сигнал при ухудшении; история на любом устройстве;
кнопка «Войти через ЕМИАС») и «Анонимно» (плюсы: не нужны имя, полис и телефон; опросы и помощь в
кризисе доступны; минусы: врач не видит ответы; история только на этом устройстве; кнопка
«Продолжить анонимно»). Отдельно десктоп 1440×1000.
```
**A2 · Вход через ЕМИАС**
```
Экран входа. Плашка «Демонстрационный режим: вход подтверждается одноразовым кодом, который врач
выдаёт на приёме». Поле «Код подключения от врача» и кнопка «Сканировать QR-код». Кнопка «Войти».
Ниже разделитель «или» и список «Демо-пациенты»: строка «Андрей С. · 13-й день терапии · Войти».
Состояния: ошибка «Код не подошёл. Проверьте написание или попросите врача выдать новый код».
```
**A3 · Анонимный вход**
```
«Анонимный вход». Три пункта с галочками: что доступно, что врач не видит, что нужно согласие.
Жёлтая плашка: «История привязана к этому устройству. Если вы выйдете, восстановить её будет нельзя».
Кнопка «Начать анонимно».
```
**A4 · Согласие на обработку данных**
```
Экран согласия (152-ФЗ). Заголовок «Перед началом нужно ваше согласие». Короткие блоки с иконками:
«Что обрабатываем» — ответы о самочувствии, сне, приёме лекарств; «Кто видит» — только ваш лечащий
врач; «Как отозвать» — в любой момент в профиле. Ссылка «Полный текст согласия». Кнопки «Согласен(на)»
и «Не согласен(на)» равного веса — без давления.
```
**A5 · Возраст**
```
«Вам уже исполнилось 18 лет?» Две карточки: «Да, мне 18 или больше», «Нет, мне меньше 18».
```
**A6 · Отказ или младше 18**
```
Спокойный информационный экран без упрёка: «Без согласия мы не можем сохранять ваши ответы. Если
передумаете — нажмите „Начать заново“». Блок «Если нужна помощь сейчас» с номерами. Вариант для
младше 18: «Сервис рассчитан на взрослых» и детский телефон доверия 8-800-2000-122.
```
**B1 · Вступление к анкете**
```
«Сначала несколько вопросов». Абзац «Многие после возвращения замечают проблемы со сном,
раздражительность, тревогу — это частая реакция, и с ней можно справиться». Раскрывающиеся
вопросы FAQ: «Зачем эти вопросы?», «Кто увидит ответы?», «Можно ли прерваться?». Подпись
«≈ 10 минут, можно с перерывами». Кнопка «Начнём».
```
**B2 · Травматический опыт**
```
«Был ли у вас опыт, связанный с угрозой жизни — вашей или других людей?» Карточки «Да», «Нет»,
«Не хочу отвечать». Деликатно, без подробностей события.
```
**B4 · Доступ к оружию**
```
«Есть ли у вас дома оружие?» Карточки: «Нет», «Да, у меня», «Да, хранится у других или в сейфе»,
«Не хочу отвечать». Отдельный кадр — блок о безопасном хранении: спокойный тон, совет хранить
оружие и патроны отдельно или передать на время близкому человеку.
```
**B5 · Итог анкеты**
```
«Спасибо, анкета заполнена». Без баллов и диагнозов: «Ваш врач получит результаты к следующему
визиту». Блок «Что дальше»: ежедневная отметка в 14:00, напоминания о приёме, повторная анкета
через 2 недели. Кнопка «Перейти на главную».
```
**E2 · Причина пропуска**
```
«Почему пропускаете приём?» Карточки: «Забыл(а)», «Побочные эффекты», «Решил(а) бросить», «Другое».
Подпись: «Ответ поможет врачу подобрать лечение».
```
**E3 · Побочные эффекты**
```
«Что беспокоит?» Чипы с множественным выбором: тошнота, бессонница, сонливость, головная боль,
снижение аппетита, другое. При «Другое» — поле ввода. Кнопка «Отправить».
```
**E4 · Моя схема (вкладка «Лекарства»)**
```
Вкладка «Лекарства». Карточка препарата: название, «1 таблетка утром после еды», время 09:00.
Полоса отметок за 7 дней: принято / пропущено / нет отметки. Блок «Вопрос о лекарстве».
Строка «Схему меняет только ваш врач».
```
**E5 · Вопрос о лекарстве**
```
Поле вопроса и пример ответа: карточка «По инструкции к препарату: …» с подписью источника
«Инструкция ГРЛС». Внизу всегда: «Не меняйте схему приёма самостоятельно — решения принимает врач».
Состояние «В инструкции нет ответа — уточните у врача».
```
**F1 · Написать врачу**
```
«Сообщение врачу». Поле ввода, подсказка «Врач ответит в рабочее время». Красная плашка: «Если
помощь нужна сейчас — звоните 112». Кнопка «Отправить». Экран успеха: «Сообщение передано».
```
**F2 · Следующий визит**
```
Дата и время визита, врач, адрес поликлиники. Список «Что обсудить на приёме» — можно добавить
свой пункт. Кнопка «Добавить в календарь».
```
**G1 · Нужна помощь** (шторка снизу)
```
Нижняя шторка поверх любого экрана: «Если вам сейчас очень тяжело». Две крупные строки звонка с
номерами 112 и +7 (495) 989-50-50, кнопка «Позвонить» у каждой.
```
**G3 · План безопасности**
```
«Мой план безопасности». Шаги: «Мои тревожные сигналы», «Что мне помогает успокоиться», «Кому я
могу позвонить» (контакты), «Куда обратиться» (номера служб). Каждый шаг можно редактировать.
```
**G4 · Вопросы о безопасности**
```
Экран вопроса из опросника риска. Очень спокойная подача, без красного цвета. Подпись: «Мы
спрашиваем об этом всех — это помогает вовремя поддержать». Ответы «Да» / «Нет».
```
**H1 · Профиль**
```
Вкладка «Профиль». Способ входа (ЕМИАС или анонимно). Для анонимного — кнопка «Подключить ЕМИАС».
Настройки: время ежедневной отметки, уведомления о приёме. Разделы «Мои данные и согласие», «Выйти».
```
**H2 · Данные и согласие**
```
«Мои данные». Дата согласия, ссылка на текст. Кнопка «Отозвать согласие» с подтверждением:
«Обработка ответов прекратится, данные будут помечены к удалению». Для анонимного — «Удалить мои данные».
```
**I1 · Сообщения**
```
Вкладка «Сообщения» — чат с помощником. Сообщения помощника слева на белом, ответы пациента справа
на синем. Кнопки ответа под сообщением — карточками. Поле ввода внизу.
```
**S1–S4 · Системные состояния**
```
Четыре кадра: загрузка (скелетон главной); нет сети («Нет соединения — ответы отправятся, когда
появится сеть», кнопка «Нужна помощь» работает); ошибка («Не удалось загрузить. Повторить»);
сессия истекла («Войдите снова»).
```
---
## 6. Чек-лист приёмки
- [ ] Все кадры 390×844, имена файлов по ID.
- [ ] Цвета, шрифт и скругления — из раздела 3.
- [ ] Кнопка «Нужна помощь» на каждом экране, кроме кризисного (там она заменена звонками).
- [ ] Нигде нет баллов анкет, диагнозов и логотипов ЕМИАС, mos.ru, Госуслуг.
- [ ] Тексты реальные, на русском; у вопросов есть выбранное состояние.
- [ ] Кнопки не ниже 48 px; текст не мельче 12 px.
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Deploy origin/dev to the shared server (ssh alias `vobot`, dir /srv/ptsr). Run from a clean checkout:
# infra/deploy/deploy.sh [git-ref] default: origin/dev
# Keeps /srv/ptsr/.env (secrets live only on the server) and the Postgres volume.
set -euo pipefail
REF=${1:-origin/dev}
HOST=${DEPLOY_HOST:-vobot}
DIR=/srv/ptsr
git fetch -q origin || true
SHA=$(git rev-parse --short "$REF")
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
set -euo pipefail
cd $DIR
cp .env app.new/.env
rm -rf app.old; [ -d app ] && mv app app.old; mv app.new app
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 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"
SH
+30
View File
@@ -0,0 +1,30 @@
# Server override (shared host with vobot): loopback-only ports behind host nginx, restart policy, memory caps.
# docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build
services:
postgres:
ports: !reset []
restart: unless-stopped
mem_limit: 512m
app:
ports: !override
- "127.0.0.1:8010:8000"
restart: unless-stopped
mem_limit: 768m
dashboard:
command:
- streamlit
- run
- dashboard/app.py
- --server.port=8501
- --server.address=0.0.0.0
- --server.baseUrlPath=doctor
- --browser.gatherUsageStats=false
environment:
API_BASE_URL: http://app:8000
DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-https://dev.meditron.tenvex.ru}/doctor
ports: !override
- "127.0.0.1:8511:8501"
restart: unless-stopped
mem_limit: 768m
+119
View File
@@ -0,0 +1,119 @@
# 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)
# 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;
'' close;
}
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;
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;
}
}
+542
View File
@@ -0,0 +1,542 @@
"""Builds data/meds/meds_reference.json — the drug reference of the medication module.
Source of every value: the two Minzdrav clinical guidelines in config/rag/corpus/ —
* «Депрессивный эпизод, рекуррентное депрессивное расстройство» (2024): прил. А3 табл. 2 (дозы),
табл. 3 (профили побочных эффектов), разд. 3.1–3.6, прил. Б5, В;
* «Посттравматическое стрессовое расстройство» (2023, ID 753): разд. 3.2, прил. А3 (дозы).
Patient side-effect items: effects rated «++»/«+++» in табл. 3 (antidepressants) or named in the КР text
(other drugs), worded for a patient. Nothing here is a recommendation: the app only follows the doctor's
prescription (module spec, «Границы»). Texts and draft parameters must be reviewed by a psychiatrist.
Run: uv run python -m scripts.build_meds_reference
"""
import json
from pathlib import Path
OUT = Path(__file__).resolve().parents[1] / "data" / "meds" / "meds_reference.json"
DEP = "КР депрессия 2024"
PTSD = "КР ПТСР 2023 (ID 753)"
# Patient wording of the КР табл. 3 columns (and of effects named in the КР text).
SE = {
"anticholinergic": "Сухость во рту или запоры",
"nausea": "Тошнота",
"sedation": "Сонливость днём",
"insomnia": "Бессонница или беспокойство",
"sexual": "Сексуальные проблемы",
"orthostatic": "Головокружение при вставании",
"weight": "Прибавка веса",
"headache": "Головная боль",
"dizziness": "Головокружение",
"coordination": "Нарушение координации",
"memory": "Снижение внимания и памяти",
"akathisia": "Беспокойство, невозможность усидеть на месте",
"eps": "Скованность или дрожь в теле",
"skin": "Кожная сыпь или зуд",
}
RF_BLEEDING = "Необычные кровотечения или синяки"
RF_EARLY = "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма"
RF_JAUNDICE = "Желтушность кожи или глаз"
RF_RASH = "Кожная сыпь"
RF_PRIAPISM = "Длительная болезненная эрекция"
RF_UTERINE = "Маточное кровотечение"
TCA_MON = "Артериальное давление и ЭКГ"
AP_MON = "Вес и признаки метаболического синдрома"
def ad(id_, name, cls, start, lo, hi, items, *, ptsd=None, red=(), mon=(), withdrawal=None, overdose=False, note=None):
"""Antidepressant: doses from КР депрессия табл. 2, side effects from табл. 3."""
d = {
"id": id_,
"name_ru": name,
"group": "Антидепрессант",
"class": cls,
"indications": {
"depression": {
"start_mg": start,
"min_mg": lo,
"max_mg": hi,
"off_label": False,
"expected_response": "3–4 недели",
"course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев",
"source": f"{DEP}, прил. А3 табл. 2; разд. 3.1",
}
},
"diary_items": [SE[i] for i in items],
"diary_items_source": f"{DEP}, прил. А3 табл. 3 (++/+++)",
"red_flags": [RF_EARLY, *red],
"monitoring": list(mon),
"withdrawal_risk": withdrawal,
"overdose_danger": overdose,
"max_course_days": None,
}
if ptsd:
d["indications"]["ptsd"] = ptsd
if note:
d["note"] = note
return d
def ptsd_ind(lo, hi, off_label, start=None):
return {
"start_mg": start,
"min_mg": lo,
"max_mg": hi,
"off_label": off_label,
"expected_response": "4–6 недель",
"course": "Обычно 6–12 месяцев после стабилизации",
"source": f"{PTSD}, разд. 3.2; прил. А3",
}
def other(
id_, name, cls, lo, hi, off_label, items, *, red=(), mon=(), withdrawal=None, course_days=None, src, note=None
):
"""Non-antidepressant used for PTSD: doses from КР ПТСР прил. А3 / разд. 3.2, effects named in the text."""
ind = ptsd_ind(lo, hi, off_label)
ind["expected_response"] = None
ind["course"] = None
d = {
"id": id_,
"name_ru": name,
"group": next(g for key, g in GROUPS.items() if key in cls),
"class": cls,
"indications": {"ptsd": ind},
"diary_items": [SE[i] for i in items],
"diary_items_source": src,
"red_flags": list(red),
"monitoring": list(mon),
"withdrawal_risk": withdrawal,
"overdose_danger": False,
"max_course_days": course_days,
}
if note:
d["note"] = note
return d
# Patient-facing drug group by the КР class wording.
GROUPS = {
"бензодиазепин": "Анксиолитик (транквилизатор)",
"Анксиолитик": "Анксиолитик",
"Антипсихотическое": "Антипсихотик",
"Противоэпилептический": "Противоэпилептический препарат",
}
SSRI = ["nausea", "insomnia", "sexual", "headache"] # табл. 3 + головная боль из разд. 3.1 (СИОЗС)
BENZO = ["sedation", "dizziness", "coordination", "memory"] # КР ПТСР 3.2: седация, головокружение, координация…
BENZO_SRC = f"{PTSD}, разд. 3.2 (комментарии о производных бензодиазепина)"
AP_SRC = f"{PTSD}, разд. 3.2: акатизия, экстрапирамидные расстройства, метаболический синдром"
DRUGS = [
ad(
"paroxetine",
"Пароксетин",
"СИОЗС",
20,
20,
50,
SSRI,
ptsd=ptsd_ind(20, 50, False),
red=[RF_BLEEDING],
withdrawal="high",
),
ad("sertraline", "Сертралин", "СИОЗС", 50, 50, 200, SSRI, ptsd=ptsd_ind(25, 200, False), red=[RF_BLEEDING]),
ad(
"fluvoxamine",
"Флувоксамин",
"СИОЗС",
50,
100,
300,
SSRI,
red=[RF_BLEEDING],
note="Стартовая доза по табл. 2: 50–100 мг",
),
ad(
"fluoxetine",
"Флуоксетин",
"СИОЗС",
20,
20,
80,
SSRI,
ptsd=ptsd_ind(20, 40, True),
red=[RF_BLEEDING],
withdrawal="low",
),
ad("citalopram", "Циталопрам", "СИОЗС", 20, 20, 40, SSRI, red=[RF_BLEEDING]),
ad("escitalopram", "Эсциталопрам", "СИОЗС", 10, 10, 20, SSRI, red=[RF_BLEEDING]),
ad(
"venlafaxine",
"Венлафаксин",
"СИОЗСН",
75,
75,
375,
["nausea", "insomnia", "sexual"],
ptsd=ptsd_ind(75, 225, True),
mon=["Артериальное давление"],
withdrawal="high",
note="Депрессия: >225 мг/сут — в стационарных условиях",
),
ad("duloxetine", "Дулоксетин", "СИОЗСН", 60, 60, 120, ["nausea", "insomnia"]),
ad(
"amitriptyline",
"Амитриптилин",
"ТЦА",
50,
150,
300,
["anticholinergic", "sedation", "orthostatic", "weight"],
ptsd=ptsd_ind(75, 150, True),
mon=[TCA_MON],
withdrawal="high",
overdose=True,
note="Депрессия: старт 50–75 мг; >150 мг/сут — в стационарных условиях",
),
ad(
"imipramine",
"Имипрамин",
"ТЦА",
25,
150,
300,
["anticholinergic", "insomnia", "orthostatic", "weight"],
ptsd=ptsd_ind(75, 250, True),
mon=[TCA_MON],
withdrawal="high",
overdose=True,
note="Депрессия: старт 25–75 мг; >200 мг/сут — в стационарных условиях",
),
ad(
"clomipramine",
"Кломипрамин",
"ТЦА",
50,
100,
250,
["anticholinergic", "sexual", "orthostatic", "weight"],
mon=[TCA_MON],
withdrawal="high",
overdose=True,
note="Старт 50–75 мг; летальность при передозировке средняя",
),
ad(
"pipofezine",
"Пипофезин",
"ТЦА (АТХ — другие антидепрессанты)",
25,
150,
500,
[],
note="В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 25–50 мг",
),
ad("mianserin", "Миансерин", "НаССА", 30, 60, 90, ["sedation"], mon=["Формула крови (по КР — изменения редко)"]),
ad(
"mirtazapine",
"Миртазапин",
"НаССА",
15,
15,
45,
["sedation", "weight"],
ptsd=ptsd_ind(30, 60, True),
note="Старт 15–30 мг",
),
ad(
"trazodone",
"Тразодон",
"Специфический серотонинергический АД",
100,
150,
600,
["sedation", "sexual"],
red=[RF_PRIAPISM],
note=">450 мг/сут — в стационарных условиях",
),
ad(
"pirlindole",
"Пирлиндол",
"Ингибитор МАО-А",
50,
150,
400,
[],
note="В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 50–75 мг",
),
ad(
"agomelatine",
"Агомелатин",
"Мелатонинергический АД",
25,
25,
50,
[],
red=[RF_JAUNDICE],
mon=["Билирубин, АСТ, АЛТ, щелочная фосфатаза — в начале лечения и при повышении дозы"],
),
ad("vortioxetine", "Вортиоксетин", "Серотонинергический модулятор", 10, 10, 20, ["nausea", "insomnia"]),
# КР ПТСР: анксиолитики, антипсихотические средства, противоэпилептические препараты
other(
"diazepam",
"Диазепам",
"Производное бензодиазепина",
5,
15,
False,
BENZO,
withdrawal="high",
course_days=28,
src=BENZO_SRC,
note="Курс не более 4 недель, в тяжёлых случаях до 8–12 недель",
),
other(
"lorazepam",
"Лоразепам",
"Производное бензодиазепина",
2,
10,
False,
BENZO,
withdrawal="high",
course_days=21,
src=BENZO_SRC,
note="Дозы — из текста разд. 3.2 (в табл. А3 не приведён)",
),
other(
"clonazepam",
"Клоназепам",
"Производное бензодиазепина",
1,
4,
False,
BENZO,
withdrawal="high",
course_days=21,
src=BENZO_SRC,
note="Дозы — из текста разд. 3.2 (в табл. А3 не приведён)",
),
other(
"alprazolam",
"Алпразолам",
"Производное бензодиазепина",
0.25,
4.5,
False,
BENZO,
withdrawal="high",
course_days=90,
src=BENZO_SRC,
note="Курс до 3 месяцев; отмена в течение 2–6 недель",
),
other(
"tofisopam",
"Тофизопам",
"Производное бензодиазепина (дневной транквилизатор)",
50,
300,
False,
[],
course_days=84,
src=f"{PTSD}, разд. 3.2",
note="По КР не вызывает физической зависимости и седации; курс 4–12 недель",
),
other(
"etifoxine",
"Этифоксин",
"Анксиолитик",
150,
200,
False,
["sedation", "skin"],
red=[RF_JAUNDICE, RF_UTERINE],
course_days=42,
src=f"{PTSD}, разд. 3.2",
note="Курс от нескольких дней до 4–6 недель",
),
other(
"hydroxyzine",
"Гидроксизин",
"Анксиолитик",
25,
100,
True,
["sedation"],
course_days=56,
src=f"{PTSD}, разд. 3.2",
note="Курс обычно 4–8 недель",
),
other(
"buspirone",
"Буспирон",
"Анксиолитик",
20,
30,
False,
[],
src=f"{PTSD}, разд. 3.2",
note="Эффект с 7–14 дня, максимум к 4-й неделе",
),
other(
"alimemazine",
"Алимемазин",
"Антипсихотическое средство",
15,
80,
False,
[],
mon=[AP_MON],
src=f"{PTSD}, разд. 3.2",
note="По КР побочные эффекты редки и слабо выражены",
),
other(
"risperidone",
"Рисперидон",
"Антипсихотическое средство",
0.5,
8,
True,
["akathisia", "eps", "weight"],
mon=[AP_MON],
src=AP_SRC,
),
other(
"quetiapine",
"Кветиапин",
"Антипсихотическое средство",
50,
300,
True,
["akathisia", "eps", "weight"],
mon=[AP_MON],
src=AP_SRC,
),
other(
"olanzapine",
"Оланзапин",
"Антипсихотическое средство",
5,
20,
True,
["weight", "sedation"],
mon=[AP_MON],
src=f"{DEP}, разд. 3.3 (увеличение веса, седация); {PTSD}, разд. 3.2",
),
other(
"tiapride", "Тиаприд", "Антипсихотическое средство", 200, 300, False, [], mon=[AP_MON], src=f"{PTSD}, разд. 3.2"
),
other(
"chlorprothixene",
"Хлорпротиксен",
"Антипсихотическое средство",
25,
150,
True,
[],
mon=[AP_MON],
src=f"{PTSD}, разд. 3.2",
note="Расхождение в КР ПТСР: в тексте 15–100 мг, в табл. А3 — 25–150 мг",
),
other(
"lamotrigine",
"Ламотриджин",
"Противоэпилептический препарат",
25,
500,
True,
[],
red=[RF_RASH],
src=f"{PTSD}, разд. 3.2",
note="Частые кожные высыпания вплоть до токсического эпидермального некролиза",
),
other("topiramate", "Топирамат", "Противоэпилептический препарат", 200, 400, True, [], src=f"{PTSD}, разд. 3.2"),
]
TEXTS = {
"expect_effect_depression": (
"Лекарства от депрессии действуют не сразу. Первые изменения иногда заметны уже через 1–2 недели, "
"а эффект врач оценивает через 3–4 недели. Принимайте препарат по схеме, даже если пока не чувствуете разницы."
),
"expect_effect_ptsd": (
"Эффект препарата при ПТСР развивается постепенно — его оценивают через 4–6 недель. В первую неделю тревога "
"может временно усилиться: это известная реакция, сообщите о ней врачу. Не прекращайте приём самостоятельно."
),
"dont_stop_alone": (
"Не прекращайте приём самостоятельно: резкая отмена может ухудшить самочувствие, а лечение — потерять эффект. "
"Если препарат мешает жить или вы в нём сомневаетесь, обсудите это с врачом — он может изменить дозу "
"или подобрать другой."
),
"why_continue_depression": (
"Даже когда становится лучше, лечение продолжают ещё несколько месяцев в той же дозе: так ниже риск, "
"что депрессия вернётся."
),
"withdrawal_missed": (
"Вы пропустили приём два дня подряд. При резком прекращении этого препарата бывают неприятные ощущения: "
"головокружение, раздражительность, нарушения сна, состояние как при гриппе. Вернитесь к обычной схеме "
"и сообщите врачу, если стало хуже."
),
"ran_out": (
"Лекарство закончилось? Не откладывайте — попросите у врача новый рецепт: "
"перерыв в приёме может ухудшить самочувствие."
),
"red_flag": "Этот симптом нужно обсудить с врачом сегодня. Если состояние тяжёлое — вызывайте скорую: 103 или 112.",
"side_effects_general": (
"Спасибо, что сообщили. Побочные эффекты часто ослабевают в первые недели. Если они мешают жить — это повод "
"обсудить с врачом дозу или замену препарата. Не меняйте схему сами."
),
"instruction_fallback": "Уточните у врача или в инструкции к препарату.",
}
RULES = [
{"id": "M1", "params": {"missed_days_in_row": 2}, "status": "черновик"},
{"id": "M2", "params": {}, "status": "черновик"},
{"id": "M3", "params": {"ran_out_days_in_row": 2}, "status": "черновик"},
{"id": "M4", "params": {"default_max_course_days": 21}, "status": "КР ПТСР 3.2"},
{
"id": "M5",
"params": {"check_day": 28, "baseline_lookback_days": 14, "phq9_min_drop_pct": 50, "pcl5_min_drop": 10},
"status": "черновик",
},
{"id": "M6", "params": {}, "status": "КР депрессия 3.1.3"},
{"id": "M7", "params": {}, "status": "черновик"},
{"id": "M8", "params": {"fibser_burden_min": 5, "side_effect_misses_in_7_days": 2}, "status": "черновик"},
{"id": "M9", "params": {}, "status": "в сводке врачу"},
{"id": "M10", "params": {}, "status": "приложения А3 обеих КР"},
]
def main() -> None:
OUT.parent.mkdir(parents=True, exist_ok=True)
doc = {
"version": "draft-2026-10-04",
"status": "черновик: собран по КР Минздрава, тексты и параметры вычитывает психиатр",
"sources": [
{
"id": "kr_depression_2024",
"title": "Депрессивный эпизод, рекуррентное депрессивное расстройство",
"year": 2024,
"file": "docs/source/rag/kr_depression.pdf",
},
{
"id": "kr_ptsd_2023",
"title": "Посттравматическое стрессовое расстройство",
"year": 2023,
"kr_id": 753,
"file": "docs/source/rag/kr_ptsd.pdf",
},
],
"drugs": DRUGS,
"texts": TEXTS,
"rules": RULES,
}
OUT.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"{OUT}: {len(DRUGS)} drugs")
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
"""Test-wide isolation: a developer's local `.env` (e.g. OPENROUTER_API_KEY) must never make tests call a real
LLM provider — that costs money and makes results depend on the network. Agent tests pass explicit
providers/settings; everything else runs with the chat agent off."""
import pytest
from app.config import get_settings
@pytest.fixture(autouse=True)
def _no_live_llm(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CHAT_AGENT", "off")
monkeypatch.setenv("OPENROUTER_API_KEY", "")
monkeypatch.setenv("LLM_PROVIDER", "mock")
get_settings.cache_clear()
yield
get_settings.cache_clear()
+103
View File
@@ -0,0 +1,103 @@
"""MAX endpoints authenticate before reading bindings or accepting events."""
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.exc import OperationalError
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.integrations import max_store
from app.main import app
BASE = "/api/v1/me/notifications/max"
HOOK = "/api/v1/webhooks/max"
TOKEN = "synthetic-patient"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
SECRET = "test-secret-" * 4
@pytest.fixture
async def client(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" + HOOK,
"MAX_SITE_URL": "https://example.org",
}.items():
monkeypatch.setenv(key, value)
get_settings.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()
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
get_settings.cache_clear()
@pytest.mark.parametrize("auth", [{}, {"Authorization": "Bearer unknown"}, {"Authorization": "Basic test"}])
async def test_auth_required(client, auth):
assert (await client.get(BASE, headers=auth)).status_code == 401
assert (await client.post(BASE + "/link", headers=auth, json={"consent": True})).status_code == 401
assert (await client.delete(BASE, headers=auth)).status_code == 401
async def test_link_requires_consent_and_hides_patient_credential(client):
for body in ({}, {"consent": False}, {"consent": True, "user_id": 100}):
assert (await client.post(BASE + "/link", headers=AUTH, json=body)).status_code == 422
response = await client.post(BASE + "/link", headers=AUTH, json={"consent": True})
assert response.status_code == 200
assert TOKEN not in response.text
url = urlsplit(response.json()["url"])
assert url.netloc == "max.ru" and url.path == "/test_bot"
code = parse_qs(url.query)["start"][0]
async with session.get_identity_session() as s:
assert await max_store.bind(s, code, 100, clock.now()) == TOKEN
assert (await client.get(BASE, headers=AUTH)).json()["connected"] is True
assert (await client.delete(BASE, headers=AUTH)).json()["connected"] is False
async def test_revoke_consent_prevents_linking(client):
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()
)
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 403
async def test_disabled_service_still_allows_disconnect(client, monkeypatch):
monkeypatch.setenv("MAX_ENABLED", "false")
get_settings.cache_clear()
assert (await client.get(BASE, headers=AUTH)).json()["available"] is False
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 503
assert (await client.post(HOOK, json={})).status_code == 503
assert (await client.delete(BASE, headers=AUTH)).status_code == 200
async def test_webhook_secret_checked_before_body_or_adapter(client):
assert (await client.post(HOOK, content="not-json")).status_code == 403
assert (await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": "wrong"}, json={})).status_code == 403
headers = {"X-Max-Bot-Api-Secret": SECRET}
assert (await client.post(HOOK, headers=headers, content="not-json")).status_code == 400
assert (await client.post(HOOK, headers=headers, json=[])).status_code == 422
assert (await client.post(HOOK, headers=headers, content=" " * 65537)).status_code == 413
async def test_webhook_storage_failure_is_retryable_without_identity_details(client, monkeypatch):
handler = AsyncMock(side_effect=OperationalError("private SQL", {"token": TOKEN}, Exception("private")))
monkeypatch.setitem(sys.modules, "app.channels.max_bot", SimpleNamespace(handle_update=handler))
response = await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": SECRET}, json={})
assert response.status_code == 503 and TOKEN not in response.text and "private" not in response.text
+287
View File
@@ -0,0 +1,287 @@
"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine."""
from collections.abc import AsyncIterator
from datetime import datetime, time, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from app.api import fixtures
from app.core import clock
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
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def _patient_with_code(code: str) -> None:
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Синтетический П.",
snils="000-000-000 00",
clinic_id="clinic-1",
doctor_id="doc-1",
now=clock.now(),
)
await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-1", now=clock.now())
async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()
second = (await client.post("/api/v1/auth/anonymous")).json()
assert first["mode"] == "anonymous" and first["display_name"] is None
assert len(first["token"]) == 32 and first["token"] != second["token"]
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:
# The care team sees a pseudonymous profile without an identity record.
profile = await repo.get_profile(s, first["token"])
assert profile.doctor_id == anonymous_doctor() and profile.modules == []
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 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
async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"]
buttons = [b["data"] for r in replies for row in r["buttons"] for b in row]
assert "consent:yes" in buttons and "consent:no" in buttons
async def test_emias_code_is_one_time_and_attaches_doctor(client: AsyncClient) -> None:
await _patient_with_code("DEMO-0042")
ok = await client.post("/api/v1/auth/emias", json={"code": " demo-0042 "})
assert ok.status_code == 200 and ok.json() == {"token": TOKEN, "mode": "emias", "display_name": None}
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "doc-1"
again = await client.post("/api/v1/auth/emias", json={"code": "DEMO-0042"})
assert again.status_code == 401
async def test_emias_rejects_unknown_code(client: AsyncClient) -> None:
assert (await client.post("/api/v1/auth/emias", json={"code": "NOPE"})).status_code == 401
assert (await client.post("/api/v1/auth/emias", json={"code": ""})).status_code == 422
async def test_emias_demo_persona(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0]
res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})
assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name}
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, persona.token)).doctor_id == fixtures.DOCTOR_ID
missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"})
assert missing.status_code == 404
async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
now = clock.now()
today = now.date()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", next_visit=today + timedelta(days=5))
rx = Prescription(token=TOKEN, drug="sertraline", dose="50 мг", schedule={"times": ["09:00", "21:00"]})
s.add(rx)
await s.flush()
morning = datetime.combine(today, time(9, 0), now.tzinfo)
s.add_all(
[
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning, status="taken"),
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning + timedelta(hours=12)),
]
)
await repo.upsert_checkin(s, token=TOKEN, day=today, now=now, mood=6)
body = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()
assert body["has_doctor"] and body["checkin_done"]
assert body["next_visit"] == (today + timedelta(days=5)).isoformat()
assert [(d["time"], d["status"]) for d in body["doses"]] == [("09:00", "taken"), ("21:00", "pending")]
week = body["prescriptions"][0]["week"]
assert len(week) == 7 and next(w for w in week if w["day"] == today.isoformat())["status"] == "pending"
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 True and body["doses"] == [] and body["prescriptions"] == []
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()]
assert "112" in phones
async def test_today_links_prescription_to_reference_and_therapy_day(client: AsyncClient) -> None:
today = clock.now().date()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", modules=["veteran"])
s.add(Prescription(token=TOKEN, drug="Сертралин", dose="50 мг", schedule={}, start_date=today - timedelta(13)))
rx = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["indication"] == "ptsd" and rx["therapy_day"] == 14
async def test_demo_persona_week_mirrors_dashboard_story(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["therapy_day"] > 1
async def test_questionnaires_are_off_without_release_or_demo_flag(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("INTAKE_DEMO", raising=False)
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1")
q = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["questionnaires"]
assert q == {"enabled": False, "intake": "none", "diary_done": False, "diary_next": "morning"}
async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "1")
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
replies = (await client.post(f"/api/v1/dialog/{persona}/events", json=event)).json()["replies"]
return [b["data"] for r in replies for row in r["buttons"] for b in row]
await press()
await press("consent:yes")
buttons = await press("age:adult")
# 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"
async def test_instrument_catalog_describes_intake_items(client: AsyncClient) -> None:
body = (await client.get("/api/v1/instruments/catalog")).json()
phq = body["items"]["phq9_3"]
assert phq["form"] == "phq9" and phq["index"] == 3 and phq["total"] == 9 and not phq["adaptive"]
assert body["items"]["cssrs_q1"]["adaptive"] and body["forms"]["cssrs"]["section"] == "safety"
assert body["items"]["sleep_hours"]["numeric"] and body["items"]["alcohol_portions"]["numeric"]
assert body["items"]["dphq_9"]["form"] == "diary" and body["items"]["dpcl_18"]["form"] == "diary"
assert body["forms"]["pcl5"]["recall_period"] and body["forms"]["gad7"]["section"] == "scale"
drinks = body["standard_drinks"]
assert len(drinks["header"]) == 4 and len(drinks["rows"]) == 5 and all(len(c) == 4 for _, c in drinks["rows"])
async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClient) -> None:
body = (await client.get("/api/v1/meds/reference")).json()
assert "rules" not in body and body["texts"]["dont_stop_alone"]
drugs = {d["id"]: d for d in body["drugs"]}
sertraline = drugs["sertraline"]
assert sertraline["indications"]["depression"]["max_mg"] == 200
assert (
sertraline["indications"]["ptsd"]["min_mg"] == 25 and "КР ПТСР" in sertraline["indications"]["ptsd"]["source"]
)
assert drugs["diazepam"]["max_course_days"] == 28 and drugs["alprazolam"]["max_course_days"] == 90
assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values())
async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
# Stage 1 is the same for everyone: it does not wait for a module (it sets them itself).
monkeypatch.setenv("INTAKE_DEMO", "1")
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
replies = (await client.post(f"/api/v1/dialog/{token}/events", json=event)).json()["replies"]
return [b["data"] for r in replies for row in r["buttons"] for b in row]
assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"]
await press()
await press("consent:yes")
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
+47
View File
@@ -0,0 +1,47 @@
"""MAX stays off without credentials; enabled deployments fail closed on incomplete settings."""
import pytest
from pydantic import ValidationError
from app.config import Settings
def configured(**overrides):
fields = {
"max_enabled": True,
"max_bot_token": "test-only-token",
"max_bot_username": "test_bot",
"max_webhook_secret": "test-only-secret-" * 3,
"max_webhook_url": "https://api.example.org/api/v1/webhooks/max",
"max_site_url": "https://patient.example.org/#/today",
}
return Settings(_env_file=None, **(fields | overrides))
def test_max_disabled_by_default():
assert not Settings(_env_file=None).max_enabled
def test_max_secrets_are_redacted():
settings = configured()
assert "test-only-token" not in repr(settings)
assert "test-only-secret" not in repr(settings)
@pytest.mark.parametrize(
"overrides",
[
{"max_bot_token": ""},
{"max_bot_username": "@test_bot"},
{"max_webhook_secret": "short"},
{"max_site_url": "http://patient.example.org"},
{"max_webhook_url": "http://api.example.org"},
{"max_webhook_url": "https://api.example.org:8443/hook"},
{"max_webhook_url": "https://user:password@api.example.org/hook"},
{"max_webhook_url": "https://api.example.org/hook#fragment"},
{"max_site_url": "https://patient.example.org?token=credential"},
],
)
def test_max_rejects_incomplete_or_unsafe_settings(overrides):
with pytest.raises(ValidationError):
configured(**overrides)
+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}")
+9 -1
View File
@@ -53,7 +53,15 @@ async def _tables(engine_fn: Callable[[], AsyncEngine]) -> set[str]:
async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
identity_tables = await _tables(session.identity_engine)
clinical_tables = await _tables(session.clinical_engine)
assert identity_tables == {"patients", "doctors", "connect_codes"}
assert identity_tables == {
"patients",
"doctors",
"connect_codes",
"max_bindings",
"max_link_codes",
"max_webhook_receipts",
"max_deliveries",
}
assert {
"patient_profiles",
"item_responses",
+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
+184 -60
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:"):
@@ -94,6 +96,17 @@ async def complete_intake(e, choices=None):
raise AssertionError("intake did not terminate")
async def to_cssrs(e, choices=None):
"""Stage 1 up to the C-SSRS screener, which comes after PHQ-2, the trauma filter and RUS-AUDIT-S."""
replies = await press(e, "intake:start")
for _ in range(20):
button = answer_button(replies, {"trauma": 1, **(choices or {})})
if ":cssrs_" in button:
return replies
replies = await press(e, button)
raise AssertionError("C-SSRS not reached")
async def test_minimal_intake_raw_data_not_imputed_and_restart(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
(row,) = await rows()
@@ -111,32 +124,65 @@ 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 == {"cssrs_q1": 0}
assert row.answers == {"rf_psychosis": 0}
await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume")
assert "cssrs_q2" in answer_button(replies)
assert "rf_harm_others" in answer_button(replies)
await press(restarted, old)
assert (await rows())[0].answers == {"cssrs_q1": 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":
replies = await press(released, "intake:help")
elif signal == "cssrs":
replies = await to_cssrs(released)
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):
@@ -169,12 +215,13 @@ async def test_alcohol_conflict_keeps_positive_short_answer(released):
async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
replies = await press(released, "intake:start")
replies = await to_cssrs(released)
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies))
assert any(r.kind == "safety_plan" for r in replies)
assert "phq9_1" in answer_button(replies)
# The screener ends stage 1; the context questions follow (sex is known from the profile).
assert ":tbi:" in answer_button(replies)
async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch):
@@ -210,9 +257,10 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
monkeypatch.setattr(integrations, "outbox", Failing())
await jobs.run_until_now()
tasks = await rows(QuestionnaireTask)
assert len(tasks) == 2
risk = next(t for t in tasks if t.kind == "cssrs_since_contact")
assert risk.delivery and risk.sent_at is None
# 20:00 Moscow: both parts of the diary are due; no risk monitoring → no daily C-SSRS.
assert {t.kind for t in tasks} == {"diary_morning", "diary_evening"}
morning = next(t for t in tasks if t.kind == "diary_morning")
assert morning.delivery and morning.sent_at is None
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
await jobs.run_until_now()
@@ -222,17 +270,29 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
assert len(await rows()) == 2
async def test_missing_since_contact_is_manual_review_not_recent(released):
async def test_since_contact_risk_check_opens_the_evening_diary(released):
async with session.get_clinical_session() as s:
task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW)
task_id = task.id
replies = await released.handle(
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
)
(row,) = await rows()
assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers
assert row.details["stop_reason"] == "cssrs_since_contact_required"
assert not answer_button(replies)
assert any("последнего ответа" in r.text for r in replies)
for _ in range(5):
button = answer_button(replies)
if ":cssrs_" not in (button or ""):
break
replies = await press(released, button)
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
assert risk.status == "complete" and risk.answers == {
"cssrs_q1": 0,
"cssrs_q2": 0,
"cssrs_q6_lifetime": 0,
"cssrs_q6_recent": 0,
}
assert risk.details["answer_metadata"]["cssrs_q6_recent"]["derived"] is True
assert ":mood:" in answer_button(replies)
assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
async def test_disabled_or_no_adult_confirmation_cannot_start(released):
@@ -256,6 +316,20 @@ async def review_risk_record(e):
)
async def run_diary(e, replies, choices=None, numbers=("6,5", "2")):
"""Answers the open diary questions; numeric items take `numbers` in turn."""
numbers = list(numbers)
for _ in range(30):
button = answer_button(replies, choices)
if button:
replies = await press(e, button)
elif any("часов" in r.text or "порци" in r.text for r in replies) and numbers:
replies = await e.handle(TOKEN, Event(type="text", text=numbers.pop(0)))
else:
return replies
raise AssertionError("diary did not terminate")
async def test_doctor_review_is_audited_and_unblocks_diary(released):
from fastapi import HTTPException
@@ -263,8 +337,14 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
from app.db.audit import AuditLog
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await jobs.run_until_now()
async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
replies = await run_diary(released, await press(released, "menu:checkin"))
# Evening: the risk check comes first; thoughts of a method (q3) are a moderate risk → stop for a specialist.
replies = await press(released, "menu:checkin")
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0})
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
assert risk.status == "review_required"
with pytest.raises(HTTPException) as exc:
await review(
TOKEN,
@@ -277,53 +357,51 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required")
await review_risk_record(released)
await jobs.run_until_now()
assert any(r.kind == "diary" and r.status == "active" for r in await rows())
assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
await history(TOKEN, "synthetic-doctor")
audits = await rows(AuditLog)
assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"}
async def test_diary_numeric_skip_and_not_prescribed(released):
async def test_diary_morning_then_evening_with_the_ptsd_module(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await press(released, "menu:checkin")
await review_risk_record(released)
replies = await press(released, "menu:checkin")
for _ in range(30):
q = next(
(b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")),
None,
)
if q == "comment":
button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip"))
replies = await press(released, button)
elif q:
replies = await press(released, answer_button(replies, {"medication": 3}))
elif any("часов" in r.text for r in replies):
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
assert any("число часов" in r.text for r in invalid)
replies = await released.handle(TOKEN, Event(type="text", text="6,5"))
else:
break
diary = next(r for r in await rows() if r.kind == "diary")
assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5
assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers
assert diary.answers["comment"] is None
assert any("часов" in r.text for r in replies)
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
assert any("число часов" in r.text for r in invalid)
replies = await run_diary(released, invalid, {"sleep_quality": 3})
assert any("утро" in r.text for r in replies)
morning = next(r for r in await rows() if r.kind == "diary_morning")
assert morning.status == "complete"
assert morning.answers == {"sleep_hours": 6.5, "sleep_quality": 3, "alcohol_portions": 2.0}
replies = await run_diary(released, await press(released, "menu:checkin"), {"mood": 7, "medication": 2})
evening = next(r for r in await rows() if r.kind == "diary_evening")
assert evening.status == "complete" and evening.answers["medication"] == "not_prescribed"
assert "medication_reason" not in evening.answers
assert {"dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18"} <= set(evening.answers)
assert evening.details["interpretation"]["kind"] == "diary_evening"
replies = await press(released, "menu:checkin")
assert any("уже заполнен" in r.text for r in replies)
async def test_high_risk_diary_asks_safety_first_and_stops(released):
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.get_profile(s, TOKEN)).enhanced_monitoring = True
await press(released, "menu:checkin")
await review_risk_record(released)
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 press(released, "menu:checkin")
button = answer_button(replies)
assert "death_thoughts" in button
replies = await press(released, button)
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.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)
assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line)
diary = next(r for r in await rows() if r.kind == "diary")
assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted"
async def test_unknown_sex_goes_to_review_without_a_zone(released):
@@ -344,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 == {"cssrs_q1": 0}
assert row.status == "review_required" and row.answers == {"rf_psychosis": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released):
@@ -377,31 +455,77 @@ async def test_stop_any_policy_interrupts_first_positive(released):
data = yaml.safe_load(path.read_text())
data["cssrs_positive_policy"] = "stop_any"
path.write_text(yaml.safe_dump(data, allow_unicode=True))
replies = await press(released, "intake:start")
replies = await to_cssrs(released)
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
assert any(r.kind == "crisis" for r in replies)
assert (await rows())[0].answers == {"cssrs_q1": 1}
assert (await rows())[0].answers["cssrs_q1"] == 1
async def test_evening_schedule_uses_moscow_date(released, monkeypatch):
async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59))
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=4, minute=59))
await jobs.plan()
assert await rows(QuestionnaireTask) == []
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0))
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=5, minute=0))
await jobs.plan()
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"}
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning"}
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=17, minute=0))
await jobs.plan()
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning", "diary_evening"}
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))
replies = await press(released, "intake:resume")
await press(released, answer_button(replies))
(record,) = await rows()
metadata = record.details["answer_metadata"]["cssrs_q2"]
assert metadata["period_start"] == "2026-09-03"
metadata = record.details["answer_metadata"]["phq9_2"]
assert metadata["period_start"] == "2026-09-19"
assert metadata["period_end"] == "2026-10-03"
assert metadata["answered_at"].startswith("2026-10-04")
async def test_intake_result_is_shown_logged_and_sets_the_diary_modules(released):
from app.api.patient_web import my_results
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
choices.update(trauma=1, tbi=1, psychotropics=1)
replies = await complete_intake(released, choices)
assert any("Предположение депрессии" in r.text for r in replies)
assert any("не диагноз" in r.text for r in replies)
(row,) = await rows()
interpretation = row.details["interpretation"]
assert interpretation["codes"] == ["depression_suspected"] and interpretation["modules"] == ["depression"]
async with session.get_clinical_session() as s:
# A doctor's modules (diagnosis) stay; the screening only adds to them.
assert (await repo.get_profile(s, TOKEN)).modules == ["veteran", "depression"]
results = await my_results(TOKEN)
assert results.conclusion.title == interpretation["title"]
assert [h.kind for h in results.history] == ["intake"] and results.history[0].status_title == "пройден"
async def test_without_a_doctor_the_screening_defines_the_modules(released):
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
replies = await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
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)
+8
View File
@@ -202,6 +202,14 @@ async def test_side_effect_other_free_text(engine: Engine) -> None:
assert (await checkin()).side_effects == ["headache", "other"]
async def test_side_effect_description_is_optional(engine: Engine) -> None:
reminder = await system(engine, "med_reminder")
r = await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
assert r[0].kind == "question" # the client shows an (optional) input for it
await press(engine, "text:skip")
assert (await checkin()).side_effects == ["other"] and (await get_state())[0] == "idle"
async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_checkin(
+97 -29
View File
@@ -56,28 +56,51 @@ def test_medication_schedule_overlapping_and_delayed_completion():
)
def test_diary_consecutive_days_and_baseline_do_not_include_bad_window():
today = date(2026, 10, 3)
history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)}
for i in range(3):
history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"}
assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"}
history[today - timedelta(days=1)] = {}
assert diary_flags(history, today) == ()
history[today]["death_thoughts"] = 1
assert diary_flags(history, today) == ("suicide_assessment_now",)
def norm_and_week(norm: dict, week: dict, today=date(2026, 10, 30)):
"""14 days of personal norm starting 27 days ago, then the last 7 days with other values."""
start = today - timedelta(days=27)
history = {start + timedelta(days=i): dict(norm) for i in range(14)}
history.update({today - timedelta(days=i): dict(week) for i in range(7)})
return history, today
def test_sleep_alternation_counts_but_threshold_is_strict():
def test_weekly_mood_against_personal_norm():
history, today = norm_and_week({"mood": 8}, {"mood": 5})
assert "phq9_due_mood" in diary_flags(history, today)
history, today = norm_and_week({"mood": 8}, {"mood": 6})
assert "phq9_due_mood" not in diary_flags(history, today)
# Inside the norm window there is no trend yet.
early = {date(2026, 10, 1) + timedelta(days=i): {"mood": 9 if i < 3 else 1} for i in range(10)}
assert "phq9_due_mood" not in diary_flags(early, date(2026, 10, 10))
def test_module_trends_bring_full_scales_forward():
ptsd = {"dpcl_1": 1, "dpcl_7": 1, "dpcl_10": 1, "dpcl_18": 1}
history, today = norm_and_week(ptsd, {k: 2 for k in ptsd})
assert "pcl5_due_ptsd" in diary_flags(history, today)
history, today = norm_and_week({"dphq_4": 0}, {"dphq_4": 1})
assert "phq9_due_depression" in diary_flags(history, today)
def test_sleep_alcohol_and_medication_flags():
today = date(2026, 10, 3)
h = {
today: {"nightmares": 3},
today - timedelta(days=1): {"sleep_hours": 3.5},
today - timedelta(days=2): {"sleep_hours": 3.5},
}
assert "sleep_review" in diary_flags(h, today)
h[today - timedelta(days=1)]["sleep_hours"] = 4
assert "sleep_review" not in diary_flags(h, today)
nights = {today - timedelta(days=i): {"sleep_hours": 7, "sleep_quality": 0} for i in range(3)}
nights[today - timedelta(days=1)] = {"sleep_hours": 3.5, "sleep_quality": 2}
assert "sleep_review" in diary_flags(nights, today)
nights[today - timedelta(days=1)]["sleep_hours"] = 4
assert "sleep_review" not in diary_flags(nights, today)
drinks = {today: {"alcohol_portions": 6}, today - timedelta(days=2): {"alcohol_portions": 7}}
assert set(diary_flags(drinks, today, medication_active=True)) >= {"alcohol_on_medication", "rus_audit_due_alcohol"}
assert "alcohol_on_medication" not in diary_flags(drinks, today)
missed = {today - timedelta(days=i): {"medication": "no"} for i in range(3)}
assert "medication_contact" in diary_flags(missed, today)
week = {today - timedelta(days=i): {"medication": "yes" if i % 3 else "no"} for i in range(7)}
assert "adherence_review" in diary_flags(week, today)
assert diary_flags({today: {"medication": "no", "medication_reason": "side_effects"}}, today) == (
"side_effects_review",
)
def test_total_threshold_and_zone_are_separate_decisions():
@@ -96,15 +119,60 @@ def test_total_threshold_and_zone_are_separate_decisions():
IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2})
def test_module_rotation_never_drops_suicide_question():
def test_diary_parts_and_module_rotation():
from app.core.intake_questions import diary_questions
first = {
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100)
}
second = {
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101)
}
assert "death_thoughts" in first & second
assert "nightmares" in first and "nightmares" not in second
assert "burden" in first and "burden" not in second
assert [q.id for q in diary_questions("morning", ["veteran", "depression"])] == [
"sleep_hours",
"sleep_quality",
"alcohol_portions",
]
evening = [q.id for q in diary_questions("evening", ["veteran", "depression"], day_index=0)]
assert evening == ["mood", "medication", "medication_reason", "dphq_1", "dphq_2", "dphq_3"] + [
"dpcl_1",
"dpcl_7",
"dpcl_10",
"dpcl_18",
]
assert [q.id for q in diary_questions("evening", ["depression"], day_index=5)][-3:] == [
"dphq_7",
"dphq_8",
"dphq_9",
]
assert [q.id for q in diary_questions("evening", [], day_index=1)] == ["mood", "medication", "medication_reason"]
def test_demo_policy_is_separate_and_marked(monkeypatch):
from app.config import get_settings
monkeypatch.setenv("INTAKE_DEMO", "1")
monkeypatch.setattr(get_settings(), "demo_clock", True)
demo = load_policy()
assert demo.approval and "DEMO" in demo.approval.reviewer and demo.cssrs_positive_policy == "graded"
monkeypatch.delenv("INTAKE_DEMO")
assert load_policy().approval is None
def test_full_scale_change_against_the_start_of_observation():
from app.core.interpretation import scale_interpretation
def phq(total, baseline, previous=None):
totals = {"phq9": {"complete": True, "total": total}}
return scale_interpretation(
"phq9", totals, baseline=baseline, previous=previous, policy=load_policy(), retriage_delta=5
)["codes"]
assert phq(4, 16) == ["minimal", "remission"]
assert phq(8, 16) == ["mild", "response"]
assert phq(11, 16) == ["moderate", "improved"]
assert phq(15, 16) == ["moderately_severe", "stable"]
assert phq(21, 10, previous=16) == ["severe", "worse"]
pcl = scale_interpretation(
"pcl5",
{"pcl5": {"complete": True, "total": 27}},
baseline=45,
previous=40,
policy=load_policy(),
retriage_delta=10,
)
assert pcl["codes"] == ["below", "response"]
+123
View File
@@ -0,0 +1,123 @@
"""Persistent, one-time linking and queue recovery; synthetic identities only."""
from datetime import timedelta
import pytest
from sqlalchemy import select
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.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.integrations import max_store as store
@pytest.fixture(autouse=True)
async def database():
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
yield
await session.configure()
async def test_codes_are_hashed_expiring_one_time_and_replaced():
now = clock.now()
async with session.get_identity_session() as s:
old, _ = await store.issue_code(s, "patient", now, 60)
code, expires = await store.issue_code(s, "patient", now, 60)
assert await store.code_token(s, old, now) is None
row = (await s.scalars(select(MaxLinkCode))).one()
assert row.digest != code and row.digest == store.digest(code)
assert await store.code_token(s, code, expires) is None
assert await store.bind(s, code, 100, now) == "patient"
assert await store.bind(s, code, 100, now) is None
async with session.get_identity_session() as s:
assert (await s.get(MaxBinding, "patient")).user_id == 100
async def test_active_binding_cannot_be_reassigned_in_either_direction():
now = clock.now()
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, "one", now, 60)
await store.bind(s, code, 100, now)
code, _ = await store.issue_code(s, "two", now, 60)
assert await store.bind(s, code, 100, now) is None
assert await store.code_token(s, code, now) == "two"
code, _ = await store.issue_code(s, "one", now, 60)
assert await store.bind(s, code, 200, now) is None
await store.disconnect(s, "one")
assert await store.code_token(s, code, now) is None
code, _ = await store.issue_code(s, "two", now, 60)
assert await store.bind(s, code, 100, now) == "two"
async def test_disconnect_cancels_pending_delivery_and_prevents_stale_ack():
now = clock.now()
async with session.get_identity_session() as s:
await store.enqueue(s, key="job", user_id=100, token="one", kind="notification", now=now)
row = await store.claim(s, now)
async with session.get_identity_session() as s:
await store.disconnect(s, "one")
await store.finish(s, row, state="sent", now=now)
async with session.get_identity_session() as s:
assert (await s.get(MaxDelivery, "job")).state == "cancelled"
async def test_queue_survives_sessions_deduplicates_and_recovers_expired_lease():
now = clock.now()
async with session.get_identity_session() as s:
for _ in range(2):
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
async with session.get_identity_session() as s:
first = await store.claim(s, now)
assert first.attempts == 1
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(seconds=59)) is None
async with session.get_identity_session() as s:
second = await store.claim(s, now + timedelta(seconds=61))
assert second.id == first.id and second.lease != first.lease and second.attempts == 2
async with session.get_identity_session() as s:
await store.finish(s, first, state="sent", now=now)
async with session.get_identity_session() as s:
assert (await s.get(MaxDelivery, "job")).state == "sending"
await store.finish(s, second, state="sent", now=now)
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(days=1)) is None
async def test_retry_deadline_and_attempt_limit():
now = clock.now()
async with session.get_identity_session() as s:
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
for attempt in range(store.MAX_ATTEMPTS):
when = now + timedelta(seconds=attempt * 120)
async with session.get_identity_session() as s:
row = await store.claim(s, when)
async with session.get_identity_session() as s:
await store.finish(s, row, state="queued", now=when, retry_seconds=120, error="http_429")
async with session.get_identity_session() as s:
assert await store.claim(s, when + timedelta(seconds=119)) is None
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(days=1)) is None
async with session.get_identity_session() as s:
row = await s.get(MaxDelivery, "job")
assert row.state == "failed" and row.attempts == store.MAX_ATTEMPTS
async def test_completion_scan_ignores_old_and_incomplete_tests_and_recovers_without_duplicates():
now = clock.now()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token="one", doctor_id="")
await repo.set_dialog_state(s, token="one", state="idle", data={"consent": True, "adult": True}, now=now)
for ts, complete in ((now - timedelta(days=1), True), (now, True), (now, False)):
s.add(ScaleResult(token="one", ts=ts, scale="phq9", total=0, severity="none", complete=complete))
async with session.get_identity_session() as s:
s.add(MaxBinding(token="one", user_id=100, enabled=True, connected_at=now))
for _ in range(2):
async with session.get_identity_session() as s:
await store.collect_completed(s, now)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.kind == "completed" and row.token == "one"
assert "phq9" not in row.id
+100
View File
@@ -169,3 +169,103 @@ def test_build_provider_modes():
assert isinstance(agent.build_provider(Settings(_env_file=None, chat_agent="mock")), MockAgentProvider)
assert agent.build_provider(Settings(_env_file=None, chat_agent="openrouter")) is None # no key
assert agent.build_provider(Settings(_env_file=None, chat_agent="off")) is None
async def test_check_defaults_to_current_prescription(sertraline):
out = json.loads(await run_tool(ToolContext(token=ANDREY), "check_drug_for_patient", {"drug": "мои таблетки"}))
assert out["found"] and out["inn"] == "сертралин"
assert "лактоз" in out["intolerance_check"] and "нет" in out["intolerance_check"]
assert any("Алкоголь" in t for t in out["general_interactions"])
async def test_empty_final_answer_is_nudged(sertraline):
replies = iter(
[
{"role": "assistant", "content": None, "tool_calls": _CALLS},
{"role": "assistant", "content": ""},
{"role": "assistant", "content": "Алкоголь во время лечения не рекомендуется. Обсудите с врачом."},
]
)
sent = []
def handler(request):
sent.append(json.loads(request.content))
return httpx.Response(200, json={"choices": [{"message": next(replies)}]})
provider = OpenRouterProvider(_settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
ans = await agent.answer_chat(ANDREY, scrub("пиво можно?"), provider=provider, settings=_settings())
assert ans is not None and "Алкоголь" in ans.text
assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1]
assert "сертралин" in sent[0]["messages"][0]["content"] # patient brief in the system prompt
async def test_region_block_string_error_degrades():
# Seen live from the RF server: 403 {"success": false, "error": "Access denied by security policy."}
client = httpx.AsyncClient(
transport=httpx.MockTransport(lambda r: httpx.Response(403, json={"success": False, "error": "Access denied"}))
)
provider = OpenRouterProvider(_settings(), client=client)
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None
async def test_unexpected_provider_bug_degrades():
class Broken:
name = "broken"
async def chat(self, messages, tools):
raise AttributeError("boom")
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=Broken(), settings=_settings()) is None
# ------------------------------------------------------------------------------------------------- gigachat
class _FakeGiga:
"""Mimics gigachat.GigaChat.achat: first a function_call, then the final text."""
def __init__(self):
self.chats = []
async def achat(self, chat):
from types import SimpleNamespace as NS
self.chats.append(chat)
if len(self.chats) == 1:
fc = NS(name="check_drug_for_patient", arguments={"drug": "ибупрофен"})
msg = NS(content="", function_call=fc)
else:
msg = NS(
content="Ибупрофен вместе с сертралином повышает риск кровотечений. Обсудите с врачом.",
function_call=None,
)
return NS(choices=[NS(message=msg)], usage=NS(total_tokens=42))
async def test_gigachat_tool_loop(sertraline):
from app.llm.agent.providers import GigaChatToolProvider
fake = _FakeGiga()
s = Settings(_env_file=None, chat_agent="gigachat", gigachat_credentials="x", chat_agent_timeout_s=5.0)
ans = await agent.answer_chat(
ANDREY, scrub("Можно ибупрофен?"), provider=GigaChatToolProvider(s, client=fake), settings=s
)
assert ans is not None and "кровотеч" in ans.text and ans.provider == "gigachat"
second = fake.chats[1]
roles = [str(m.role.value if hasattr(m.role, "value") else m.role) for m in second.messages]
assert roles == ["system", "user", "assistant", "function"]
assert second.messages[2].function_call.name == "check_drug_for_patient"
assert second.messages[3].name == "check_drug_for_patient"
assert {f.name for f in second.functions} == {
"search_guidelines",
"get_patient_record",
"get_drug_info",
"check_drug_for_patient",
}
def test_build_provider_prefers_gigachat():
from app.llm.agent.providers import GigaChatToolProvider
s = Settings(_env_file=None, chat_agent="auto", gigachat_credentials="x", openrouter_api_key="y")
assert isinstance(agent.build_provider(s), GigaChatToolProvider)
+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(

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