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:
@@ -14,6 +14,7 @@ from app.core.intake_questions import (
|
||||
diary_questions,
|
||||
group_answers,
|
||||
next_intake,
|
||||
red_flag_category,
|
||||
scale_question,
|
||||
texts,
|
||||
)
|
||||
@@ -27,6 +28,7 @@ from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.crisis import crisis_open, open_crisis
|
||||
from app.dialog.fsm import Step, on_enter, state, trigger
|
||||
from app.services import questionnaire as service
|
||||
from app.services.followup import schedule_post_crisis_call
|
||||
|
||||
|
||||
def allowed(ctx):
|
||||
@@ -105,6 +107,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
|
||||
level=AlertLevel.ACUTE,
|
||||
params={"record_id": row.id if row else None, "flags": flags},
|
||||
)
|
||||
await schedule_post_crisis_call(ctx.session, ctx.token, ctx.now, source="questionnaire:" + code)
|
||||
step = open_crisis(ctx, categories or [], source="questionnaire:" + code)
|
||||
step.data["questionnaire_record"] = None
|
||||
step.data["intake_review_required"] = True
|
||||
@@ -113,6 +116,41 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
|
||||
return step
|
||||
|
||||
|
||||
async def item9_discrepancy(ctx, row):
|
||||
profile = await ctx.profile()
|
||||
if profile:
|
||||
profile.enhanced_monitoring = True
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
ctx.token,
|
||||
row.id + ":phq9_cssrs_discrepancy",
|
||||
"phq9_cssrs_discrepancy",
|
||||
ctx.now,
|
||||
level=AlertLevel.HIGH,
|
||||
params={"record_id": row.id},
|
||||
)
|
||||
row.details = {**row.details, "item9_flagged": True}
|
||||
|
||||
|
||||
async def item9_assessment(ctx, row):
|
||||
"""Thoughts of death in PHQ-9 item 9 → the C-SSRS screener at once; the screener's level decides (graded)."""
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
ctx.token,
|
||||
row.id + ":item9_assessment",
|
||||
"phq9_item9_cssrs",
|
||||
ctx.now,
|
||||
level=AlertLevel.HIGH,
|
||||
params={"record_id": row.id},
|
||||
)
|
||||
await service.finish(ctx, row, "complete", extra={"stop_reason": "phq9_item9_cssrs"})
|
||||
profile = await ctx.profile()
|
||||
if profile:
|
||||
profile.enhanced_monitoring = True
|
||||
task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", row.id + ":cssrs", ctx.now)
|
||||
return await start(ctx, "cssrs_since_contact", task)
|
||||
|
||||
|
||||
async def manual_review(ctx, row, code):
|
||||
await service.finish(ctx, row, "review_required", extra={"stop_reason": code})
|
||||
await service.alert(
|
||||
@@ -135,9 +173,24 @@ async def advance(ctx, row):
|
||||
a = row.answers
|
||||
c = score_cssrs_recent(cssrs_answers(a))
|
||||
policy = load_policy()
|
||||
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
|
||||
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high":
|
||||
# Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category.
|
||||
for item_id, value in a.items():
|
||||
if item_id.startswith("rf_") and value == 1:
|
||||
category = red_flag_category(item_id)
|
||||
return await urgent(ctx, row, code="red_flag_" + item_id[3:], categories=[category] if category else [])
|
||||
# One crisis rule for the whole app (ADR-013): the crisis screen opens at a HIGH C-SSRS risk only. Moderate →
|
||||
# stop + psychiatrist (about 24 h); low → flag, safety plan, continue.
|
||||
if c.risk_lower_bound == "high":
|
||||
return await urgent(ctx, row, code="suicide_signal")
|
||||
if a.get("dphq_9", 0) > 0 and row.kind in DIARY_KINDS:
|
||||
# Diary depression module, item 9 ≥1 → the C-SSRS right away («Этап 2»); its risk level decides.
|
||||
return await item9_assessment(ctx, row)
|
||||
if a.get("phq9_9", 0) > 0 and not c.complete:
|
||||
# Item 9 without a completed screener (only in a scheduled PHQ-9): assess the risk right away.
|
||||
return await item9_assessment(ctx, row)
|
||||
if a.get("phq9_9", 0) > 0 and not row.details.get("item9_flagged"):
|
||||
# Item 9 ≥1 with a negative / low C-SSRS = discrepancy (routing table): flag to the doctor, safety plan.
|
||||
await item9_discrepancy(ctx, row)
|
||||
if c.positive and policy.cssrs_positive_policy == "stop_any":
|
||||
return await urgent(ctx, row, code="cssrs_positive")
|
||||
if c.conflicts:
|
||||
@@ -165,7 +218,7 @@ async def advance(ctx, row):
|
||||
return await manual_review(ctx, row, "answer_conflict")
|
||||
if q:
|
||||
replies = [question_reply(row, q)]
|
||||
if c.complete and c.risk == "low" and not row.details.get("plan_shown"):
|
||||
if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"):
|
||||
from app.dialog.actions import safety_plan_reply
|
||||
|
||||
replies.insert(0, await safety_plan_reply(ctx))
|
||||
@@ -225,11 +278,12 @@ async def advance(ctx, row):
|
||||
if row.kind in ("phq9", "pcl5"):
|
||||
sid = row.kind
|
||||
total = extra["totals"][sid]["total"]
|
||||
priority = getattr(policy, sid + "_priority_at")
|
||||
# Only PHQ-9 has a priority threshold; PCL-5 alone never makes an appointment a priority.
|
||||
priority = getattr(policy, sid + "_priority_at", None)
|
||||
specialist = getattr(policy, sid + "_specialist_at")
|
||||
extra["route"] = (
|
||||
"priority_psychiatrist"
|
||||
if total >= priority
|
||||
if priority is not None and total >= priority
|
||||
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
|
||||
)
|
||||
earlier = [
|
||||
@@ -284,7 +338,13 @@ async def advance(ctx, row):
|
||||
return step
|
||||
from app.dialog.flows.core import menu_reply
|
||||
|
||||
return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None})
|
||||
replies = [menu_reply(ctx, shown)]
|
||||
if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"):
|
||||
# The safety plan is never skipped because the risk signal came with the last question.
|
||||
from app.dialog.actions import safety_plan_reply
|
||||
|
||||
replies.insert(0, await safety_plan_reply(ctx))
|
||||
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
|
||||
|
||||
|
||||
# Diary flags that bring a full scale forward («Этап 2», p.4).
|
||||
|
||||
Reference in New Issue
Block a user