feat(questionnaire): integrate revised intake and follow-up monitoring
CI / ruff · pytest (pull_request) Successful in 55s
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
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""Question selection and exact versioned content, with no side effects."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import yaml
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.cssrs_content import load_cssrs_russian
|
||||
from app.core.questionnaire import IntakePolicy, alcohol_expansion, score_alcohol, score_cssrs_recent
|
||||
from app.core.scales import ScaleDef, load_scale
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Question:
|
||||
id: str
|
||||
text: str
|
||||
options: tuple[tuple[object, str], ...] = ()
|
||||
period: str = "context"
|
||||
numeric: bool = False
|
||||
optional: bool = False
|
||||
|
||||
|
||||
def content() -> dict:
|
||||
return yaml.safe_load((get_settings().config_dir / "instruments/intake_forms.yaml").read_text())
|
||||
|
||||
|
||||
def texts() -> dict:
|
||||
return yaml.safe_load((get_settings().config_dir / "texts/intake.yaml").read_text())
|
||||
|
||||
|
||||
def scale_question(scale_id: str, item_id: str) -> Question:
|
||||
forms = content()["forms"]
|
||||
scale = ScaleDef.model_validate(forms[scale_id]) if scale_id in forms else load_scale(scale_id)
|
||||
item = next(i for i in scale.items if i.id == item_id)
|
||||
instruction = scale.instruction or ""
|
||||
# The beverage table is part of the approved questionnaire source.
|
||||
if item_id == "rus_audit_2":
|
||||
instruction += "\n" + texts()["standard_drinks"]
|
||||
return Question(
|
||||
item_id,
|
||||
instruction + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in item.options),
|
||||
scale.recall_period or "",
|
||||
)
|
||||
|
||||
|
||||
def group_answers(answers: dict, prefix: str) -> dict[str, int]:
|
||||
return {k: v for k, v in answers.items() if k.startswith(prefix + "_")}
|
||||
|
||||
|
||||
def cssrs_answers(answers: dict) -> dict[str, int]:
|
||||
return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")}
|
||||
|
||||
|
||||
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
if c.missing:
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == c.missing[0])
|
||||
period = texts()["periods"][item.recall_period]
|
||||
# Guidance is interviewer-facing; retained in the source and record version.
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
period + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
item.recall_period,
|
||||
)
|
||||
for item in ("phq9_1", "phq9_2"):
|
||||
if item not in answers:
|
||||
return scale_question("phq9", item)
|
||||
if "trauma" not in answers:
|
||||
return Question(
|
||||
"trauma", content()["trauma_introduction"] + "\n\n" + content()["trauma_filter"], ((1, "Да"), (0, "Нет"))
|
||||
)
|
||||
if answers["trauma"]:
|
||||
for item in load_scale("tsq").item_ids:
|
||||
if item not in answers:
|
||||
return scale_question("tsq", item)
|
||||
if "recent_trauma" not in answers:
|
||||
return Question("recent_trauma", texts()["recent_trauma"], ((1, "Да"), (0, "Нет")))
|
||||
for item in load_scale("rus_audit_s").item_ids:
|
||||
if item not in answers:
|
||||
return scale_question("rus_audit_s", item)
|
||||
if sex is None and "sex" not in answers:
|
||||
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
|
||||
for key in ("tbi", "psychotropics"):
|
||||
if key not in answers:
|
||||
return Question(key, texts()[key], ((1, "Да"), (0, "Нет")))
|
||||
if answers["phq9_1"] + answers["phq9_2"] >= policy.phq2_expand_at:
|
||||
for item in load_scale("phq9").item_ids:
|
||||
if item not in answers:
|
||||
return scale_question("phq9", item)
|
||||
if answers["trauma"] and sum(group_answers(answers, "tsq").values()) >= policy.tsq_expand_at:
|
||||
for item in load_scale("pcl5").item_ids:
|
||||
if item not in answers:
|
||||
return scale_question("pcl5", item)
|
||||
alcohol = group_answers(answers, "rus_audit")
|
||||
selected_sex = sex or (answers.get("sex") if answers.get("sex") in ("m", "f") else None)
|
||||
short = score_alcohol(
|
||||
{k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}, short=True, sex=selected_sex
|
||||
)
|
||||
if alcohol_expansion(short, policy):
|
||||
for item in load_scale("rus_audit").item_ids:
|
||||
if item == "rus_audit_2" and alcohol.get("rus_audit_1") == 0:
|
||||
continue
|
||||
if item not in answers:
|
||||
return scale_question("rus_audit", item)
|
||||
return None
|
||||
|
||||
|
||||
def diary_questions(
|
||||
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
|
||||
) -> tuple[Question, ...]:
|
||||
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
|
||||
selected = ["core"]
|
||||
detailed = [name for name in ("veteran", "depression") if name in modules]
|
||||
if len(detailed) == 2:
|
||||
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
|
||||
if "veteran" in detailed:
|
||||
selected.append("ptsd")
|
||||
if "depression" in detailed:
|
||||
selected.append("depression")
|
||||
if suicide_monitoring or "depression" in modules:
|
||||
selected.append("suicide")
|
||||
questions = []
|
||||
for section in selected:
|
||||
for item in config[section]:
|
||||
if item.get("weekly") and weekday != 0:
|
||||
continue
|
||||
questions.append(
|
||||
Question(
|
||||
item["id"],
|
||||
item["text"],
|
||||
tuple((o["value"], o["label"]) for o in item.get("options", [])),
|
||||
"past_week" if item.get("weekly") else "past_day",
|
||||
item.get("numeric", False),
|
||||
item.get("optional", False),
|
||||
)
|
||||
)
|
||||
questions.sort(key=lambda q: q.id != "death_thoughts")
|
||||
return tuple(questions)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""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)
|
||||
+15
-12
@@ -41,9 +41,10 @@ class IntakePolicy(BaseModel):
|
||||
version: str = Field(min_length=1)
|
||||
approval: Approval | None = None
|
||||
cssrs_positive_policy: Literal["pending", "stop_any", "graded"] = "pending"
|
||||
rus_audit_s_expand_at: int | None = Field(default=None, ge=1, le=12, strict=True)
|
||||
rus_audit_s_expand_from_zone: int | None = Field(default=None, ge=1, le=4, strict=True)
|
||||
phq2_expand_at: int = Field(ge=1, le=6, strict=True)
|
||||
pcptsd5_expand_at: int = Field(ge=1, le=5, strict=True)
|
||||
tsq_expand_at: int = Field(ge=1, le=10, strict=True)
|
||||
phq9_psychologist_at: int = Field(ge=0, le=27, strict=True)
|
||||
phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
|
||||
phq9_priority_at: int = Field(ge=0, le=27, strict=True)
|
||||
@@ -56,9 +57,11 @@ class IntakePolicy(BaseModel):
|
||||
if self.approval and (
|
||||
self.approval.policy_version != self.version
|
||||
or self.cssrs_positive_policy == "pending"
|
||||
or self.rus_audit_s_expand_from_zone is None
|
||||
or (self.rus_audit_s_expand_from_zone is None and self.rus_audit_s_expand_at is None)
|
||||
):
|
||||
raise ValueError("approval requires the matching version and resolved clinical decisions")
|
||||
if self.rus_audit_s_expand_from_zone is not None and self.rus_audit_s_expand_at is not None:
|
||||
raise ValueError("choose a total threshold OR a zone for alcohol expansion")
|
||||
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
|
||||
raise ValueError("PHQ-9 thresholds must be ordered")
|
||||
if self.pcl5_specialist_at >= self.pcl5_priority_at:
|
||||
@@ -94,7 +97,7 @@ def score_alcohol(answers: dict[str, int], *, short: bool, sex: Sex | None) -> A
|
||||
for item in ("rus_audit_2", "rus_audit_3"):
|
||||
if values.get(item, 0) != 0:
|
||||
raise ValueError("never drinks conflicts with positive consumption answers")
|
||||
if item not in values:
|
||||
if item == "rus_audit_2" and item not in values:
|
||||
values[item] = 0
|
||||
derived.append(item)
|
||||
result = score(scale_id, values, sex=sex)
|
||||
@@ -121,6 +124,8 @@ def alcohol_expansion(result: AlcoholResult, policy: IntakePolicy) -> bool | Non
|
||||
raise ValueError("expansion requires RUS-AUDIT-S")
|
||||
if not policy.approval or not result.complete or result.zone is None:
|
||||
return None
|
||||
if policy.rus_audit_s_expand_at is not None:
|
||||
return result.total >= policy.rus_audit_s_expand_at
|
||||
assert policy.rus_audit_s_expand_from_zone is not None
|
||||
return result.zone >= policy.rus_audit_s_expand_from_zone
|
||||
|
||||
@@ -203,8 +208,8 @@ def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics,
|
||||
zone = full.zone if full.complete else short.zone
|
||||
if zone is not None and zone >= 2:
|
||||
flags.append("alcohol_zone_ii_or_higher")
|
||||
if psychotropics:
|
||||
flags.append("alcohol_psychotropics")
|
||||
if psychotropics and (any(v > 0 for v in alcohol.values())):
|
||||
flags.append("alcohol_psychotropics")
|
||||
if full.zone == 4:
|
||||
flags.append("alcohol_zone_iv")
|
||||
except ValueError:
|
||||
@@ -217,7 +222,7 @@ def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics,
|
||||
def evaluate_intake(
|
||||
*,
|
||||
phq9: dict[str, int],
|
||||
pcptsd5: dict[str, int],
|
||||
tsq: dict[str, int],
|
||||
pcl5: dict[str, int],
|
||||
alcohol: dict[str, int],
|
||||
cssrs: dict[str, int],
|
||||
@@ -269,12 +274,10 @@ def evaluate_intake(
|
||||
return IntakeDecision(policy.version, "clinical_review_required", None, (), tuple(flags), c.missing)
|
||||
|
||||
phq = score("phq9", phq9)
|
||||
pc = score("pcptsd5", pcptsd5)
|
||||
pc = score("tsq", tsq)
|
||||
pcl = score("pcl5", pcl5)
|
||||
full_alcohol = score_alcohol(alcohol, short=False, sex=sex)
|
||||
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
|
||||
if "rus_audit_3" in full_alcohol.derived:
|
||||
short_values["rus_audit_3"] = 0
|
||||
short_alcohol = score_alcohol(short_values, short=True, sex=sex)
|
||||
missing = list(c.missing)
|
||||
phq2_items = ("phq9_1", "phq9_2")
|
||||
@@ -285,11 +288,11 @@ def evaluate_intake(
|
||||
missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
|
||||
if trauma_exposure is None:
|
||||
missing.append("trauma_exposure")
|
||||
if trauma_exposure is False and any(pcptsd5.values()):
|
||||
if trauma_exposure is False and tsq:
|
||||
flags.append("trauma_filter_conflict")
|
||||
pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.pcptsd5_expand_at
|
||||
pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.tsq_expand_at
|
||||
if trauma_exposure is True and not pc.complete:
|
||||
missing.extend(k for k in load_scale("pcptsd5").item_ids if k not in pcptsd5)
|
||||
missing.extend(k for k in load_scale("tsq").item_ids if k not in tsq)
|
||||
if pc_positive and not pcl.complete:
|
||||
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5)
|
||||
# Partially administered optional scales also cannot be treated as completed negatives.
|
||||
|
||||
Reference in New Issue
Block a user