Files
Arxip222andClaude Opus 5.5 290b0d6151 feat(web): two-stage questionnaires, screening result and diary by result
- 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>
2026-10-04 15:14:09 +03:00

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)