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