Files
PTSRaiAgent/app/db/repo.py
T
ElebobaandClaude Opus 5.5 b4731fb1d6
CI / ruff · pytest (pull_request) Successful in 22s
chore(core): stage 1 contracts — dialog, API schemas + fixtures, integrations, monitoring, modules
Freeze interfaces between lanes A (bot), B (backend), C (dashboard) so three developers work in
parallel without touching each other's files: dialog Event/Reply + engine registry with echo stub,
integration protocols with in-memory defaults, MonitoringService with noop stub, /api/v1 schemas
with fixture-backed routes, frozen main.py wiring, config/modules.yaml + due_scales(), DAR-5,
tzdata, Andrey trajectory, lanes in WORKFLOW, ADR-010.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:22:25 +03:00

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