Files
PTSRaiAgent/app/db/repo.py
T
ElebobaandClaude Opus 5.5 121b607414
CI / ruff · pytest (pull_request) Successful in 45s
feat(backend): data model — identity / clinical / audit, repositories, create_all
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>
2026-10-03 14:58:26 +03:00

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()