Files
PTSRaiAgent/app/core/instrument_catalog.py
T
Arxip222andClaude Opus 5.5 290b0d6151 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>
2026-10-04 15:14:09 +03:00

140 lines
5.0 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, 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,
),
)
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())