"""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 import time as monotonic_time 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, Request from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select from app.api import fixtures from app.api.schemas import DemoPersona from app.config import get_settings 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 identity_repo, 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, get_identity_session from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock from app.llm.stt import InvalidAudio, SttError, SttNoSpeech, get_stt, wav_seconds 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, source: str | None = None) -> 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(source or token) item = next((p for p in fixtures.PATIENTS if p.token == (source or 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") if persona.token == DEMO_SOURCE: # Every visitor gets a private copy (never the shared account); this entry starts from consent and the # initial questionnaires (subject to the screening switch), unlike the one-click chat demo. return await _fresh_demo_copy(start_in_chat=False) 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) DEMO_SOURCE = "tok-andrey" ONE_CLICK_NAME = "Андрей С." # «Демо: войти как Андрей»; the ЕМИАС demo persona is named in fixtures (Никита) @router.post("/auth/demo/fresh", response_model=AuthOut) async def auth_demo_fresh() -> AuthOut: return await _fresh_demo_copy(start_in_chat=True) async def _fresh_demo_copy(*, start_in_chat: bool) -> AuthOut: """Presentation entry: a fresh private copy of veteran Andrey for every sign-in — consent given, prescription and ЕМИАС record in place, dialog resting in the chat. Nobody's crisis or half-done questionnaire leaks into another viewer's demo. Synthetic data only.""" token = f"{DEMO_SOURCE}-{secrets.token_hex(4)}" now = clock.now() persona = next(p for p in fixtures.DEMO_PERSONAS if p.token == DEMO_SOURCE) name = persona.display_name if not start_in_chat else ONE_CLICK_NAME snils = f"000-{secrets.randbelow(10**3):03d}-{secrets.randbelow(10**3):03d} {secrets.randbelow(100):02d}" async with get_identity_session() as s: await identity_repo.create_patient( s, token=token, display_name=f"{name} (демо)", snils=snils, clinic_id="demo", doctor_id=fixtures.DOCTOR_ID, now=now, birth_year=now.year - 34, sex="m", ) if start_in_chat: await identity_repo.record_consent(s, token=token, version="demo", now=now) await _seed_demo_persona(token, source=DEMO_SOURCE) async with get_clinical_session() as s: await repo.upsert_profile(s, token=token, sex="m", birth_year=now.year - 34) if start_in_chat: consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None} await repo.set_dialog_state(s, token=token, state="idle", data={**consent, "adult": True}, now=now) log.info("web auth: demo fresh tok=%s", token[:14]) return AuthOut(token=token, mode="emias", display_name=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(), "voice_input": get_stt(get_settings()) is not None} class SttOut(BaseModel): text: str provider: str # 16 kHz mono 16-bit PCM = 32 000 bytes/s, + header slack. _WAV_BYTES_PER_SECOND = 32_000 @router.post("/me/{token}/stt", response_model=SttOut) async def transcribe_voice(token: str, request: Request) -> SttOut: """Voice → text for the patient to check and edit (#116). Body: raw `audio/wav`, 16 kHz mono. Only transcribes: nothing is written to the dialog and the agent is not called — the patient sends the checked text as an ordinary message, where safety and `scrub()` apply. The audio is neither stored nor logged. """ settings = get_settings() stt = get_stt(settings) if stt is None: raise HTTPException(404, "voice input is off") async with get_clinical_session() as s: if await repo.get_profile(s, token) is None: raise HTTPException(404, "patient not found") max_bytes = (settings.stt_max_seconds + 2) * _WAV_BYTES_PER_SECOND + 4096 declared = request.headers.get("content-length") if declared and declared.isdigit() and int(declared) > max_bytes: raise HTTPException(413, "recording too long") audio = await request.body() if len(audio) > max_bytes: raise HTTPException(413, "recording too long") try: seconds = wav_seconds(audio) except InvalidAudio as exc: raise HTTPException(415, "expected audio/wav") from exc if seconds > settings.stt_max_seconds + 1: raise HTTPException(413, "recording too long") started = monotonic_time.monotonic() # latency only, not domain time (that is app.core.clock) try: text = await stt.transcribe(audio, "wav") except SttNoSpeech as exc: raise HTTPException(422, "no speech recognised") from exc except SttError as exc: log.warning("stt failed tok=%s provider=%s: %s", token[:6], stt.name, exc) raise HTTPException(503, "speech recognition unavailable") from exc ms = int((monotonic_time.monotonic() - started) * 1000) log.info("stt tok=%s provider=%s secs=%.1f ms=%d chars=%d", token[:6], stt.name, seconds, ms, len(text)) return SttOut(text=text, provider=stt.name) @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"}