Clinical review of the questionnaires (ADR-013):
- Red-flag block first in stage 1 (config/instruments/red_flags.yaml); any «Да» stops the
questionnaire with an acute alert and the help screen of its category.
- One crisis rule: the crisis screen opens at a HIGH C-SSRS risk only; moderate stops and goes
to a psychiatrist (24 h as a guide), low is a flag with the safety plan. PHQ-9 item 9 with a
negative screener is a discrepancy flag; without a completed screener it asks the C-SSRS at once.
config/questionnaire.yaml: cssrs_positive_policy graded (C01).
- Every crisis leaves a callback task for the doctor on duty (post_crisis_call, one per patient)
and the app asks how the person is the next day, the safety plan one tap away.
- Routing: risk and flags first, scales second; PCL-5 >= 50 no longer means a priority
appointment (pcl5_severe is moderate on the dashboard too).
- Crisis numbers live only in config/texts/crisis.yaml; texts and the agent prompt refer to them
via {phone:<key>}. Fixes the children's helpline offered to adults by the chat agent.
Refs #92
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
5.2 KiB
Python
143 lines
5.2 KiB
Python
"""Read-only catalog of questionnaire items for patient clients (web app).
|
|
|
|
The dialog engine sends a questionnaire question as plain text plus buttons; the button payload carries the item id
|
|
(`iq:<nonce>:<item>:<i>`, `scale:<scale>:<item>:<v>`). A client looks the item up here to show which form it belongs
|
|
to, its position and recall period. Nothing here changes what is asked or how it is scored — the texts stay those of
|
|
the versioned sources in config/.
|
|
"""
|
|
|
|
from functools import lru_cache
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from app.config import get_settings
|
|
from app.core.cssrs_content import load_cssrs_russian
|
|
from app.core.intake_questions import DIARY_PARTS, content, diary_config, red_flags, texts
|
|
from app.core.scales import load_scale
|
|
|
|
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
|
|
|
|
# Forms whose item order depends on earlier answers: a client shows the position without «из N».
|
|
ADAPTIVE = {"cssrs", "rus_audit", "diary", "context"}
|
|
|
|
|
|
class FormInfo(BaseModel):
|
|
id: str
|
|
title: str
|
|
section: Section
|
|
recall_period: str | None = None
|
|
instruction: str | None = None
|
|
|
|
|
|
class ItemInfo(BaseModel):
|
|
form: str
|
|
index: int # 1-based position in the form
|
|
total: int
|
|
adaptive: bool
|
|
optional: bool = False
|
|
numeric: bool = False
|
|
|
|
|
|
class DrinksTable(BaseModel):
|
|
"""RUS-AUDIT item 2 standard-drink table, exactly as in config/texts/intake.yaml (the approved form)."""
|
|
|
|
title: str
|
|
header: list[str]
|
|
rows: list[tuple[str, list[str]]]
|
|
note: str
|
|
|
|
|
|
class Catalog(BaseModel):
|
|
forms: dict[str, FormInfo]
|
|
items: dict[str, ItemInfo]
|
|
standard_drinks: DrinksTable | None
|
|
|
|
|
|
def _drinks() -> DrinksTable | None:
|
|
raw = texts().get("standard_drinks")
|
|
if not raw:
|
|
return None
|
|
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
|
title, header_line, *rest = lines
|
|
rows = [line for line in rest if ":" in line]
|
|
note = " ".join(line for line in rest if ":" not in line)
|
|
return DrinksTable(
|
|
title=title,
|
|
header=[h.strip() for h in header_line.split("·")],
|
|
rows=[
|
|
(label.strip(), [c.strip() for c in cells.split("·")]) for label, cells in (r.split(":", 1) for r in rows)
|
|
],
|
|
note=note,
|
|
)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def catalog() -> Catalog:
|
|
forms: dict[str, FormInfo] = {}
|
|
items: dict[str, ItemInfo] = {}
|
|
|
|
def add(form: FormInfo, ids: list[str], *, optional: set[str] = frozenset(), numeric: set[str] = frozenset()):
|
|
forms[form.id] = form
|
|
for i, item in enumerate(ids, 1):
|
|
items.setdefault(
|
|
item,
|
|
ItemInfo(
|
|
form=form.id,
|
|
index=i,
|
|
total=len(ids),
|
|
adaptive=form.id in ADAPTIVE,
|
|
optional=item in optional,
|
|
numeric=item in numeric,
|
|
),
|
|
)
|
|
|
|
flags = red_flags()
|
|
add(FormInfo(id="red_flags", title="Вопросы о безопасности", section="safety"), [i["id"] for i in flags["items"]])
|
|
|
|
cssrs = load_cssrs_russian()
|
|
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items])
|
|
|
|
intake_forms: dict[str, Any] = content()["forms"]
|
|
for sid, section in (("phq9", "mood"), ("tsq", "trauma"), ("pcl5", "trauma"), ("rus_audit", "alcohol")):
|
|
raw = intake_forms.get(sid) or {}
|
|
scale = load_scale(sid)
|
|
add(
|
|
FormInfo(
|
|
id=sid,
|
|
title=raw.get("title") or scale.title,
|
|
section=section,
|
|
recall_period=raw.get("recall_period") or scale.recall_period,
|
|
instruction=raw.get("instruction") or scale.instruction,
|
|
),
|
|
list(scale.item_ids),
|
|
)
|
|
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
|
|
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
|
|
|
|
diary = diary_config()
|
|
core = [item for part in DIARY_PARTS for item in diary[part]]
|
|
depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]]
|
|
ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]]
|
|
add(
|
|
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
|
|
[i["id"] for i in core] + depression + ptsd,
|
|
optional={i["id"] for i in core if i.get("optional")},
|
|
numeric={i["id"] for i in core if i.get("numeric")},
|
|
)
|
|
|
|
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
|
|
for path in sorted((get_settings().config_dir / "scales").glob("*.yaml")):
|
|
scale = load_scale(path.stem)
|
|
forms.setdefault(
|
|
scale.id,
|
|
FormInfo(
|
|
id=scale.id,
|
|
title=scale.title,
|
|
section="scale",
|
|
recall_period=scale.recall_period,
|
|
instruction=scale.instruction,
|
|
),
|
|
)
|
|
return Catalog(forms=forms, items=items, standard_drinks=_drinks())
|