CI / ruff · pytest (pull_request) Successful in 55s
Add gated TSQ, Russian C-SSRS Recent, durable responses and notifications, diaries, schedules and audited clinical review. Preserve manual assessment when the Russian follow-up source is unavailable. Refs #52
154 lines
6.4 KiB
Python
154 lines
6.4 KiB
Python
"""Versioned release gate and deterministic follow-up planning."""
|
|
|
|
from calendar import monthrange
|
|
from datetime import date, timedelta
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import yaml
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
|
from app.config import get_settings
|
|
from app.core.questionnaire import Approval, load_policy
|
|
|
|
|
|
class RuntimePolicy(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
version: str
|
|
enabled: bool = False
|
|
approval: Approval | None = None
|
|
content_review: Approval | None = None
|
|
contact_review: Approval | None = None
|
|
timezone: str
|
|
daily_hour: int = Field(ge=0, le=23)
|
|
mood_baseline_days: int = Field(ge=2)
|
|
mood_bad_days: int = Field(ge=1)
|
|
mood_drop: int = Field(ge=1, le=10)
|
|
sleep_bad_days: int = Field(ge=1)
|
|
sleep_hours_below: float = Field(gt=0, le=24)
|
|
nightmares_at: int = Field(ge=0, le=4)
|
|
missed_medication_days: int = Field(ge=1)
|
|
high_risk_silence_days: int = Field(ge=1)
|
|
medication_days: tuple[int, ...]
|
|
medication_repeat_days: int = Field(ge=1)
|
|
route_intervals: dict[str, dict[str, int]]
|
|
alcohol_repeat_days: int = Field(ge=1)
|
|
phq9_retriage_delta: int = Field(ge=1)
|
|
pcl5_retriage_delta: int = Field(ge=1)
|
|
|
|
@model_validator(mode="after")
|
|
def valid(self):
|
|
ZoneInfo(self.timezone)
|
|
if set(self.route_intervals) != {"after_crisis", "specialist", "self_help"} or any(
|
|
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
|
|
):
|
|
raise ValueError("all route and scale intervals must be defined")
|
|
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
|
|
raise ValueError("medication days must be nonempty and strictly increasing")
|
|
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
|
|
raise ValueError("intervals must be positive")
|
|
if any(
|
|
a and a.policy_version != self.version for a in (self.approval, self.content_review, self.contact_review)
|
|
):
|
|
raise ValueError("review must match the runtime version")
|
|
return self
|
|
|
|
@property
|
|
def released(self) -> bool:
|
|
return self.enabled and all((self.approval, self.content_review, self.contact_review))
|
|
|
|
|
|
def load_runtime(path: Path | None = None) -> RuntimePolicy:
|
|
path = path or get_settings().config_dir / "intake_runtime.yaml"
|
|
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text()))
|
|
|
|
|
|
def intake_enabled() -> bool:
|
|
return load_runtime().released and load_policy().approval is not None
|
|
|
|
|
|
def month_before(day: date, months: int = 1) -> date:
|
|
year, month = divmod(day.year * 12 + day.month - 1 - months, 12)
|
|
return day.replace(year=year, month=month + 1, day=min(day.day, monthrange(year, month + 1)[1]))
|
|
|
|
|
|
def due_scales(
|
|
*,
|
|
today: date,
|
|
enrolled: date,
|
|
last: dict[str, date],
|
|
route: Literal["specialist", "self_help"],
|
|
medication_changed: date | None = None,
|
|
after_crisis: date | None = None,
|
|
policy: RuntimePolicy | None = None,
|
|
) -> tuple[str, ...]:
|
|
"""Return each overdue form once. Therapy day zero is the recorded start/change date.
|
|
|
|
Due dates stay due until completion. Overlapping schedules choose the earlier
|
|
date; missed ticks do not create a burst of historical questionnaires.
|
|
"""
|
|
p = policy or load_runtime()
|
|
due = set()
|
|
for scale in ("phq9", "pcl5"):
|
|
completed = last.get(scale)
|
|
dates = [(completed or enrolled) + timedelta(days=p.route_intervals[route][scale])]
|
|
if after_crisis and after_crisis <= today:
|
|
dates.append(
|
|
max(completed or after_crisis, after_crisis) + timedelta(days=p.route_intervals["after_crisis"][scale])
|
|
)
|
|
if medication_changed and medication_changed <= today:
|
|
for offset in p.medication_days:
|
|
milestone = medication_changed + timedelta(days=offset)
|
|
if completed is None or completed < milestone:
|
|
dates.append(milestone)
|
|
break
|
|
else:
|
|
dates.append(
|
|
max(completed or medication_changed, medication_changed + timedelta(days=p.medication_days[-1]))
|
|
+ timedelta(days=p.medication_repeat_days)
|
|
)
|
|
if min(dates) <= today:
|
|
due.add(scale)
|
|
if (last.get("rus_audit_s") or enrolled) + timedelta(days=p.alcohol_repeat_days) <= today:
|
|
due.add("rus_audit_s")
|
|
return tuple(sorted(due))
|
|
|
|
|
|
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
|
|
"""Consecutive calendar dates; missing data never become zero or missed doses.
|
|
|
|
Baseline is the seven dates BEFORE the three-day deterioration window;
|
|
every baseline value is required. Nightmares OR short sleep must hold on
|
|
each of three nights. These candidate definitions require signed review.
|
|
"""
|
|
p = policy or load_runtime()
|
|
flags = []
|
|
current = history.get(today, {})
|
|
if current.get("death_thoughts") == 1:
|
|
flags.append("suicide_assessment_now")
|
|
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
|
|
baseline = [
|
|
history.get(today - timedelta(days=i), {}).get("mood")
|
|
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
|
|
]
|
|
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
|
|
mean = sum(baseline) / len(baseline)
|
|
if all(mean - x["mood"] >= p.mood_drop for x in recent):
|
|
flags.append("phq9_due_mood")
|
|
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
|
|
if all(
|
|
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
|
|
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
|
|
for r in nights
|
|
):
|
|
flags.append("sleep_review")
|
|
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
|
|
if all(r.get("medication") == "none" for r in doses):
|
|
flags.append("medication_contact")
|
|
if current.get("side_effects") == 1:
|
|
flags.append("side_effects_review")
|
|
if current.get("medication") == "partial":
|
|
flags.append("partial_medication_review")
|
|
return tuple(flags)
|