- 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>
400 lines
16 KiB
Python
400 lines
16 KiB
Python
"""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 and nothing else — no identity record, no doctor. The engine
|
|
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is
|
|
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor.
|
|
|
|
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.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.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
|
|
|
|
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.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 (`/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; no identity record, no doctor.
|
|
|
|
With `focus` a doctor-less pseudonymous profile keeps only the chosen module, so the engine starts the
|
|
matching onboarding instead of going straight to the menu. Without it nothing is stored at all.
|
|
"""
|
|
token = secrets.token_hex(TOKEN_LEN // 2)
|
|
if body is not None and body.focus is not None:
|
|
async with get_clinical_session() as s:
|
|
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[body.focus]])
|
|
log.info("web auth: anonymous tok=%s", token[:6])
|
|
return AuthOut(token=token, mode="anonymous")
|
|
|
|
|
|
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"}
|