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>
This commit is contained in:
2026-10-04 15:14:09 +03:00
co-authored by Claude Opus 5.5
parent 6e7c89900b
commit 290b0d6151
36 changed files with 1900 additions and 691 deletions
+125 -58
View File
@@ -94,6 +94,17 @@ async def complete_intake(e, choices=None):
raise AssertionError("intake did not terminate")
async def to_cssrs(e, choices=None):
"""Stage 1 up to the C-SSRS screener, which comes after PHQ-2, the trauma filter and RUS-AUDIT-S."""
replies = await press(e, "intake:start")
for _ in range(20):
button = answer_button(replies, {"trauma": 1, **(choices or {})})
if ":cssrs_" in button:
return replies
replies = await press(e, button)
raise AssertionError("C-SSRS not reached")
async def test_minimal_intake_raw_data_not_imputed_and_restart(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
(row,) = await rows()
@@ -111,13 +122,13 @@ 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 == {"cssrs_q1": 0}
assert row.answers == {"phq9_1": 0}
await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume")
assert "cssrs_q2" in answer_button(replies)
assert "phq9_2" in answer_button(replies)
await press(restarted, old)
assert (await rows())[0].answers == {"cssrs_q1": 0}
assert (await rows())[0].answers == {"phq9_1": 0}
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
@@ -127,6 +138,7 @@ async def test_immediate_stop_preserves_partial_answers(released, signal):
if signal == "help":
replies = await press(released, "intake:help")
elif signal == "cssrs":
replies = await to_cssrs(released)
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0}))
else:
@@ -169,12 +181,13 @@ async def test_alcohol_conflict_keeps_positive_short_answer(released):
async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
replies = await press(released, "intake:start")
replies = await to_cssrs(released)
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies))
assert any(r.kind == "safety_plan" for r in replies)
assert "phq9_1" in answer_button(replies)
# The screener ends stage 1; the context questions follow (sex is known from the profile).
assert ":tbi:" in answer_button(replies)
async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch):
@@ -210,9 +223,10 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
monkeypatch.setattr(integrations, "outbox", Failing())
await jobs.run_until_now()
tasks = await rows(QuestionnaireTask)
assert len(tasks) == 2
risk = next(t for t in tasks if t.kind == "cssrs_since_contact")
assert risk.delivery and risk.sent_at is None
# 20:00 Moscow: both parts of the diary are due; no risk monitoring → no daily C-SSRS.
assert {t.kind for t in tasks} == {"diary_morning", "diary_evening"}
morning = next(t for t in tasks if t.kind == "diary_morning")
assert morning.delivery and morning.sent_at is None
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
await jobs.run_until_now()
@@ -222,17 +236,29 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
assert len(await rows()) == 2
async def test_missing_since_contact_is_manual_review_not_recent(released):
async def test_since_contact_risk_check_opens_the_evening_diary(released):
async with session.get_clinical_session() as s:
task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW)
task_id = task.id
replies = await released.handle(
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
)
(row,) = await rows()
assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers
assert row.details["stop_reason"] == "cssrs_since_contact_required"
assert not answer_button(replies)
assert any("последнего ответа" in r.text for r in replies)
for _ in range(5):
button = answer_button(replies)
if ":cssrs_" not in (button or ""):
break
replies = await press(released, button)
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
assert risk.status == "complete" and risk.answers == {
"cssrs_q1": 0,
"cssrs_q2": 0,
"cssrs_q6_lifetime": 0,
"cssrs_q6_recent": 0,
}
assert risk.details["answer_metadata"]["cssrs_q6_recent"]["derived"] is True
assert ":mood:" in answer_button(replies)
assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
async def test_disabled_or_no_adult_confirmation_cannot_start(released):
@@ -256,6 +282,20 @@ async def review_risk_record(e):
)
async def run_diary(e, replies, choices=None, numbers=("6,5", "2")):
"""Answers the open diary questions; numeric items take `numbers` in turn."""
numbers = list(numbers)
for _ in range(30):
button = answer_button(replies, choices)
if button:
replies = await press(e, button)
elif any("часов" in r.text or "порци" in r.text for r in replies) and numbers:
replies = await e.handle(TOKEN, Event(type="text", text=numbers.pop(0)))
else:
return replies
raise AssertionError("diary did not terminate")
async def test_doctor_review_is_audited_and_unblocks_diary(released):
from fastapi import HTTPException
@@ -263,8 +303,14 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
from app.db.audit import AuditLog
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await jobs.run_until_now()
async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
replies = await run_diary(released, await press(released, "menu:checkin"))
# Evening: the risk check comes first; thoughts of a method (q3) are a moderate risk → stop for a specialist.
replies = await press(released, "menu:checkin")
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0})
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
assert risk.status == "review_required"
with pytest.raises(HTTPException) as exc:
await review(
TOKEN,
@@ -277,53 +323,43 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required")
await review_risk_record(released)
await jobs.run_until_now()
assert any(r.kind == "diary" and r.status == "active" for r in await rows())
assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
await history(TOKEN, "synthetic-doctor")
audits = await rows(AuditLog)
assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"}
async def test_diary_numeric_skip_and_not_prescribed(released):
async def test_diary_morning_then_evening_with_the_ptsd_module(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await press(released, "menu:checkin")
await review_risk_record(released)
replies = await press(released, "menu:checkin")
for _ in range(30):
q = next(
(b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")),
None,
)
if q == "comment":
button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip"))
replies = await press(released, button)
elif q:
replies = await press(released, answer_button(replies, {"medication": 3}))
elif any("часов" in r.text for r in replies):
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
assert any("число часов" in r.text for r in invalid)
replies = await released.handle(TOKEN, Event(type="text", text="6,5"))
else:
break
diary = next(r for r in await rows() if r.kind == "diary")
assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5
assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers
assert diary.answers["comment"] is None
assert any("часов" in r.text for r in replies)
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
assert any("число часов" in r.text for r in invalid)
replies = await run_diary(released, invalid, {"sleep_quality": 3})
assert any("утро" in r.text for r in replies)
morning = next(r for r in await rows() if r.kind == "diary_morning")
assert morning.status == "complete"
assert morning.answers == {"sleep_hours": 6.5, "sleep_quality": 3, "alcohol_portions": 2.0}
replies = await run_diary(released, await press(released, "menu:checkin"), {"mood": 7, "medication": 2})
evening = next(r for r in await rows() if r.kind == "diary_evening")
assert evening.status == "complete" and evening.answers["medication"] == "not_prescribed"
assert "medication_reason" not in evening.answers
assert {"dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18"} <= set(evening.answers)
assert evening.details["interpretation"]["kind"] == "diary_evening"
replies = await press(released, "menu:checkin")
assert any("уже заполнен" in r.text for r in replies)
async def test_high_risk_diary_asks_safety_first_and_stops(released):
async def test_depression_module_item9_stops_with_crisis(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
await press(released, "menu:checkin")
await review_risk_record(released)
replies = await press(released, "menu:checkin")
button = answer_button(replies)
assert "death_thoughts" in button
replies = await press(released, button)
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)
assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line)
diary = next(r for r in await rows() if r.kind == "diary")
assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted"
evening = next(r for r in await rows() if r.kind == "diary_evening")
assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted"
async def test_unknown_sex_goes_to_review_without_a_zone(released):
@@ -344,7 +380,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 == {"cssrs_q1": 0}
assert row.status == "review_required" and row.answers == {"phq9_1": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released):
@@ -377,20 +413,23 @@ async def test_stop_any_policy_interrupts_first_positive(released):
data = yaml.safe_load(path.read_text())
data["cssrs_positive_policy"] = "stop_any"
path.write_text(yaml.safe_dump(data, allow_unicode=True))
replies = await press(released, "intake:start")
replies = await to_cssrs(released)
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
assert any(r.kind == "crisis" for r in replies)
assert (await rows())[0].answers == {"cssrs_q1": 1}
assert (await rows())[0].answers["cssrs_q1"] == 1
async def test_evening_schedule_uses_moscow_date(released, monkeypatch):
async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59))
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=4, minute=59))
await jobs.plan()
assert await rows(QuestionnaireTask) == []
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0))
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=5, minute=0))
await jobs.plan()
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"}
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning"}
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=17, minute=0))
await jobs.plan()
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning", "diary_evening"}
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
@@ -401,7 +440,35 @@ async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:resume")
await press(released, answer_button(replies))
(record,) = await rows()
metadata = record.details["answer_metadata"]["cssrs_q2"]
assert metadata["period_start"] == "2026-09-03"
metadata = record.details["answer_metadata"]["phq9_2"]
assert metadata["period_start"] == "2026-09-19"
assert metadata["period_end"] == "2026-10-03"
assert metadata["answered_at"].startswith("2026-10-04")
async def test_intake_result_is_shown_logged_and_sets_the_diary_modules(released):
from app.api.patient_web import my_results
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
choices.update(trauma=1, tbi=1, psychotropics=1)
replies = await complete_intake(released, choices)
assert any("Предположение депрессии" in r.text for r in replies)
assert any("не диагноз" in r.text for r in replies)
(row,) = await rows()
interpretation = row.details["interpretation"]
assert interpretation["codes"] == ["depression_suspected"] and interpretation["modules"] == ["depression"]
async with session.get_clinical_session() as s:
# A doctor's modules (diagnosis) stay; the screening only adds to them.
assert (await repo.get_profile(s, TOKEN)).modules == ["veteran", "depression"]
results = await my_results(TOKEN)
assert results.conclusion.title == interpretation["title"]
assert [h.kind for h in results.history] == ["intake"] and results.history[0].status_title == "пройден"
async def test_without_a_doctor_the_screening_defines_the_modules(released):
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
replies = await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
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 == []