- 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>
298 lines
12 KiB
Python
298 lines
12 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_runtime import intake_enabled, load_runtime
|
|
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 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() -> AuthOut:
|
|
"""Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere."""
|
|
token = secrets.token_hex(TOKEN_LEN // 2)
|
|
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
|
|
|
|
|
|
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()
|
|
diary_done = any(r.kind == "diary" and r.day == diary_day and r.status == "complete" for r in records)
|
|
|
|
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,
|
|
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(
|
|
enabled=intake_enabled() and profile is not None,
|
|
intake=_intake_state(records),
|
|
diary_done=diary_done,
|
|
),
|
|
)
|
|
|
|
|
|
@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"}
|