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>
This commit is contained in:
@@ -18,6 +18,7 @@ class Question:
|
||||
period: str = "context"
|
||||
numeric: bool = False
|
||||
optional: bool = False
|
||||
maximum: float = 24
|
||||
|
||||
|
||||
def content() -> dict:
|
||||
@@ -53,18 +54,10 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
"""Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
|
||||
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
|
||||
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
|
||||
so a positive answer interrupts the questionnaire wherever it comes."""
|
||||
for item in ("phq9_1", "phq9_2"):
|
||||
if item not in answers:
|
||||
return scale_question("phq9", item)
|
||||
@@ -81,6 +74,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
for item in load_scale("rus_audit_s").item_ids:
|
||||
if item not in answers:
|
||||
return scale_question("rus_audit_s", item)
|
||||
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,
|
||||
)
|
||||
if sex is None and "sex" not in answers:
|
||||
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
|
||||
for key in ("tbi", "psychotropics"):
|
||||
@@ -108,34 +113,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
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),
|
||||
def cssrs_since_contact_question(answers: dict) -> Question | None:
|
||||
"""Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact».
|
||||
|
||||
The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the
|
||||
items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact
|
||||
is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies.
|
||||
"""
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
missing = [k for k in c.missing if k != "q6_recent"]
|
||||
if not missing:
|
||||
return None
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == missing[0])
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
texts()["periods"]["since_last_contact"] + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
"since_last_contact",
|
||||
)
|
||||
|
||||
|
||||
DIARY_PARTS = ("morning", "evening")
|
||||
DIARY_KINDS = ("diary_morning", "diary_evening")
|
||||
|
||||
|
||||
def diary_config() -> dict:
|
||||
return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]:
|
||||
"""Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile.
|
||||
|
||||
PTSD («veteran») and depression run side by side when both are present. The depression module asks three
|
||||
PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`)
|
||||
so diary answers never mix with the full scales.
|
||||
"""
|
||||
if part not in DIARY_PARTS:
|
||||
raise ValueError("unknown diary part")
|
||||
config = diary_config()
|
||||
questions = [
|
||||
Question(
|
||||
item["id"],
|
||||
item["text"],
|
||||
tuple((o["value"], o["label"]) for o in item.get("options", [])),
|
||||
"past_day",
|
||||
item.get("numeric", False),
|
||||
item.get("optional", False),
|
||||
item.get("max", 24),
|
||||
)
|
||||
for item in config[part]
|
||||
]
|
||||
if part == "evening":
|
||||
forms = content()["forms"]
|
||||
if "depression" in modules:
|
||||
module = config["depression"]
|
||||
items = forms[module["form"]]["items"]
|
||||
per_day = module["items_per_day"]
|
||||
start = (day_index % (len(items) // per_day)) * per_day
|
||||
options = tuple((o["value"], o["label"]) for o in module["options"])
|
||||
for item in items[start : start + per_day]:
|
||||
number = item["id"].rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today")
|
||||
)
|
||||
if "veteran" in modules:
|
||||
module = config["ptsd"]
|
||||
form = forms[module["form"]]
|
||||
by_id = {i["id"]: i["text"] for i in form["items"]}
|
||||
options = tuple((o["value"], o["label"]) for o in form["options"])
|
||||
for item_id in module["items"]:
|
||||
number = item_id.rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today")
|
||||
)
|
||||
)
|
||||
questions.sort(key=lambda q: q.id != "death_thoughts")
|
||||
return tuple(questions)
|
||||
|
||||
Reference in New Issue
Block a user