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>
167 lines
6.6 KiB
Python
167 lines
6.6 KiB
Python
"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml.
|
|
|
|
Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2.
|
|
Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags.
|
|
Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3).
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from app.config import get_settings
|
|
from app.core.intake_questions import content
|
|
from app.core.questionnaire import IntakeDecision, IntakePolicy
|
|
from app.core.safety import fill_phones
|
|
|
|
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
|
|
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
|
|
# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022).
|
|
PHQ9_REMISSION_BELOW = 5
|
|
PHQ9_IMPROVED_BY = 5
|
|
PCL5_RESPONSE_BY = 10
|
|
PCL5_IMPROVED_BELOW = 28
|
|
|
|
|
|
def texts() -> dict[str, Any]:
|
|
return yaml.safe_load(fill_phones((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8")))
|
|
|
|
|
|
def render(interpretation: dict[str, Any]) -> str:
|
|
"""Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any)."""
|
|
parts = [interpretation["title"], *interpretation.get("lines", [])]
|
|
if interpretation.get("disclaimer"):
|
|
parts.append(interpretation["disclaimer"])
|
|
return "\n\n".join(p for p in parts if p)
|
|
|
|
|
|
def _alcohol_zone(totals: dict) -> int | None:
|
|
full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {}
|
|
return full.get("zone") if full.get("complete") else short.get("zone")
|
|
|
|
|
|
def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]:
|
|
"""Screening findings of stage 1, in the order they are shown."""
|
|
found = []
|
|
phq = totals.get("phq9") or {}
|
|
if phq.get("complete"):
|
|
if phq["total"] >= policy.phq9_specialist_at:
|
|
found.append("depression_suspected")
|
|
elif phq["total"] >= policy.phq9_psychologist_at:
|
|
found.append("depression_mild")
|
|
pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {}
|
|
if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at:
|
|
found.append("ptsd_suspected")
|
|
elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at:
|
|
found.append("ptsd_signs")
|
|
zone = _alcohol_zone(totals)
|
|
if zone == 4:
|
|
found.append("alcohol_high")
|
|
elif zone in (2, 3):
|
|
found.append("alcohol_risky")
|
|
if (totals.get("cssrs_recent") or {}).get("risk") == "low":
|
|
found.append("suicide_low")
|
|
if answers.get("tbi") == 1:
|
|
found.append("tbi")
|
|
return found
|
|
|
|
|
|
def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict:
|
|
t = texts()
|
|
found = intake_findings(totals, answers, policy)
|
|
modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE]
|
|
head = t["intake"]["attention" if found else "ok"]
|
|
lines = [head["text"]]
|
|
lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found]
|
|
if decision.primary_route in t["routes"]:
|
|
lines.append(t["routes"][decision.primary_route])
|
|
names = ", ".join(t["modules"]["names"][m] for m in modules)
|
|
lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else ""))
|
|
return {
|
|
"kind": "intake",
|
|
"title": head["title"],
|
|
"lines": lines,
|
|
"codes": found or ["ok"],
|
|
"findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found],
|
|
"route": decision.primary_route,
|
|
"modules": modules,
|
|
"disclaimer": t["disclaimer"],
|
|
}
|
|
|
|
|
|
def _phq9_level(total: int) -> str:
|
|
band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"])
|
|
return band["code"]
|
|
|
|
|
|
def scale_interpretation(
|
|
kind: str,
|
|
totals: dict,
|
|
*,
|
|
baseline: int | None,
|
|
previous: int | None,
|
|
policy: IntakePolicy,
|
|
retriage_delta: int,
|
|
) -> dict:
|
|
"""A scheduled full scale: level, then the change against the start of observation and the last measurement."""
|
|
t = texts()["scales"]
|
|
if kind == "rus_audit_s":
|
|
zone = _alcohol_zone(totals)
|
|
result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"]
|
|
level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"]
|
|
line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level)
|
|
return {
|
|
"kind": kind,
|
|
"title": t[kind]["title"],
|
|
"lines": [line],
|
|
"codes": [f"zone_{zone}" if zone else "unknown"],
|
|
}
|
|
total = totals[kind]["total"]
|
|
pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below"
|
|
code = _phq9_level(total) if kind == "phq9" else pcl5_level
|
|
lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])]
|
|
change = None
|
|
if previous is not None and total - previous >= retriage_delta:
|
|
change = "worse"
|
|
elif baseline is not None:
|
|
if kind == "phq9":
|
|
if total < PHQ9_REMISSION_BELOW:
|
|
change = "remission"
|
|
elif baseline > 0 and total <= baseline / 2:
|
|
change = "response"
|
|
elif baseline - total >= PHQ9_IMPROVED_BY:
|
|
change = "improved"
|
|
else:
|
|
change = "stable"
|
|
else:
|
|
if baseline - total >= PCL5_RESPONSE_BY:
|
|
change = "response"
|
|
elif total < PCL5_IMPROVED_BELOW <= baseline:
|
|
change = "improved"
|
|
else:
|
|
change = "stable"
|
|
if change:
|
|
lines.append(t["change"][change])
|
|
return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]}
|
|
|
|
|
|
def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict:
|
|
t = texts()["diary"]
|
|
shown = [f for f in flags if f in t["flags"]]
|
|
return {
|
|
"kind": f"diary_{part}",
|
|
"title": t["saved"].format(part=t["parts"][part]),
|
|
"lines": [t["flags"][f] for f in shown],
|
|
"codes": list(flags),
|
|
}
|
|
|
|
|
|
def cssrs_interpretation(risk: str | None) -> dict:
|
|
t = texts()["cssrs"]
|
|
return {
|
|
"kind": "cssrs_since_contact",
|
|
"title": t["low" if risk == "low" else "none"],
|
|
"lines": [],
|
|
"codes": [risk or "none"],
|
|
}
|