fix(api): provide pseudonymous patients access to doctor care

This commit is contained in:
Pavel Eliseev
2026-10-04 16:47:06 +03:00
parent 079c3003aa
commit 0743184f3d
6 changed files with 141 additions and 15 deletions
+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,
+30 -7
View File
@@ -7,9 +7,9 @@ exactly like the web console, and consent, onboarding, scales and check-ins come
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.
- **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.
@@ -36,11 +36,13 @@ 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__)
@@ -153,19 +155,40 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
@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.
"""Anonymous sign-in: a fresh random token; care without an identity record.
The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself.
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="", modules=[_FOCUS_MODULE[focus]] if focus else [])
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
+2 -1
View File
@@ -43,6 +43,7 @@ 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")
@@ -66,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
+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}"