Merge pull request 'feat(questionnaire): revised intake, diaries and follow-up monitoring' (#53) from feat/52-questionnaire into dev
CI / ruff · pytest (push) Successful in 59s
CI / ruff · pytest (push) Successful in 59s
Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""Protect the reviewed PDF transcription and its connection to Recent scoring."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.cssrs_content import load_cssrs_russian
|
||||
from app.core.questionnaire import score_cssrs_recent
|
||||
|
||||
CLINICAL = Path(__file__).resolve().parents[1] / "docs" / "clinical"
|
||||
|
||||
|
||||
def test_source_hashes_and_complete_transcription():
|
||||
form = load_cssrs_russian()
|
||||
manifest = json.loads((CLINICAL / "review-status.json").read_text())["cssrs_russian_pdf"]
|
||||
assert hashlib.sha256((CLINICAL / manifest["file"]).read_bytes()).hexdigest() == manifest["sha256"]
|
||||
source = (CLINICAL / manifest["source_text"]).read_bytes()
|
||||
assert hashlib.sha256(source).hexdigest() == manifest["source_text_sha256"]
|
||||
assert form.version == manifest["content_version"]
|
||||
# Reconstruct the complete extracted page, including numbering, guidance,
|
||||
# original typos, answer order and the color legend. No wording is dropped.
|
||||
blocks = [form.title, form.subtitle, form.period_label_original, form.initial_instruction]
|
||||
blocks.extend(option.label for option in form.options)
|
||||
for index, item in enumerate(form.items):
|
||||
blocks.append(f"{index + 1}. {item.text}" if index < 6 else item.text)
|
||||
if item.interviewer_guidance:
|
||||
blocks.append(item.interviewer_guidance)
|
||||
if item.id == "q2":
|
||||
blocks.append(form.branch_instruction)
|
||||
blocks.extend(["Low Risk", "Moderate Risk", "High Risk"])
|
||||
assert " ".join(blocks) == source.decode().strip()
|
||||
assert [item.recall_period for item in form.items] == ["past_month"] * 5 + ["lifetime", "past_3_months"]
|
||||
|
||||
|
||||
def test_all_source_branches_and_color_categories_match_scorer():
|
||||
form = load_cssrs_russian()
|
||||
colors = {item.id: item.yes_risk for item in form.items}
|
||||
assert list(colors.values()) == ["low", "low", "moderate", "high", "high", "moderate", "high"]
|
||||
levels = ["none", "low", "moderate", "high"]
|
||||
for values in product((0, 1), repeat=7):
|
||||
all_answers = dict(zip(colors, values, strict=True))
|
||||
asked = ["q1", "q2"]
|
||||
if all_answers["q2"]:
|
||||
asked.extend(["q3", "q4", "q5"])
|
||||
asked.append("q6_lifetime")
|
||||
if all_answers["q6_lifetime"]:
|
||||
asked.append("q6_recent")
|
||||
answers = {}
|
||||
for item_id in asked:
|
||||
partial = score_cssrs_recent(answers)
|
||||
assert not partial.complete
|
||||
assert partial.missing[0] == item_id
|
||||
answers[item_id] = all_answers[item_id]
|
||||
result = score_cssrs_recent(answers)
|
||||
expected = max((levels.index(colors[key]) for key, value in answers.items() if value), default=0)
|
||||
assert result.complete and result.risk == levels[expected]
|
||||
assert result.required == tuple(asked)
|
||||
@@ -67,6 +67,9 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
|
||||
"safety_plans",
|
||||
"clock_state",
|
||||
"audit_log",
|
||||
"questionnaire_records",
|
||||
"questionnaire_notices",
|
||||
"questionnaire_tasks",
|
||||
} == clinical_tables
|
||||
await init_db() # idempotent
|
||||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Synthetic released-config integration tests. Production approvals stay empty."""
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import get_settings
|
||||
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.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
|
||||
from app.dialog.contracts import Event
|
||||
from app.dialog.engine import Engine
|
||||
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
|
||||
from app.llm.gateway import LLMGateway
|
||||
from app.llm.mock import MockTasks
|
||||
from app.scheduler import jobs
|
||||
from app.services import questionnaire as service
|
||||
|
||||
TOKEN = "b" * 32
|
||||
NOW = datetime(2026, 10, 3, 17, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def released(tmp_path, monkeypatch):
|
||||
config = tmp_path / "config"
|
||||
shutil.copytree(get_settings().config_dir, config)
|
||||
for name in ("questionnaire", "intake_runtime"):
|
||||
path = config / f"{name}.yaml"
|
||||
data = yaml.safe_load(path.read_text())
|
||||
approval = dict(
|
||||
reviewer="Synthetic test",
|
||||
decision_reference="fixture-only",
|
||||
approved_at=NOW.isoformat(),
|
||||
policy_version=data["version"],
|
||||
)
|
||||
data["approval"] = approval
|
||||
if name == "questionnaire":
|
||||
data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1)
|
||||
else:
|
||||
data.update(enabled=True, content_review=approval, contact_review=approval)
|
||||
path.write_text(yaml.safe_dump(data, allow_unicode=True))
|
||||
monkeypatch.setattr(get_settings(), "config_dir", config)
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW)
|
||||
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
|
||||
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
|
||||
clear_cache()
|
||||
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
|
||||
await init_db()
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=TOKEN, doctor_id="synthetic-doctor", modules=["veteran"], sex="m")
|
||||
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=NOW)
|
||||
yield Engine(gateway=LLMGateway(MockTasks()))
|
||||
await session.configure()
|
||||
clear_cache()
|
||||
|
||||
|
||||
async def rows(cls=QuestionnaireRecord):
|
||||
async with session.get_clinical_session() as s:
|
||||
return list((await s.scalars(select(cls))).all())
|
||||
|
||||
|
||||
async def press(e, data):
|
||||
return await e.handle(TOKEN, Event(type="button", button=data))
|
||||
|
||||
|
||||
def answer_button(replies, choices=None):
|
||||
choices = choices or {}
|
||||
for reply in reversed(replies):
|
||||
for line in reply.buttons:
|
||||
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)
|
||||
for line2 in reply.buttons:
|
||||
for other in line2:
|
||||
if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"):
|
||||
return other.data
|
||||
return None
|
||||
|
||||
|
||||
async def complete_intake(e, choices=None):
|
||||
replies = await press(e, "intake:start")
|
||||
for _ in range(100):
|
||||
button = answer_button(replies, choices)
|
||||
if button is None:
|
||||
return replies
|
||||
replies = await press(e, button)
|
||||
raise AssertionError("intake did not terminate")
|
||||
|
||||
|
||||
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()
|
||||
assert row.status == "complete"
|
||||
assert row.answers["trauma"] == 0 and not any(k.startswith("tsq_") for k in row.answers)
|
||||
assert row.details["totals"]["tsq"]["total"] is None
|
||||
assert row.details["totals"]["phq9"]["total"] is None
|
||||
assert row.details["totals"]["rus_audit_s"]["total"] == 0
|
||||
assert row.details["totals"]["cssrs_recent"]["risk"] == "none"
|
||||
assert row.details["decision"]["primary_route"] == "self_help"
|
||||
|
||||
|
||||
async def test_pause_restart_and_duplicate_callbacks(released):
|
||||
replies = await press(released, "intake:start")
|
||||
old = answer_button(replies)
|
||||
await asyncio.gather(press(released, old), press(released, old))
|
||||
(row,) = await rows()
|
||||
assert row.answers == {"cssrs_q1": 0}
|
||||
await press(released, "intake:pause")
|
||||
restarted = Engine(gateway=LLMGateway(MockTasks()))
|
||||
replies = await press(restarted, "intake:resume")
|
||||
assert "cssrs_q2" in answer_button(replies)
|
||||
await press(restarted, old)
|
||||
assert (await rows())[0].answers == {"cssrs_q1": 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"])
|
||||
async def test_immediate_stop_preserves_partial_answers(released, signal):
|
||||
replies = await press(released, "intake:start")
|
||||
if signal == "help":
|
||||
replies = await press(released, "intake:help")
|
||||
elif signal == "cssrs":
|
||||
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})
|
||||
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
|
||||
|
||||
|
||||
async def complete_existing(e, replies, choices):
|
||||
for _ in range(100):
|
||||
button = answer_button(replies, choices)
|
||||
if not button:
|
||||
return replies
|
||||
replies = await press(e, button)
|
||||
raise AssertionError("not terminated")
|
||||
|
||||
|
||||
async def test_full_tsq_expands_at_six_and_carries_alcohol_answers(released):
|
||||
choices = {"tsq_" + str(i): 0 if i <= 6 else 1 for i in range(1, 11)}
|
||||
choices.update(rus_audit_3=1, rus_audit_1=1, tbi=1, psychotropics=1)
|
||||
await complete_intake(released, choices)
|
||||
(row,) = await rows()
|
||||
assert row.status == "complete"
|
||||
assert row.details["totals"]["tsq"]["total"] == 6
|
||||
assert row.details["totals"]["pcl5"]["total"] == 0
|
||||
assert row.details["totals"]["rus_audit"]["complete"]
|
||||
assert row.answers["rus_audit_3"] == 1
|
||||
assert len([k for k in row.answers if k.startswith("rus_audit_")]) == 10
|
||||
|
||||
|
||||
async def test_alcohol_conflict_keeps_positive_short_answer(released):
|
||||
await complete_intake(released, {"trauma": 1, "rus_audit_3": 1, "rus_audit_1": 0})
|
||||
(row,) = await rows()
|
||||
assert row.status == "review_required" and row.answers["rus_audit_3"] == 1
|
||||
assert row.details["stop_reason"] == "answer_conflict"
|
||||
|
||||
|
||||
async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
|
||||
replies = await press(released, "intake:start")
|
||||
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)
|
||||
|
||||
|
||||
async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch):
|
||||
class Failing:
|
||||
async def send(self, *args, **kwargs):
|
||||
raise OSError("sensitive error content must not be stored")
|
||||
|
||||
monkeypatch.setattr(integrations, "notifier", Failing())
|
||||
await press(released, "intake:start")
|
||||
await press(released, "intake:help")
|
||||
(notice,) = await rows(QuestionnaireNotice)
|
||||
assert notice.status == "pending" and notice.error_code == "OSError" and notice.attempts == 1
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(minutes=3))
|
||||
notifier = MemoryNotifier()
|
||||
monkeypatch.setattr(integrations, "notifier", notifier)
|
||||
await service.dispatch_notices()
|
||||
await service.dispatch_notices()
|
||||
assert len(notifier.sent) == 1
|
||||
assert (await rows(QuestionnaireNotice))[0].attempts == 2
|
||||
await press(released, "consent:revoke")
|
||||
async with session.get_clinical_session() as s:
|
||||
state = await repo.get_dialog_state(s, TOKEN)
|
||||
assert state.data["consent"] is False and "questionnaire_record" not in state.data
|
||||
|
||||
|
||||
async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeypatch):
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
|
||||
class Failing:
|
||||
async def push(self, *args):
|
||||
raise OSError()
|
||||
|
||||
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
|
||||
outbox = MemoryOutbox()
|
||||
monkeypatch.setattr(integrations, "outbox", outbox)
|
||||
await jobs.run_until_now()
|
||||
await jobs.run_until_now()
|
||||
assert len(await rows(QuestionnaireTask)) == 2
|
||||
assert len(outbox.drain(TOKEN)) == 1
|
||||
assert len(await rows()) == 2
|
||||
|
||||
|
||||
async def test_missing_since_contact_is_manual_review_not_recent(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)
|
||||
|
||||
|
||||
async def test_disabled_or_no_adult_confirmation_cannot_start(released):
|
||||
async with session.get_clinical_session() as s:
|
||||
state = await repo.get_dialog_state(s, TOKEN)
|
||||
state.data = {"consent": True}
|
||||
await press(released, "intake:start")
|
||||
assert await rows() == []
|
||||
|
||||
|
||||
async def review_risk_record(e):
|
||||
from app.api.questionnaire import ReviewIn, review
|
||||
|
||||
risk = next(r for r in reversed(await rows()) if r.kind == "cssrs_since_contact")
|
||||
return await review(
|
||||
TOKEN,
|
||||
risk.id,
|
||||
ReviewIn(
|
||||
doctor_id="synthetic-doctor", assessment_performed=True, decision="resume", reference="synthetic/assessment"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_doctor_review_is_audited_and_unblocks_diary(released):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.questionnaire import ReviewIn, history, review
|
||||
from app.db.audit import AuditLog
|
||||
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
await jobs.run_until_now()
|
||||
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await review(
|
||||
TOKEN,
|
||||
risk.id,
|
||||
ReviewIn(doctor_id="other-doctor", assessment_performed=True, decision="resume", reference="test"),
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
await released.handle(TOKEN, Event(type="system", system="doctor_ack"))
|
||||
async with session.get_clinical_session() as s:
|
||||
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())
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
async def test_high_risk_diary_asks_safety_first_and_stops(released):
|
||||
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)
|
||||
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"
|
||||
|
||||
|
||||
async def test_unknown_sex_goes_to_review_without_a_zone(released):
|
||||
async with session.get_clinical_session() as s:
|
||||
(await repo.get_profile(s, TOKEN)).sex = None
|
||||
await complete_intake(released, {"trauma": 1, "sex": 2})
|
||||
(row,) = await rows()
|
||||
assert row.status == "review_required" and row.answers["sex"] == "unknown"
|
||||
|
||||
|
||||
async def test_version_change_blocks_resume_and_preserves_answers(released):
|
||||
replies = await press(released, "intake:start")
|
||||
await press(released, answer_button(replies))
|
||||
await press(released, "intake:pause")
|
||||
path = get_settings().config_dir / "instruments/intake_forms.yaml"
|
||||
data = yaml.safe_load(path.read_text())
|
||||
data["version"] = "synthetic-next-version"
|
||||
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}
|
||||
|
||||
|
||||
async def test_followup_delta_uses_comparable_complete_baseline(released):
|
||||
await complete_intake(released, {"trauma": 1, "phq9_1": 2, "tbi": 1, "psychotropics": 1})
|
||||
async with session.get_clinical_session() as s:
|
||||
task = await service.queue_task(s, TOKEN, "phq9", "synthetic-repeat", NOW)
|
||||
task_id = task.id
|
||||
replies = await released.handle(
|
||||
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
|
||||
)
|
||||
replies = await complete_existing(released, replies, {"phq9_1": 3, "phq9_2": 3, "phq9_3": 1})
|
||||
repeat = next(r for r in await rows() if r.kind == "phq9")
|
||||
assert repeat.status == "complete" and repeat.details["delta"]["value"] == 5
|
||||
assert repeat.details["delta"]["retriage"] is True
|
||||
|
||||
|
||||
async def test_silence_uses_completed_checkins_not_empty_records(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
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
|
||||
await jobs.plan()
|
||||
await jobs.plan()
|
||||
notices = await rows(QuestionnaireNotice)
|
||||
assert len([n for n in notices if n.event_key.startswith("silence:")]) == 1
|
||||
|
||||
|
||||
async def test_stop_any_policy_interrupts_first_positive(released):
|
||||
path = get_settings().config_dir / "questionnaire.yaml"
|
||||
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 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}
|
||||
|
||||
|
||||
async def test_evening_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))
|
||||
await jobs.plan()
|
||||
assert await rows(QuestionnaireTask) == []
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0))
|
||||
await jobs.plan()
|
||||
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"}
|
||||
|
||||
|
||||
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
|
||||
replies = await press(released, "intake:start")
|
||||
await press(released, answer_button(replies))
|
||||
await press(released, "intake:pause")
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1))
|
||||
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"
|
||||
assert metadata["period_end"] == "2026-10-03"
|
||||
assert metadata["answered_at"].startswith("2026-10-04")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Synthetic exhaustive scoring and calendar/diary boundary checks."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from itertools import product
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.intake_runtime import diary_flags, due_scales, load_runtime, month_before
|
||||
from app.core.questionnaire import IntakePolicy, alcohol_expansion, load_policy, score_alcohol
|
||||
from app.core.scales import score
|
||||
|
||||
|
||||
def test_release_is_closed():
|
||||
assert not load_runtime().released
|
||||
assert load_policy().approval is None
|
||||
|
||||
|
||||
def test_all_tsq_patterns():
|
||||
for answers in product((0, 1), repeat=10):
|
||||
result = score("tsq", {f"tsq_{i + 1}": v for i, v in enumerate(answers)})
|
||||
assert result.complete and result.total == sum(answers)
|
||||
assert (result.severity_code == "positive") == (sum(answers) >= 6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"day,expected",
|
||||
[
|
||||
(date(2024, 3, 31), date(2024, 2, 29)),
|
||||
(date(2025, 3, 31), date(2025, 2, 28)),
|
||||
(date(2026, 1, 1), date(2025, 12, 1)),
|
||||
],
|
||||
)
|
||||
def test_calendar_month(day, expected):
|
||||
assert month_before(day) == expected
|
||||
|
||||
|
||||
def test_medication_schedule_overlapping_and_delayed_completion():
|
||||
start = date(2026, 1, 1)
|
||||
last = {}
|
||||
for day in (6, 7, 13, 14, 27, 28, 55, 56):
|
||||
due = due_scales(
|
||||
today=start + timedelta(days=day), enrolled=start, last=last, route="self_help", medication_changed=start
|
||||
)
|
||||
assert ("phq9" in due) == (day in (7, 14, 28, 56))
|
||||
if "phq9" in due:
|
||||
last.update(phq9=start + timedelta(days=day), pcl5=start + timedelta(days=day))
|
||||
# A missed tick yields one form, not each historical milestone.
|
||||
assert (
|
||||
due_scales(
|
||||
today=start + timedelta(days=80), enrolled=start, last={}, route="self_help", medication_changed=start
|
||||
).count("phq9")
|
||||
== 1
|
||||
)
|
||||
assert "phq9" in due_scales(
|
||||
today=start + timedelta(days=7), enrolled=start, last={}, route="specialist", after_crisis=start
|
||||
)
|
||||
|
||||
|
||||
def test_diary_consecutive_days_and_baseline_do_not_include_bad_window():
|
||||
today = date(2026, 10, 3)
|
||||
history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)}
|
||||
for i in range(3):
|
||||
history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"}
|
||||
assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"}
|
||||
history[today - timedelta(days=1)] = {}
|
||||
assert diary_flags(history, today) == ()
|
||||
history[today]["death_thoughts"] = 1
|
||||
assert diary_flags(history, today) == ("suicide_assessment_now",)
|
||||
|
||||
|
||||
def test_sleep_alternation_counts_but_threshold_is_strict():
|
||||
today = date(2026, 10, 3)
|
||||
h = {
|
||||
today: {"nightmares": 3},
|
||||
today - timedelta(days=1): {"sleep_hours": 3.5},
|
||||
today - timedelta(days=2): {"sleep_hours": 3.5},
|
||||
}
|
||||
assert "sleep_review" in diary_flags(h, today)
|
||||
h[today - timedelta(days=1)]["sleep_hours"] = 4
|
||||
assert "sleep_review" not in diary_flags(h, today)
|
||||
|
||||
|
||||
def test_total_threshold_and_zone_are_separate_decisions():
|
||||
data = load_policy().model_dump()
|
||||
data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1)
|
||||
data["approval"] = dict(
|
||||
reviewer="Synthetic fixture",
|
||||
decision_reference="test",
|
||||
approved_at="2026-10-03T00:00:00Z",
|
||||
policy_version=data["version"],
|
||||
)
|
||||
policy = IntakePolicy.model_validate(data)
|
||||
r = score_alcohol({"rus_audit_3": 1, "rus_audit_9": 0, "rus_audit_10": 0}, short=True, sex="m")
|
||||
assert r.zone == 1 and alcohol_expansion(r, policy)
|
||||
with pytest.raises(ValueError):
|
||||
IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2})
|
||||
|
||||
|
||||
def test_module_rotation_never_drops_suicide_question():
|
||||
from app.core.intake_questions import diary_questions
|
||||
|
||||
first = {
|
||||
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100)
|
||||
}
|
||||
second = {
|
||||
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101)
|
||||
}
|
||||
assert "death_thoughts" in first & second
|
||||
assert "nightmares" in first and "nightmares" not in second
|
||||
assert "burden" in first and "burden" not in second
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Synthetic boundary, branching and safety regression cases for intake (#52)."""
|
||||
|
||||
from itertools import product
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.questionnaire import (
|
||||
IntakePolicy,
|
||||
alcohol_expansion,
|
||||
evaluate_intake,
|
||||
extend_alcohol,
|
||||
load_policy,
|
||||
score_alcohol,
|
||||
score_cssrs_recent,
|
||||
)
|
||||
from app.core.scales import load_scale
|
||||
|
||||
|
||||
def approved(mode="graded"):
|
||||
data = load_policy().model_dump()
|
||||
data.update(cssrs_positive_policy=mode, rus_audit_s_expand_from_zone=2)
|
||||
data["approval"] = {
|
||||
"reviewer": "SYNTHETIC TEST ONLY",
|
||||
"decision_reference": "test-fixture-not-clinical-approval",
|
||||
"approved_at": "2026-10-03T12:00:00+03:00",
|
||||
"policy_version": data["version"],
|
||||
}
|
||||
return IntakePolicy.model_validate(data)
|
||||
|
||||
|
||||
def zeros(scale):
|
||||
return {key: 0 for key in load_scale(scale).item_ids}
|
||||
|
||||
|
||||
def intake(**changes):
|
||||
args = dict(
|
||||
phq9={"phq9_1": 0, "phq9_2": 0},
|
||||
tsq={},
|
||||
pcl5={},
|
||||
alcohol=zeros("rus_audit_s"),
|
||||
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 0},
|
||||
sex="f",
|
||||
trauma_exposure=False,
|
||||
policy=approved(),
|
||||
)
|
||||
args.update(changes)
|
||||
return evaluate_intake(**args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sex", ["f", "m"])
|
||||
def test_every_short_alcohol_answer_combination(sex):
|
||||
# Exhaustively cover all valid patterns, including sparse 0/2/4 history options.
|
||||
for q3, q9, q10 in product(range(5), (0, 2, 4), (0, 2, 4)):
|
||||
total = q3 + q9 + q10
|
||||
expected = (
|
||||
(1 if total < 2 else 2 if total < 5 else 3 if total == 5 else 4)
|
||||
if sex == "f"
|
||||
else (1 if total < 4 else 2 if total < 7 else 3 if total == 7 else 4)
|
||||
)
|
||||
result = score_alcohol(
|
||||
dict(zip(load_scale("rus_audit_s").item_ids, (q3, q9, q10), strict=True)), short=True, sex=sex
|
||||
)
|
||||
assert result.complete and result.total == total and result.zone == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sex,bounds",
|
||||
[
|
||||
("f", [(0, 1), (4, 1), (5, 2), (9, 2), (10, 3), (11, 4), (40, 4)]),
|
||||
("m", [(0, 1), (8, 1), (9, 2), (13, 2), (14, 3), (16, 3), (17, 4), (40, 4)]),
|
||||
],
|
||||
)
|
||||
def test_full_alcohol_boundaries(sex, bounds):
|
||||
for total, expected in bounds:
|
||||
values = zeros("rus_audit")
|
||||
remaining = total
|
||||
for item in load_scale("rus_audit").items:
|
||||
value = max(o.value for o in item.options if o.value <= remaining)
|
||||
values[item.id] = value
|
||||
remaining -= value
|
||||
assert remaining == 0
|
||||
assert score_alcohol(values, short=False, sex=sex).zone == expected
|
||||
|
||||
|
||||
def test_alcohol_missing_unknown_sex_and_no_implicit_approval():
|
||||
r = score_alcohol({"rus_audit_3": 0}, short=True, sex="f")
|
||||
assert not r.complete and r.total is None and r.zone is None
|
||||
r = score_alcohol(zeros("rus_audit_s"), short=True, sex=None)
|
||||
assert r.complete and r.total == 0 and r.zone is None
|
||||
r = score_alcohol({"rus_audit_3": 4, "rus_audit_9": 4, "rus_audit_10": 4}, short=True, sex="m")
|
||||
assert alcohol_expansion(r, load_policy()) is None
|
||||
assert alcohol_expansion(r, approved()) is True
|
||||
|
||||
|
||||
def test_full_extension_preserves_history_and_never_counts_twice():
|
||||
short = {"rus_audit_3": 0, "rus_audit_9": 2, "rus_audit_10": 4}
|
||||
extra = {"rus_audit_1": 0, **{f"rus_audit_{i}": 0 for i in range(4, 9)}, "rus_audit_9": 2}
|
||||
merged = extend_alcohol(short, extra)
|
||||
result = score_alcohol(merged, short=False, sex="f")
|
||||
assert result.complete and result.total == 6
|
||||
assert result.derived == ("rus_audit_2",)
|
||||
assert "rus_audit_2" not in merged # caller-owned raw answers are not mutated
|
||||
with pytest.raises(ValueError, match="conflicting"):
|
||||
extend_alcohol(short, {"rus_audit_9": 0})
|
||||
with pytest.raises(ValueError, match="conflicts"):
|
||||
score_alcohol({"rus_audit_1": 0, "rus_audit_3": 1}, short=False, sex="m")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [True, "1", 1.0, -1, 5])
|
||||
def test_alcohol_rejects_invalid_answers(bad):
|
||||
with pytest.raises(ValueError):
|
||||
score_alcohol({"rus_audit_3": bad}, short=True, sex="f")
|
||||
|
||||
|
||||
def test_cannot_use_auditc_items_or_sparse_history_values():
|
||||
for values in [{"rus_audit_1": 1}, {"auditc_1": 1}, {"rus_audit_9": 1}]:
|
||||
with pytest.raises(ValueError):
|
||||
score_alcohol(values, short=True, sex="f")
|
||||
|
||||
|
||||
def test_cssrs_negative_branch_is_complete_without_inventing_zeroes():
|
||||
a = {"q1": 0, "q2": 0, "q6_lifetime": 0}
|
||||
r = score_cssrs_recent(a)
|
||||
assert r.complete and r.risk == "none"
|
||||
assert r.required == ("q1", "q2", "q6_lifetime")
|
||||
assert set(a) == set(r.required)
|
||||
|
||||
|
||||
def test_cssrs_remote_behavior_is_not_negative_and_missing_recency_not_low():
|
||||
a = {"q1": 0, "q2": 0, "q6_lifetime": 1}
|
||||
r = score_cssrs_recent(a)
|
||||
assert not r.complete and r.risk is None and r.risk_lower_bound == "moderate"
|
||||
assert r.missing == ("q6_recent",)
|
||||
assert score_cssrs_recent({**a, "q6_recent": 0}).risk == "moderate"
|
||||
assert score_cssrs_recent({**a, "q6_recent": 1}).risk == "high"
|
||||
|
||||
|
||||
def test_cssrs_ideation_branch_and_partial_high():
|
||||
assert score_cssrs_recent({}).risk is None
|
||||
assert score_cssrs_recent({"q2": 1}).missing == ("q1", "q3", "q4", "q5", "q6_lifetime")
|
||||
r = score_cssrs_recent({"q4": 1})
|
||||
assert not r.complete and r.risk is None and r.risk_lower_bound == "high"
|
||||
|
||||
|
||||
def test_cssrs_exhaustive_complete_ideation_patterns():
|
||||
for q1, q3, q4, q5, lifetime, recent in product((0, 1), repeat=6):
|
||||
a = dict(q1=q1, q2=1, q3=q3, q4=q4, q5=q5, q6_lifetime=lifetime)
|
||||
if lifetime:
|
||||
a["q6_recent"] = recent
|
||||
expected = "high" if q4 or q5 or (lifetime and recent) else "moderate" if q3 or lifetime else "low"
|
||||
r = score_cssrs_recent(a)
|
||||
assert r.complete and r.risk == expected
|
||||
|
||||
|
||||
def test_cssrs_conflict_preserves_high_signal():
|
||||
r = score_cssrs_recent({"q1": 0, "q2": 0, "q4": 1, "q6_lifetime": 0, "q6_recent": 1})
|
||||
assert not r.complete and r.risk_lower_bound == "high" and len(r.conflicts) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, True, "Да", 1.0, 2, -1])
|
||||
def test_cssrs_requires_explicit_binary_integer(bad):
|
||||
with pytest.raises(ValueError):
|
||||
score_cssrs_recent({"q1": bad})
|
||||
|
||||
|
||||
def test_unapproved_policy_never_continues_even_a_negative_screen():
|
||||
r = intake(policy=load_policy())
|
||||
assert r.status == "clinical_review_required" and r.primary_route is None
|
||||
assert intake(policy=load_policy(), cssrs={"q1": 1}).status == "clinical_review_required"
|
||||
|
||||
|
||||
def test_immediate_safety_preempts_unfinished_or_invalid_unrelated_scales():
|
||||
for overrides in [
|
||||
dict(help_now=True, cssrs={"broken": "data"}),
|
||||
dict(phq9={"phq9_9": 1}),
|
||||
dict(cssrs={"q4": 1}, alcohol={"bad": 50}),
|
||||
]:
|
||||
assert intake(policy=load_policy(), **overrides).status == "crisis"
|
||||
r = intake(phq9={"phq9_9": 1})
|
||||
assert "phq9_cssrs_discrepancy" in r.flags
|
||||
|
||||
|
||||
def test_physician_must_choose_between_stop_and_graded():
|
||||
low = {"q1": 1, "q2": 0, "q6_lifetime": 0}
|
||||
assert intake(cssrs=low, policy=approved("stop_any")).status == "crisis"
|
||||
r = intake(cssrs=low, policy=approved("graded"))
|
||||
assert r.status == "ready" and "cssrs_low_safety_plan" in r.flags
|
||||
data = approved().model_dump()
|
||||
data["version"] = "changed"
|
||||
with pytest.raises(ValueError, match="matching version"):
|
||||
IntakePolicy.model_validate(data)
|
||||
|
||||
|
||||
def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative():
|
||||
assert intake().primary_route == "self_help"
|
||||
r = intake(phq9={"phq9_1": 1, "phq9_2": 1})
|
||||
assert r.status == "incomplete" and "phq9_9" in r.missing
|
||||
assert intake(trauma_exposure=None).status == "incomplete"
|
||||
assert intake(trauma_exposure=True, tsq={"tsq_1": 1}).status == "incomplete"
|
||||
pc = {f"tsq_{i}": int(i <= 6) for i in range(1, 11)}
|
||||
assert intake(trauma_exposure=True, tsq=pc).status == "incomplete"
|
||||
assert intake(trauma_exposure=True, tsq=pc, pcl5=zeros("pcl5")).primary_route == "psychologist"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"total,route",
|
||||
[
|
||||
(30, "self_help"),
|
||||
(31, "psychiatrist_or_psychotherapist"),
|
||||
(49, "psychiatrist_or_psychotherapist"),
|
||||
(50, "priority_psychiatrist"),
|
||||
],
|
||||
)
|
||||
def test_pdf_pcl_boundaries_are_distinct_from_legacy_33(total, route):
|
||||
values = zeros("pcl5")
|
||||
for key in values:
|
||||
values[key] = min(4, total)
|
||||
total -= values[key]
|
||||
assert intake(pcl5=values).primary_route == route
|
||||
|
||||
|
||||
def test_urgent_route_and_parallel_referrals_do_not_overwrite_each_other():
|
||||
alcohol = {i.id: i.max_value for i in load_scale("rus_audit").items}
|
||||
r = intake(
|
||||
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 1, "q6_recent": 0},
|
||||
alcohol=alcohol,
|
||||
traumatic_brain_injury=True,
|
||||
psychotropics=True,
|
||||
)
|
||||
assert r.primary_route == "urgent_psychiatrist"
|
||||
assert r.parallel_routes == ("narcologist", "neurologist")
|
||||
assert "alcohol_psychotropics" in r.flags
|
||||
|
||||
|
||||
def test_alcohol_expansion_and_unknown_sex_block_routine_route():
|
||||
assert intake(alcohol={"rus_audit_3": 2, "rus_audit_9": 0, "rus_audit_10": 0}).status == "incomplete"
|
||||
r = intake(sex=None)
|
||||
assert r.status == "incomplete" and "alcohol_scoring_sex" in r.missing
|
||||
|
||||
|
||||
def test_non_drinker_does_not_derive_missing_short_answer():
|
||||
values = zeros("rus_audit")
|
||||
del values["rus_audit_2"]
|
||||
del values["rus_audit_3"]
|
||||
assert intake(alcohol=values).status == "incomplete"
|
||||
assert "rus_audit_3" in intake(alcohol=values).missing
|
||||
|
||||
|
||||
def test_crisis_keeps_other_flags_and_bad_data_cannot_hide_explicit_signal():
|
||||
alcohol = {i.id: i.max_value for i in load_scale("rus_audit").items}
|
||||
r = intake(cssrs={"q4": 1}, pcl5={"pcl5_15": 3}, alcohol=alcohol, traumatic_brain_injury=True, psychotropics=True)
|
||||
assert r.status == "crisis"
|
||||
assert {"pcl5_behavior", "alcohol_zone_iv", "alcohol_psychotropics", "traumatic_brain_injury"} <= set(r.flags)
|
||||
assert intake(cssrs={"q4": 1, "invalid": 7}).status == "crisis"
|
||||
assert intake(phq9={"phq9_9": 1}, cssrs={"invalid": 7}).status == "crisis"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"total,route",
|
||||
[
|
||||
(4, "self_help"),
|
||||
(5, "psychologist"),
|
||||
(9, "psychologist"),
|
||||
(10, "psychiatrist_or_psychotherapist"),
|
||||
(19, "psychiatrist_or_psychotherapist"),
|
||||
(20, "priority_psychiatrist"),
|
||||
(24, "priority_psychiatrist"),
|
||||
],
|
||||
)
|
||||
def test_phq_routes_at_each_boundary_without_positive_item9(total, route):
|
||||
values = zeros("phq9")
|
||||
for key in list(values)[:8]:
|
||||
values[key] = min(3, total)
|
||||
total -= values[key]
|
||||
assert intake(phq9=values).primary_route == route
|
||||
@@ -159,9 +159,10 @@ def test_crisis_reply_patient_default() -> None:
|
||||
assert isinstance(script, CrisisScript)
|
||||
assert script.variant == "patient.default"
|
||||
assert "112" in script.text
|
||||
assert "8-800-2000-122" in script.text
|
||||
assert "бесплатно, круглосуточно" in script.text
|
||||
assert "уведомлён" in script.text
|
||||
assert "+7 (495) 989-50-50" in script.text
|
||||
assert "круглосуточно" in script.text
|
||||
assert "по тарифу оператора" in script.text
|
||||
assert "уведомлён" not in script.text
|
||||
assert script.grounding in script.text
|
||||
assert "психиатр (#8)" in script.status
|
||||
|
||||
@@ -169,7 +170,7 @@ def test_crisis_reply_patient_default() -> None:
|
||||
def test_crisis_reply_variants() -> None:
|
||||
dv = crisis_reply(["domestic_violence", "suicide"], audience="family")
|
||||
assert dv.variant == "family.domestic_violence"
|
||||
assert "потерпеть" in dv.text and "112" in dv.text and "8-800-2000-122" in dv.text
|
||||
assert "потерпеть" in dv.text and "112" in dv.text and "+7 (495) 989-50-50" in dv.text
|
||||
assert crisis_reply(["domestic_violence"]).variant == "patient.domestic_violence"
|
||||
assert crisis_reply(["harm_others"]).variant == "patient.harm_others"
|
||||
assert crisis_reply(["harm_others"], audience="family").variant == "family.default"
|
||||
|
||||
@@ -30,6 +30,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str:
|
||||
def test_all_scales_load_with_expected_item_counts() -> None:
|
||||
counts = {k: len(v.items) for k, v in load_all().items()}
|
||||
expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4, "dar5": 5}
|
||||
expected.update(rus_audit_s=3, rus_audit=10, tsq=10)
|
||||
assert counts == expected
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user