CI / ruff · pytest (pull_request) Successful in 45s
Two DeclarativeBases on two engines (ADR-003): identity holds direct identifiers, clinical holds token-keyed pseudonymised data plus audit_log. Portable types (JSONB variant, tz-aware UTC timestamps rejecting naive values) so tests run on SQLite. init_db() runs create_all at startup. Guardrail #5 checked by AST and import-graph tests. Refs #6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
8.6 KiB
Python
299 lines
8.6 KiB
Python
"""Thin clinical-DB repository helpers. Must not import `app.db.identity` (guardrail #5).
|
|
|
|
Functions work inside the caller's session (`get_clinical_session()`), flush but never commit, and take
|
|
time as an explicit argument (ADR-005).
|
|
"""
|
|
|
|
from collections.abc import Sequence
|
|
from datetime import date, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.types import AlertLevel, ScrubbedText
|
|
from app.db.audit import audit
|
|
from app.db.clinical import (
|
|
Alert,
|
|
ClockState,
|
|
DailyCheckin,
|
|
DialogState,
|
|
ItemResponse,
|
|
Message,
|
|
PatientProfile,
|
|
ScaleResult,
|
|
)
|
|
|
|
_CHECKIN_FIELDS = frozenset(
|
|
{
|
|
"mood",
|
|
"med_taken",
|
|
"sleep_bucket",
|
|
"alcohol_bucket",
|
|
"side_effects",
|
|
"quit_intent",
|
|
"nightmares",
|
|
"free_text_scrubbed",
|
|
}
|
|
)
|
|
_PROFILE_FIELDS = frozenset(
|
|
{
|
|
"doctor_id",
|
|
"modules",
|
|
"therapy_start",
|
|
"next_visit",
|
|
"birth_year",
|
|
"sex",
|
|
"enhanced_monitoring",
|
|
"status_color",
|
|
}
|
|
)
|
|
|
|
|
|
# --- patient profiles -------------------------------------------------------------------------------
|
|
|
|
|
|
async def get_profile(session: AsyncSession, token: str) -> PatientProfile | None:
|
|
return await session.get(PatientProfile, token)
|
|
|
|
|
|
async def upsert_profile(session: AsyncSession, *, token: str, **fields: Any) -> PatientProfile:
|
|
"""Create or update a profile; only the passed fields are changed."""
|
|
unknown = set(fields) - _PROFILE_FIELDS
|
|
if unknown:
|
|
raise ValueError(f"unknown profile fields: {sorted(unknown)}")
|
|
profile = await session.get(PatientProfile, token)
|
|
if profile is None:
|
|
profile = PatientProfile(token=token, modules=[], enhanced_monitoring=False)
|
|
session.add(profile)
|
|
for key, value in fields.items():
|
|
setattr(profile, key, value)
|
|
await session.flush()
|
|
return profile
|
|
|
|
|
|
async def patients_of_doctor(session: AsyncSession, doctor_id: str) -> Sequence[PatientProfile]:
|
|
rows = await session.scalars(
|
|
select(PatientProfile).where(PatientProfile.doctor_id == doctor_id).order_by(PatientProfile.token)
|
|
)
|
|
return rows.all()
|
|
|
|
|
|
# --- daily check-ins and scales ---------------------------------------------------------------------
|
|
|
|
|
|
async def upsert_checkin(
|
|
session: AsyncSession, *, token: str, day: date, now: datetime, **fields: Any
|
|
) -> DailyCheckin:
|
|
"""Merge answers into the (token, day) check-in.
|
|
|
|
The daily dialog fills the row step by step (mood, then medication, then the module question), so
|
|
only fields passed and not None overwrite stored values.
|
|
"""
|
|
unknown = set(fields) - _CHECKIN_FIELDS
|
|
if unknown:
|
|
raise ValueError(f"unknown check-in fields: {sorted(unknown)}")
|
|
row = await session.scalar(
|
|
select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == day)
|
|
)
|
|
if row is None:
|
|
row = DailyCheckin(token=token, day=day, side_effects=[], updated_at=now)
|
|
session.add(row)
|
|
for key, value in fields.items():
|
|
if value is not None:
|
|
setattr(row, key, value)
|
|
row.updated_at = now
|
|
await session.flush()
|
|
return row
|
|
|
|
|
|
async def list_checkins(
|
|
session: AsyncSession, token: str, since: date | None = None
|
|
) -> Sequence[DailyCheckin]:
|
|
stmt = select(DailyCheckin).where(DailyCheckin.token == token)
|
|
if since is not None:
|
|
stmt = stmt.where(DailyCheckin.day >= since)
|
|
return (await session.scalars(stmt.order_by(DailyCheckin.day))).all()
|
|
|
|
|
|
async def add_item_response(
|
|
session: AsyncSession,
|
|
*,
|
|
token: str,
|
|
scale: str,
|
|
item_id: str,
|
|
score: int | None,
|
|
source: str,
|
|
ts: datetime,
|
|
confidence: float | None = None,
|
|
evidence: ScrubbedText | None = None,
|
|
) -> ItemResponse:
|
|
row = ItemResponse(
|
|
token=token,
|
|
scale=scale,
|
|
item_id=item_id,
|
|
score=score,
|
|
source=source,
|
|
ts=ts,
|
|
confidence=confidence,
|
|
evidence=evidence,
|
|
)
|
|
session.add(row)
|
|
await session.flush()
|
|
return row
|
|
|
|
|
|
async def add_scale_result(
|
|
session: AsyncSession,
|
|
*,
|
|
token: str,
|
|
scale: str,
|
|
total: int,
|
|
severity: str,
|
|
complete: bool,
|
|
ts: datetime,
|
|
item9: int | None = None,
|
|
scheduled: bool = True,
|
|
flags: dict[str, Any] | None = None,
|
|
period_start: date | None = None,
|
|
period_end: date | None = None,
|
|
) -> ScaleResult:
|
|
row = ScaleResult(
|
|
token=token,
|
|
scale=scale,
|
|
total=total,
|
|
severity=severity,
|
|
complete=complete,
|
|
ts=ts,
|
|
item9=item9,
|
|
scheduled=scheduled,
|
|
flags=flags or {},
|
|
period_start=period_start,
|
|
period_end=period_end,
|
|
)
|
|
session.add(row)
|
|
await session.flush()
|
|
return row
|
|
|
|
|
|
async def list_scale_results(
|
|
session: AsyncSession, token: str, scale: str | None = None
|
|
) -> Sequence[ScaleResult]:
|
|
stmt = select(ScaleResult).where(ScaleResult.token == token)
|
|
if scale is not None:
|
|
stmt = stmt.where(ScaleResult.scale == scale)
|
|
return (await session.scalars(stmt.order_by(ScaleResult.ts))).all()
|
|
|
|
|
|
# --- alerts -----------------------------------------------------------------------------------------
|
|
|
|
|
|
async def add_alert(
|
|
session: AsyncSession,
|
|
*,
|
|
token: str,
|
|
level: AlertLevel,
|
|
code: str,
|
|
reason: str,
|
|
ts: datetime,
|
|
params: dict[str, Any] | None = None,
|
|
) -> Alert:
|
|
row = Alert(token=token, level=level.name.lower(), code=code, reason=reason, ts=ts, params=params or {})
|
|
session.add(row)
|
|
await session.flush()
|
|
return row
|
|
|
|
|
|
async def open_alert_codes(session: AsyncSession, token: str) -> set[str]:
|
|
"""Codes of unacknowledged alerts — lets rules avoid raising the same alert twice."""
|
|
rows = await session.scalars(select(Alert.code).where(Alert.token == token, Alert.acked_at.is_(None)))
|
|
return set(rows.all())
|
|
|
|
|
|
async def list_alerts(
|
|
session: AsyncSession, *, token: str | None = None, open_only: bool = False
|
|
) -> Sequence[Alert]:
|
|
stmt = select(Alert)
|
|
if token is not None:
|
|
stmt = stmt.where(Alert.token == token)
|
|
if open_only:
|
|
stmt = stmt.where(Alert.acked_at.is_(None))
|
|
return (await session.scalars(stmt.order_by(Alert.ts.desc(), Alert.id.desc()))).all()
|
|
|
|
|
|
async def ack_alert(session: AsyncSession, *, alert_id: int, doctor_id: str, ts: datetime) -> Alert | None:
|
|
"""Doctor acknowledges an alert; the access is written to audit_log in the same transaction.
|
|
|
|
Re-acking is a no-op (first acknowledgement wins, no duplicate audit record). Unknown id -> None.
|
|
"""
|
|
alert = await session.get(Alert, alert_id)
|
|
if alert is None or alert.acked_at is not None:
|
|
return alert
|
|
alert.acked_by = doctor_id
|
|
alert.acked_at = ts
|
|
await audit(
|
|
session,
|
|
actor=doctor_id,
|
|
action="ack_alert",
|
|
token=alert.token,
|
|
details={"alert_id": alert.id, "code": alert.code, "level": alert.level},
|
|
ts=ts,
|
|
)
|
|
return alert
|
|
|
|
|
|
# --- dialog -----------------------------------------------------------------------------------------
|
|
|
|
|
|
async def get_dialog_state(session: AsyncSession, token: str) -> DialogState | None:
|
|
return await session.get(DialogState, token)
|
|
|
|
|
|
async def set_dialog_state(
|
|
session: AsyncSession, *, token: str, state: str, data: dict[str, Any], now: datetime
|
|
) -> DialogState:
|
|
row = await session.get(DialogState, token)
|
|
if row is None:
|
|
row = DialogState(token=token, state=state, data=data, updated_at=now)
|
|
session.add(row)
|
|
else:
|
|
row.state = state
|
|
row.data = dict(data) # new object so SQLAlchemy sees the JSON change
|
|
row.updated_at = now
|
|
await session.flush()
|
|
return row
|
|
|
|
|
|
async def add_message(
|
|
session: AsyncSession,
|
|
*,
|
|
token: str,
|
|
direction: str,
|
|
text: ScrubbedText,
|
|
ts: datetime,
|
|
fsm_state: str | None = None,
|
|
) -> Message:
|
|
if direction not in ("in", "out"):
|
|
raise ValueError("direction must be 'in' or 'out'")
|
|
row = Message(token=token, direction=direction, text_scrubbed=text, ts=ts, fsm_state=fsm_state)
|
|
session.add(row)
|
|
await session.flush()
|
|
return row
|
|
|
|
|
|
# --- demo clock persistence (ADR-005) ----------------------------------------------------------------
|
|
|
|
|
|
async def load_clock_offset(session: AsyncSession) -> float:
|
|
row = await session.get(ClockState, 1)
|
|
return row.offset_seconds if row else 0.0
|
|
|
|
|
|
async def save_clock_offset(session: AsyncSession, offset_seconds: float) -> None:
|
|
row = await session.get(ClockState, 1)
|
|
if row is None:
|
|
session.add(ClockState(id=1, offset_seconds=offset_seconds))
|
|
else:
|
|
row.offset_seconds = offset_seconds
|
|
await session.flush()
|