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)
|
||||
Reference in New Issue
Block a user