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

Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
2026-10-03 18:41:31 +03:00
51 changed files with 4211 additions and 27 deletions
+58
View File
@@ -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)
+3
View File
@@ -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
+407
View File
@@ -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")
+110
View File
@@ -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
+275
View File
@@ -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
+5 -4
View File
@@ -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"
+1
View File
@@ -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