fix(clinical): red flags, one crisis rule, callback after a crisis, one table of numbers
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>
This commit is contained in:
@@ -13,7 +13,7 @@ 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.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"]
|
||||
@@ -92,6 +92,9 @@ def catalog() -> Catalog:
|
||||
),
|
||||
)
|
||||
|
||||
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])
|
||||
|
||||
|
||||
@@ -53,11 +53,25 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
|
||||
return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")}
|
||||
|
||||
|
||||
def red_flags() -> dict:
|
||||
return yaml.safe_load((get_settings().config_dir / "instruments/red_flags.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def red_flag_category(item_id: str) -> str | None:
|
||||
return next((i["category"] for i in red_flags()["items"] if i["id"] == item_id), None)
|
||||
|
||||
|
||||
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
|
||||
"""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."""
|
||||
"""Stage 1: the red-flag block first (config/instruments/red_flags.yaml), then the fixed order of «Опросники
|
||||
для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, 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."""
|
||||
flags = red_flags()
|
||||
for n, item in enumerate(flags["items"]):
|
||||
if item["id"] not in answers:
|
||||
text = (flags["intro"] + "\n\n" if n == 0 else "") + item["text"]
|
||||
return Question(item["id"], text, tuple((o["value"], o["label"]) for o in flags["options"]), "now")
|
||||
for item in ("phq9_1", "phq9_2"):
|
||||
if item not in answers:
|
||||
return scale_question("phq9", item)
|
||||
|
||||
@@ -12,6 +12,7 @@ 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"}
|
||||
@@ -23,7 +24,7 @@ PCL5_IMPROVED_BELOW = 28
|
||||
|
||||
|
||||
def texts() -> dict[str, Any]:
|
||||
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))
|
||||
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:
|
||||
|
||||
@@ -50,7 +50,6 @@ class IntakePolicy(BaseModel):
|
||||
phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
|
||||
phq9_priority_at: int = Field(ge=0, le=27, strict=True)
|
||||
pcl5_specialist_at: int = Field(ge=0, le=80, strict=True)
|
||||
pcl5_priority_at: int = Field(ge=0, le=80, strict=True)
|
||||
pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -65,8 +64,6 @@ class IntakePolicy(BaseModel):
|
||||
raise ValueError("choose a total threshold OR a zone for alcohol expansion")
|
||||
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
|
||||
raise ValueError("PHQ-9 thresholds must be ordered")
|
||||
if self.pcl5_specialist_at >= self.pcl5_priority_at:
|
||||
raise ValueError("PCL-5 thresholds must be ordered")
|
||||
return self
|
||||
|
||||
|
||||
@@ -276,7 +273,9 @@ def evaluate_intake(
|
||||
if c.positive:
|
||||
flags.append("cssrs_positive")
|
||||
flags.extend(c.conflicts)
|
||||
if item9_positive or c.risk_lower_bound == "high":
|
||||
# One crisis rule (ADR-013): a high C-SSRS risk. Item 9 alone is a crisis only while the screener is not complete;
|
||||
# with a negative / low screener it is the «discrepancy» flag of the routing table, not the crisis screen.
|
||||
if c.risk_lower_bound == "high" or (item9_positive and not c.complete):
|
||||
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
|
||||
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
|
||||
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
|
||||
@@ -327,9 +326,11 @@ def evaluate_intake(
|
||||
# PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
|
||||
phq_total = phq.total if phq.complete else 0
|
||||
pcl_total = pcl.total if pcl.complete else 0
|
||||
# Risk and flags first, scales second (clinical review 2026-10-04). A high PCL-5 alone is not a reason for a
|
||||
# priority appointment: it goes to the planned specialist route.
|
||||
if c.risk == "moderate":
|
||||
primary = "urgent_psychiatrist"
|
||||
elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at:
|
||||
elif phq_total >= policy.phq9_priority_at:
|
||||
primary = "priority_psychiatrist"
|
||||
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
|
||||
primary = "psychiatrist_or_psychotherapist"
|
||||
|
||||
@@ -395,6 +395,25 @@ def _crisis_cfg(path: Path = CRISIS_PATH) -> _CrisisCfg:
|
||||
return _CrisisCfg.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
_PHONE_REF = re.compile(r"\{phone:([a-z_]+)\}")
|
||||
|
||||
|
||||
def fill_phones(text: str, path: Path = CRISIS_PATH) -> str:
|
||||
"""Replace `{phone:<resource>}` with the number from the single table in config/texts/crisis.yaml.
|
||||
|
||||
Every patient text and prompt refers to the table instead of repeating a number, so a number is changed in one
|
||||
place. An unknown resource fails loudly: a wrong number on the crisis screen must never pass silently.
|
||||
"""
|
||||
resources = _crisis_cfg(path).resources
|
||||
|
||||
def number(m: re.Match[str]) -> str:
|
||||
if m.group(1) not in resources:
|
||||
raise ValueError(f"unknown crisis resource {m.group(1)!r}")
|
||||
return resources[m.group(1)].phone
|
||||
|
||||
return _PHONE_REF.sub(number, text)
|
||||
|
||||
|
||||
def _render(body: str, resources: list[CrisisResource], doctor_notified: str, grounding: str) -> str:
|
||||
lines = [body.strip(), ""]
|
||||
for r in resources:
|
||||
|
||||
Reference in New Issue
Block a user