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.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult
from app.db.identity import Patient from app.db.identity import Patient
from app.integrations.base import Prescription as PrescriptionOut 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} COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
_MODULES = frozenset(get_args(Module)) _MODULES = frozenset(get_args(Module))
@@ -96,6 +97,7 @@ class PatientData:
doses: Sequence[Dose] doses: Sequence[Dose]
prescriptions: Sequence[Prescription] prescriptions: Sequence[Prescription]
alerts: Sequence[Alert] # newest first alerts: Sequence[Alert] # newest first
contact_requested_at: datetime | None = None
async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData: 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 = ( rx = (
await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id)) await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id))
).all() ).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( return PatientData(
profile=profile, profile=profile,
checkins=await repo.list_checkins(session, token), checkins=await repo.list_checkins(session, token),
@@ -111,6 +116,7 @@ async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> P
doses=doses, doses=doses,
prescriptions=rx, prescriptions=rx,
alerts=await repo.list_alerts(session, token=token), 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 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] scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS]
scale = scales[-1] if scales else None 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): if scale is not None and (alert is None or scale.ts > alert.ts):
day = _therapy_day(d.profile, _local(scale.ts, now)) day = _therapy_day(d.profile, _local(scale.ts, now))
return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})" 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) rx = _active_rx(d, today)
return PatientListItem( return PatientListItem(
token=p.token, 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), age=_age(patient, p, today),
modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES], modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES],
drug=_drug_label(rx) if rx else None, 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: Two ways in:
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The - **ЕМИАС** (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. 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 - **Anonymous**: a random pseudonymous token, no identity record, and access to the prototype care team.
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is Clinical answers are stored on the server and visible to the assigned doctor under a pseudonym.
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor. 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 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. 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.meds_reference import find_drug, indication_for, load_reference
from app.core.safety import CrisisResource, crisis_reply from app.core.safety import CrisisResource, crisis_reply
from app.db import repo from app.db import repo
from app.db.audit import audit
from app.db.clinical import DailyCheckin, Dose, Prescription from app.db.clinical import DailyCheckin, Dose, Prescription
from app.db.identity import TOKEN_LEN from app.db.identity import TOKEN_LEN
from app.db.intake import QuestionnaireRecord from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session from app.db.session import get_clinical_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
from app.services import anonymous_care
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -153,19 +155,40 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
@router.post("/auth/anonymous", response_model=AuthOut) @router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous(body: AnonymousIn | None = None) -> 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 The clinical side gets a pseudonymous profile assigned to the prototype care team. It holds
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself. no identity fields. Legacy clients may supply `focus`; the common stage 1 screening sets modules itself.
""" """
token = secrets.token_hex(TOKEN_LEN // 2) token = secrets.token_hex(TOKEN_LEN // 2)
focus = body.focus if body is not None else None focus = body.focus if body is not None else None
async with get_clinical_session() as s: 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]) log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous") 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): class DoseItem(BaseModel):
id: int id: int
drug: str 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.dialog.contracts import Event, get_engine
from app.integrations.base import integrations from app.integrations.base import integrations
from app.scheduler import jobs as scheduler 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 from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1") 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: def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token) p = patients.get(token)
return p.display_name if p else "Пациент" return p.display_name if p else anonymous_name(token)
# ------------------------------------------------------------------ doctor dashboard # ------------------------------------------------------------------ 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}"
+3
View File
@@ -0,0 +1,3 @@
# Routing for the current prototype: anonymous patients use the same doctor workspace,
# without an identity record. Set this to the responsible clinic's doctor when deploying.
doctor_id: doc-smirnova
+71 -6
View File
@@ -12,6 +12,7 @@ from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription from app.db.clinical import Dose, Prescription
from app.main import app from app.main import app
from app.services.anonymous_care import doctor_id as anonymous_doctor
MEM = "sqlite+aiosqlite:///:memory:" MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "b" * 32 TOKEN = "b" * 32
@@ -48,17 +49,17 @@ async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: Asy
async with session.get_identity_session() as s: async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
# Only a doctor-less pseudonymous profile: the dialog API serves known profiles only. # The care team sees a pseudonymous profile without an identity record.
profile = await repo.get_profile(s, first["token"]) profile = await repo.get_profile(s, first["token"])
assert profile.doctor_id == "" and profile.modules == [] assert profile.doctor_id == anonymous_doctor() and profile.modules == []
async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None: async def test_anonymous_focus_does_not_remove_access_to_doctor(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"] token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"]
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token) profile = await repo.get_profile(s, token)
assert profile.modules == ["veteran"] and not profile.doctor_id assert profile.modules == ["veteran"] and profile.doctor_id == anonymous_doctor()
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is True
assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422 assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422
@@ -122,7 +123,7 @@ async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None: async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"] token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
body = (await client.get(f"/api/v1/me/{token}/today")).json() body = (await client.get(f"/api/v1/me/{token}/today")).json()
assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == [] assert body["has_doctor"] is True and body["doses"] == [] and body["prescriptions"] == []
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None: async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
@@ -220,3 +221,67 @@ async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None: async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
body = (await client.get("/api/v1/patient/demo-personas")).json() body = (await client.get("/api/v1/patient/demo-personas")).json()
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS] assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
async def test_anonymous_is_visible_only_to_assigned_doctor(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()["token"]
second = (await client.post("/api/v1/auth/anonymous")).json()["token"]
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
names = {p["token"]: p["display_name"] for p in items}
assert names[first].startswith("Пациент ") and names[first] != names[second]
assert first not in names[first]
own = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": anonymous_doctor()})
assert own.status_code == 200 and own.json()["patient"]["display_name"] == names[first]
denied = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": "another-doctor"})
assert denied.status_code == 403
assert (await client.get("/api/v1/doctors/another-doctor/patients")).json() == []
async def test_old_anonymous_profile_requires_explicit_care_opt_in(client: AsyncClient) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
# Merely opening Today must not reveal an old private profile to a doctor.
assert not (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["has_doctor"]
assert (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json() == []
for _ in range(2):
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).json() == {"has_doctor": True}
async with session.get_clinical_session() as s:
p = await repo.get_profile(s, TOKEN)
assert p.doctor_id == anonymous_doctor() and p.modules == ["veteran"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, TOKEN) is None
async def test_connect_care_does_not_reassign_or_create_patients(client: AsyncClient) -> None:
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 404
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="own-doctor")
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 200
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "own-doctor"
async def test_screening_capability_respects_release_gate(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": False}
monkeypatch.setenv("INTAKE_DEMO", "1")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": True}
async def test_anonymous_contact_request_reaches_doctor(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
for event in [
{"type": "start"},
{"type": "button", "button": "consent:yes"},
{"type": "button", "button": "age:adult"},
{"type": "button", "button": "menu:contact_doctor"},
]:
response = await client.post(f"/api/v1/dialog/{token}/events", json=event)
assert response.status_code == 200
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
assert items[0]["last_event"] == "Пациент просит врача связаться"
card = (await client.get(f"/api/v1/patients/{token}", params={"doctor_id": anonymous_doctor()})).json()
assert card["patient"]["last_event"] == items[0]["last_event"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, token) is None