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>
This commit is contained in:
+82
-11
@@ -1,4 +1,5 @@
|
||||
"""Patient web app: sign-in and public reference data (`/api/v1/auth/*`, `/api/v1/crisis/resources`).
|
||||
"""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.
|
||||
@@ -18,7 +19,8 @@ import logging
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -26,10 +28,14 @@ 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
|
||||
|
||||
@@ -80,15 +86,21 @@ async def _seed_demo_persona(token: str) -> None:
|
||||
token=token,
|
||||
doctor_id=fixtures.DOCTOR_ID,
|
||||
modules=list(item.modules),
|
||||
therapy_start=today,
|
||||
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=today)
|
||||
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(
|
||||
@@ -147,6 +159,18 @@ class PrescriptionItem(BaseModel):
|
||||
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):
|
||||
@@ -157,6 +181,19 @@ class TodayOut(BaseModel):
|
||||
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"]:
|
||||
@@ -187,6 +224,7 @@ async def my_today(token: str) -> TodayOut:
|
||||
)
|
||||
).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] = []
|
||||
@@ -200,23 +238,40 @@ async def my_today(token: str) -> TodayOut:
|
||||
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,
|
||||
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=[
|
||||
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],
|
||||
)
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -224,3 +279,19 @@ async def my_today(token: str) -> TodayOut:
|
||||
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"}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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
|
||||
|
||||
import yaml
|
||||
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 content, 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,
|
||||
),
|
||||
)
|
||||
|
||||
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 = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
|
||||
diary_items = [item for key in ("core", "ptsd", "depression", "suicide") for item in diary.get(key, [])]
|
||||
add(
|
||||
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
|
||||
[i["id"] for i in diary_items],
|
||||
optional={i["id"] for i in diary_items if i.get("optional")},
|
||||
numeric={i["id"] for i in diary_items 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())
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Versioned release gate and deterministic follow-up planning."""
|
||||
|
||||
import os
|
||||
from calendar import monthrange
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
@@ -64,8 +65,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 os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user