- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
flags on weekly means against the personal norm; scheduled full scales with change
(remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).
ADR-012.
Refs #77
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
8.8 KiB
Python
206 lines
8.8 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, demo_mode, 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
|
|
morning_hour: int = Field(ge=0, le=23)
|
|
evening_hour: int = Field(ge=0, le=23)
|
|
norm_days: int = Field(ge=2)
|
|
week_days: int = Field(ge=2)
|
|
min_norm_values: int = Field(ge=1)
|
|
min_week_values: int = Field(ge=1)
|
|
mood_drop: float = Field(gt=0, le=10)
|
|
depression_item_rise: float = Field(gt=0, le=3)
|
|
ptsd_sum_rise: float = Field(gt=0, le=16)
|
|
sleep_bad_days: int = Field(ge=1)
|
|
sleep_hours_below: float = Field(gt=0, le=24)
|
|
alcohol_heavy_portions: float = Field(gt=0)
|
|
alcohol_heavy_days: int = Field(ge=1)
|
|
adherence_below: float = Field(gt=0, le=1)
|
|
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 self.morning_hour >= self.evening_hour:
|
|
raise ValueError("the morning diary must come before the evening one")
|
|
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 demo_intake() -> bool:
|
|
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): shows the questionnaires on synthetic data.
|
|
|
|
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
|
|
until the approvals are recorded there.
|
|
"""
|
|
return demo_mode()
|
|
|
|
|
|
def intake_enabled() -> bool:
|
|
return (load_runtime().released and load_policy().approval is not None) or demo_intake()
|
|
|
|
|
|
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 _mean(values: list[float]) -> float:
|
|
return sum(values) / len(values)
|
|
|
|
|
|
def diary_flags(
|
|
history: dict[date, dict],
|
|
today: date,
|
|
policy: RuntimePolicy | None = None,
|
|
*,
|
|
medication_active: bool = False,
|
|
) -> tuple[str, ...]:
|
|
"""Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days.
|
|
|
|
`history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of
|
|
observation; trends are checked only after it. Missing data never become zero or missed doses.
|
|
"""
|
|
p = policy or load_runtime()
|
|
flags: list[str] = []
|
|
current = history.get(today, {})
|
|
start = min(history) if history else today
|
|
norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
|
|
week_start = today - timedelta(days=p.week_days - 1)
|
|
week = [history[d] for d in sorted(history) if week_start <= d <= today]
|
|
trend = (week_start - start).days >= p.norm_days
|
|
|
|
def values(rows: list[dict], key) -> list[float]:
|
|
return [v for r in rows if (v := key(r)) is not None]
|
|
|
|
def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
|
|
before, now = values(norm, key), values(week, key)
|
|
if not trend or len(before) < min_norm or len(now) < min_week:
|
|
return False
|
|
change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before)
|
|
return change >= by
|
|
|
|
if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True):
|
|
flags.append("phq9_due_mood")
|
|
# Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item.
|
|
for item in range(1, 10):
|
|
if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2):
|
|
flags.append("phq9_due_depression")
|
|
break
|
|
# PTSD module: only the sum of the four items is interpreted, never single clusters.
|
|
ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18")
|
|
|
|
def ptsd_sum(r):
|
|
return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None
|
|
|
|
if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values):
|
|
flags.append("pcl5_due_ptsd")
|
|
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
|
|
if all(
|
|
(r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
|
|
for r in nights
|
|
):
|
|
flags.append("sleep_review")
|
|
heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions]
|
|
if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions:
|
|
flags.append("alcohol_on_medication")
|
|
if len(heavy) >= p.alcohol_heavy_days:
|
|
flags.append("rus_audit_due_alcohol")
|
|
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
|
|
if all(r.get("medication") == "no" for r in doses):
|
|
flags.append("medication_contact")
|
|
taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
|
|
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
|
|
flags.append("adherence_review")
|
|
if current.get("medication_reason") == "side_effects":
|
|
flags.append("side_effects_review")
|
|
return tuple(flags)
|