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:
2026-10-04 16:21:16 +03:00
co-authored by Claude Opus 5.5
parent bde6ec6855
commit 44814b7293
29 changed files with 365 additions and 56 deletions
+68 -11
View File
@@ -13,6 +13,7 @@ from app.core import clock
from app.core.scales import clear_cache
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Alert
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Event
from app.dialog.engine import Engine
@@ -76,7 +77,8 @@ def answer_button(replies, choices=None):
for b in line:
if b.data.startswith("iq:"):
item = b.data.split(":")[2]
index = choices.get(item, 1 if item.startswith("cssrs_") else 0)
# «Нет» by default for the safety items (C-SSRS, red flags), the first option otherwise.
index = choices.get(item, 1 if item.startswith(("cssrs_", "rf_")) else 0)
for line2 in reply.buttons:
for other in line2:
if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"):
@@ -122,17 +124,17 @@ async def test_pause_restart_and_duplicate_callbacks(released):
old = answer_button(replies)
await asyncio.gather(press(released, old), press(released, old))
(row,) = await rows()
assert row.answers == {"phq9_1": 0}
assert row.answers == {"rf_psychosis": 0}
await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume")
assert "phq9_2" in answer_button(replies)
assert "rf_harm_others" in answer_button(replies)
await press(restarted, old)
assert (await rows())[0].answers == {"phq9_1": 0}
assert (await rows())[0].answers == {"rf_psychosis": 0}
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
@pytest.mark.parametrize("signal", ["help", "phq", "cssrs"])
@pytest.mark.parametrize("signal", ["help", "red_flag", "cssrs"])
async def test_immediate_stop_preserves_partial_answers(released, signal):
replies = await press(released, "intake:start")
if signal == "help":
@@ -142,13 +144,45 @@ async def test_immediate_stop_preserves_partial_answers(released, signal):
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0}))
else:
replies = await complete_existing(released, replies, {"trauma": 1, "phq9_1": 2, "phq9_9": 1})
replies = await press(released, answer_button(replies, {"rf_psychosis": 0}))
assert any(r.kind == "crisis" for r in replies)
(row,) = await rows()
assert row.status == "interrupted" and row.completed_at is None
notices = await rows(QuestionnaireNotice)
assert len(notices) == 1 and notices[0].status == "test_sink"
assert "уведомлён" not in replies[0].text
# Every crisis leaves a callback task for the doctor on duty (one per patient while it is open).
calls = [a for a in await rows(Alert) if a.code == "post_crisis_call"]
assert len(calls) == 1 and calls[0].params["assignee"] == "on-duty"
async def test_red_flag_violence_uses_its_crisis_variant(released):
replies = await press(released, "intake:start")
for _ in range(3): # psychosis, harm to others, weapons: «Нет»
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies, {"rf_violence": 0}))
crisis = next(r for r in replies if r.kind == "crisis")
assert "насили" in crisis.text.lower()
(row,) = await rows()
assert row.details["stop_reason"] == "red_flag_violence"
async def test_item9_with_a_negative_screener_is_a_flag_not_a_crisis(released):
# One crisis rule (ADR-013): C-SSRS was negative, so PHQ-9 item 9 is a discrepancy for the doctor.
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
choices.update(trauma=1, tbi=1, psychotropics=1, phq9_9=1)
replies = await press(released, "intake:start")
for _ in range(60):
button = answer_button(replies, choices)
if button is None or any(r.kind == "safety_plan" for r in replies):
break
replies = await press(released, button)
assert not any(r.kind == "crisis" for r in replies)
assert any(r.kind == "safety_plan" for r in replies)
codes = {a.code for a in await rows(Alert)}
assert "phq9_cssrs_discrepancy" in codes and "post_crisis_call" not in codes
(row,) = await rows()
assert row.status == "complete" and "phq9_cssrs_discrepancy" in row.details["decision"]["flags"]
async def complete_existing(e, replies, choices):
@@ -350,16 +384,24 @@ async def test_diary_morning_then_evening_with_the_ptsd_module(released):
assert any("уже заполнен" in r.text for r in replies)
async def test_depression_module_item9_stops_with_crisis(released, monkeypatch):
async def test_depression_module_item9_asks_the_screener_at_once(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
await run_diary(released, await press(released, "menu:checkin"))
replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1})
assert any(r.kind == "crisis" for r in replies)
replies = await press(released, "menu:checkin")
for _ in range(10):
button = answer_button(replies, {"medication": 2, "dphq_9": 1})
replies = await press(released, button)
if ":cssrs_" in (answer_button(replies) or ""):
break
# Item 9 → the C-SSRS right away («Этап 2»): no crisis screen before the screener says the risk is high.
assert not any(r.kind == "crisis" for r in replies) and any("последнего ответа" in r.text for r in replies)
evening = next(r for r in await rows() if r.kind == "diary_evening")
assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted"
assert evening.answers["dphq_9"] == 1 and evening.details["stop_reason"] == "phq9_item9_cssrs"
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0, "cssrs_q4": 0})
assert any(r.kind == "crisis" for r in replies)
async def test_unknown_sex_goes_to_review_without_a_zone(released):
@@ -380,7 +422,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released):
path.write_text(yaml.safe_dump(data, allow_unicode=True))
await press(released, "intake:resume")
(row,) = await rows()
assert row.status == "review_required" and row.answers == {"phq9_1": 0}
assert row.status == "review_required" and row.answers == {"rf_psychosis": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released):
@@ -434,6 +476,8 @@ async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:start")
for _ in range(5): # the red-flag block
replies = await press(released, answer_button(replies))
await press(released, answer_button(replies))
await press(released, "intake:pause")
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1))
@@ -472,3 +516,16 @@ async def test_without_a_doctor_the_screening_defines_the_modules(released):
assert any("не выявлено" in r.text for r in replies)
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).modules == []
async def test_next_day_after_a_crisis_the_app_asks_once(released, monkeypatch):
replies = await press(released, "intake:start")
await press(released, answer_button(replies, {"rf_psychosis": 0})) # red flag → crisis
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(hours=21))
await jobs.plan()
await jobs.plan()
sent = outbox.drain(TOKEN)
assert len(sent) == 1 and "Как вы сегодня" in sent[0].text
assert any(b.data == "safety_plan" for line in sent[0].buttons for b in line)