feat(bot): veteran onboarding — exposure, PC-PTSD-5, PCL-5, PHQ-9, AUDIT-C, weapon access
CI / ruff · pytest (pull_request) Successful in 32s
CI / ruff · pytest (pull_request) Successful in 32s
Start screening of the veteran module: peer-tone intro, trauma exposure gate for PC-PTSD-5, PCL-5/PHQ-9/AUDIT-C via scale sessions (sex asked if unknown), weapon access question stored as facts for lane B with a safe-storage block on risk, plain-language result, modules enabled in profile, FAQ, resume after a crisis. Refs #36 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
"""Veteran start screening (#36): demo persona Andrey end-to-end through `Engine.handle` on the offline mock LLM.
|
||||
|
||||
Connect code → consent → age (known) → intro → trauma exposure → PC-PTSD-5 → PCL-5 → PHQ-9 → AUDIT-C → weapon
|
||||
question → plain-language result → idle. In-memory SQLite (identity / clinical), fake monitoring (lane B stub).
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from app.core import clock
|
||||
from app.core.rules import Action
|
||||
from app.db import identity_repo, repo, session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.dialog.contracts import Event, Reply
|
||||
from app.dialog.engine import Engine
|
||||
from app.dialog.flows import veteran_onboarding as vo
|
||||
from app.dialog.texts import load_dialog_texts
|
||||
from app.integrations.base import VerifiedPerson, integrations
|
||||
from app.llm.gateway import LLMGateway
|
||||
from app.llm.mock import MockTasks
|
||||
|
||||
TOKEN = "b" * 32
|
||||
MEM = "sqlite+aiosqlite:///:memory:"
|
||||
TEXTS = load_dialog_texts()
|
||||
VT = vo.load_texts()
|
||||
|
||||
# Andrey's screening (docs/product/01_demo_persona.md): PC-PTSD-5 = 4, PCL-5 = 46, PHQ-9 = 14 (item 9 = 0),
|
||||
# AUDIT-C = 3 (below the male threshold), no weapon at home.
|
||||
PCPTSD5 = [1, 1, 1, 1, 0]
|
||||
PCL5 = [3] * 6 + [2] * 14
|
||||
PHQ9 = [2, 2, 2, 2, 2, 2, 1, 1, 0]
|
||||
AUDITC = [2, 1, 0]
|
||||
|
||||
|
||||
class FakeMonitoring:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def after_data_change(self, token: str) -> list[Action]:
|
||||
self.calls += 1
|
||||
return []
|
||||
|
||||
async def is_in_crisis(self, token: str) -> bool:
|
||||
return True # only doctor_ack ends a crisis in these tests
|
||||
|
||||
|
||||
async def _make_patient(sex: str | None) -> None:
|
||||
async with session.get_identity_session() as s:
|
||||
await identity_repo.create_patient(
|
||||
s,
|
||||
token=TOKEN,
|
||||
display_name="Андрей (синтетика)",
|
||||
snils="000-000-000 00",
|
||||
clinic_id="c1",
|
||||
doctor_id="doc-test",
|
||||
now=clock.now(),
|
||||
birth_year=1988,
|
||||
sex=sex or "m",
|
||||
)
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-test", modules=["veteran"], sex=sex)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dbs() -> AsyncIterator[None]:
|
||||
await session.configure(identity_url=MEM, clinical_url=MEM)
|
||||
await init_db()
|
||||
await _make_patient("m")
|
||||
yield
|
||||
await session.configure()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dbs_nosex() -> AsyncIterator[None]:
|
||||
await session.configure(identity_url=MEM, clinical_url=MEM)
|
||||
await init_db()
|
||||
await _make_patient(None)
|
||||
yield
|
||||
await session.configure()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mon() -> FakeMonitoring:
|
||||
return FakeMonitoring()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(mon: FakeMonitoring) -> Engine:
|
||||
return Engine(gateway=LLMGateway(MockTasks()), monitoring=mon)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
async def press(e: Engine, data: str) -> list[Reply]:
|
||||
return await e.handle(TOKEN, Event(type="button", button=data))
|
||||
|
||||
|
||||
async def text(e: Engine, t: str) -> list[Reply]:
|
||||
return await e.handle(TOKEN, Event(type="text", text=t))
|
||||
|
||||
|
||||
def buttons(reply: Reply) -> list[str]:
|
||||
return [b.data for row in reply.buttons for b in row]
|
||||
|
||||
|
||||
def scale_buttons(replies: list[Reply], scale: str) -> list[str]:
|
||||
for reply in reversed(replies):
|
||||
found = [d for d in buttons(reply) if d.startswith(f"scale:{scale}:")]
|
||||
if found:
|
||||
return found
|
||||
raise AssertionError(f"no question of {scale} in replies: {[r.text[:40] for r in replies]}")
|
||||
|
||||
|
||||
async def answer(e: Engine, replies: list[Reply], scale: str, values: list[int]) -> list[Reply]:
|
||||
"""Answer `values` item by item with the buttons of the currently shown question."""
|
||||
for value in values:
|
||||
choice = [d for d in scale_buttons(replies, scale) if d.endswith(f":{value}")]
|
||||
assert choice, f"{scale}: no option {value}"
|
||||
replies = await press(e, choice[0])
|
||||
return replies
|
||||
|
||||
|
||||
async def get_state() -> tuple[str, dict[str, Any]]:
|
||||
async with session.get_clinical_session() as s:
|
||||
row = await repo.get_dialog_state(s, TOKEN)
|
||||
assert row is not None
|
||||
return row.state, dict(row.data)
|
||||
|
||||
|
||||
async def results() -> dict[str, Any]:
|
||||
async with session.get_clinical_session() as s:
|
||||
return {r.scale: r for r in await repo.list_scale_results(s, TOKEN)}
|
||||
|
||||
|
||||
async def profile() -> Any:
|
||||
async with session.get_clinical_session() as s:
|
||||
return await repo.get_profile(s, TOKEN)
|
||||
|
||||
|
||||
async def to_intro(e: Engine) -> list[Reply]:
|
||||
await e.handle(TOKEN, Event(type="start"))
|
||||
return await press(e, "consent:yes")
|
||||
|
||||
|
||||
async def to_weapon(e: Engine, phq9: list[int] = PHQ9) -> list[Reply]:
|
||||
await to_intro(e)
|
||||
r = await press(e, "vonb:go:yes")
|
||||
assert buttons(r[-1]) == ["vonb:exposure:yes", "vonb:exposure:no", "vonb:exposure:declined"]
|
||||
r = await press(e, "vonb:exposure:yes")
|
||||
r = await answer(e, r, "pcptsd5", PCPTSD5)
|
||||
r = await answer(e, r, "pcl5", PCL5)
|
||||
r = await answer(e, r, "phq9", phq9)
|
||||
return r
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- happy path
|
||||
|
||||
|
||||
async def test_andrey_full_onboarding(
|
||||
dbs: None, engine: Engine, mon: FakeMonitoring, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
class Ident:
|
||||
async def verify(self, auth_code: str) -> VerifiedPerson | None:
|
||||
return VerifiedPerson(token=TOKEN, doctor_id="doc-test") if auth_code == "QR-ANDREY" else None
|
||||
|
||||
monkeypatch.setattr(integrations, "identity", Ident())
|
||||
r = await engine.handle(TOKEN, Event(type="start", params={"code": "QR-ANDREY"}))
|
||||
assert buttons(r[0]) == ["consent:yes", "consent:no"]
|
||||
|
||||
r = await press(engine, "consent:yes") # birth year known → adult → veteran onboarding
|
||||
assert (await get_state())[0] == vo.START
|
||||
assert r[-1].text == VT.intro.text
|
||||
assert buttons(r[-1]) == ["vonb:go:yes", "vonb:faq:who", "vonb:faq:unit", "vonb:faq:licences"]
|
||||
|
||||
r = await press(engine, "vonb:go:yes")
|
||||
assert (await get_state())[0] == vo.EXPOSURE
|
||||
r = await press(engine, "vonb:exposure:yes")
|
||||
assert (await get_state())[0] == "scale.pcptsd5"
|
||||
r = await answer(engine, r, "pcptsd5", PCPTSD5)
|
||||
assert (await get_state())[0] == "scale.pcl5"
|
||||
r = await answer(engine, r, "pcl5", PCL5)
|
||||
assert (await get_state())[0] == "scale.phq9"
|
||||
r = await answer(engine, r, "phq9", PHQ9)
|
||||
assert (await get_state())[0] == "scale.auditc" # sex known from profile → no question
|
||||
r = await answer(engine, r, "auditc", AUDITC)
|
||||
assert (await get_state())[0] == vo.WEAPON
|
||||
weapon_choices = ["none", "self", "others_or_safe", "declined"]
|
||||
assert buttons(r[-1]) == [f"vonb:weapon:{c}" for c in weapon_choices]
|
||||
|
||||
r = await press(engine, "vonb:weapon:none")
|
||||
state, data = await get_state()
|
||||
assert state == "idle"
|
||||
texts = [x.text for x in r]
|
||||
assert VT.weapon.ack in texts
|
||||
summary = next(x for x in r if x.text.startswith(VT.result.intro))
|
||||
assert VT.result.ptsd in summary.text and VT.result.mood in summary.text
|
||||
assert VT.result.alcohol not in summary.text # AUDIT-C 3 < 4 for men
|
||||
assert not re.search(r"\d", summary.text) # no scores shown to the patient
|
||||
assert r[-1].text == TEXTS.idle.welcome and "menu:checkin" in buttons(r[-1])
|
||||
|
||||
res = await results()
|
||||
assert {k: v.total for k, v in res.items()} == {"pcptsd5": 4, "pcl5": 46, "phq9": 14, "auditc": 3}
|
||||
assert res["phq9"].item9 == 0 and res["auditc"].severity == "negative"
|
||||
assert all(v.complete and v.scheduled for v in res.values())
|
||||
assert (await profile()).modules == ["veteran", "depression"]
|
||||
|
||||
facts = data["facts"]
|
||||
assert facts["trauma_exposure"] is True
|
||||
assert facts["weapon_access"] is False and facts["weapon_access_choice"] == "none"
|
||||
assert facts["weapon_safety_block"] is False and "weapon_access_at" in facts
|
||||
assert data["veteran_onboarding"].get("stage") is None and "done_at" in data["veteran_onboarding"]
|
||||
assert "crisis" not in data
|
||||
assert mon.calls >= 5 # every scale + weapon + result
|
||||
|
||||
|
||||
async def test_exposure_no_skips_pcptsd5(dbs: None, engine: Engine) -> None:
|
||||
await to_intro(engine)
|
||||
await press(engine, "vonb:go:yes")
|
||||
r = await press(engine, "vonb:exposure:no")
|
||||
assert (await get_state())[0] == "scale.pcl5" # veterans get PCL-5 regardless
|
||||
assert VT.exposure.ack_other in [x.text for x in r]
|
||||
r = await answer(engine, r, "pcl5", [0] * 20)
|
||||
r = await answer(engine, r, "phq9", [0] * 9)
|
||||
r = await answer(engine, r, "auditc", [0, 0, 0])
|
||||
r = await press(engine, "vonb:weapon:declined")
|
||||
state, data = await get_state()
|
||||
assert state == "idle"
|
||||
assert "pcptsd5" not in await results()
|
||||
assert data["facts"]["trauma_exposure"] is False
|
||||
assert data["facts"]["weapon_access"] is None and data["facts"]["weapon_access_choice"] == "declined"
|
||||
assert any(VT.result.calm in x.text for x in r)
|
||||
assert (await profile()).modules == ["veteran"] # PHQ-9 0 < 10 → no depression module
|
||||
|
||||
|
||||
async def test_exposure_declined_and_free_text_reasks(dbs: None, engine: Engine) -> None:
|
||||
await to_intro(engine)
|
||||
await press(engine, "vonb:go:yes")
|
||||
r = await text(engine, "ну такое")
|
||||
assert (await get_state())[0] == vo.EXPOSURE
|
||||
assert VT.buttons_hint in r[-1].text and "vonb:exposure:yes" in buttons(r[-1])
|
||||
await press(engine, "vonb:exposure:declined")
|
||||
state, data = await get_state()
|
||||
assert state == "scale.pcl5" and data["facts"]["trauma_exposure"] is None
|
||||
|
||||
|
||||
async def test_auditc_asks_sex_when_unknown(dbs_nosex: None, engine: Engine) -> None:
|
||||
await to_weapon(engine)
|
||||
assert (await get_state())[0] == vo.AUDITC
|
||||
r = await press(engine, "vonb:sex:f")
|
||||
assert (await get_state())[0] == "scale.auditc"
|
||||
assert (await profile()).sex == "f"
|
||||
await answer(engine, r, "auditc", AUDITC)
|
||||
res = await results()
|
||||
assert res["auditc"].severity == "positive" # 3 is positive for women
|
||||
|
||||
|
||||
async def test_auditc_sex_declined_uses_default_threshold(dbs_nosex: None, engine: Engine) -> None:
|
||||
await to_weapon(engine)
|
||||
r = await press(engine, "vonb:sex:declined")
|
||||
assert (await profile()).sex is None
|
||||
r = await answer(engine, r, "auditc", AUDITC)
|
||||
assert (await results())["auditc"].severity == "positive" # unknown sex → sensitive threshold ≥ 3
|
||||
# Weapon at home + AUDIT-C positive = risk signal → safe-storage block.
|
||||
r = await press(engine, "vonb:weapon:self")
|
||||
assert VT.weapon.safety_block_self in [x.text for x in r]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- weapon / safety
|
||||
|
||||
|
||||
async def test_weapon_without_risk_no_block(dbs: None, engine: Engine) -> None:
|
||||
r = await to_weapon(engine)
|
||||
r = await answer(engine, r, "auditc", AUDITC)
|
||||
r = await press(engine, "vonb:weapon:others_or_safe")
|
||||
texts = [x.text for x in r]
|
||||
assert VT.weapon.safety_block_stored not in texts and VT.weapon.safety_block_self not in texts
|
||||
_, data = await get_state()
|
||||
assert data["facts"]["weapon_access"] is True and data["facts"]["weapon_access_choice"] == "others_or_safe"
|
||||
assert data["facts"]["weapon_safety_block"] is False
|
||||
|
||||
|
||||
async def test_item9_positive_crisis_then_resume_and_safety_block(dbs: None, engine: Engine) -> None:
|
||||
r = await to_weapon(engine, phq9=[2, 2, 2, 2, 2, 2, 1, 1, 1])
|
||||
state, data = await get_state()
|
||||
# Engine crisis path: fixed script, C-SSRS queued and started while in crisis.
|
||||
assert data["crisis"]["open"] is True and data["crisis"]["source"] == "scale:phq9"
|
||||
assert any(x.kind == "crisis" for x in r)
|
||||
assert state == "scale.cssrs_screen"
|
||||
r = await answer(engine, r, "cssrs_screen", [0] * 6)
|
||||
assert (await get_state())[0] == "crisis"
|
||||
|
||||
# Doctor acknowledged → idle → the onboarding resumes where it stopped (PHQ-9 already stored → AUDIT-C).
|
||||
r = await engine.handle(TOKEN, Event(type="system", system="doctor_ack"))
|
||||
assert VT.intro.resume in [x.text for x in r]
|
||||
assert (await get_state())[0] == "scale.auditc"
|
||||
r = await answer(engine, r, "auditc", AUDITC)
|
||||
r = await press(engine, "vonb:weapon:self")
|
||||
state, data = await get_state()
|
||||
assert VT.weapon.safety_block_self in [x.text for x in r]
|
||||
assert data["facts"]["weapon_access"] is True and data["facts"]["weapon_safety_block"] is True
|
||||
assert state == "idle"
|
||||
assert (await results())["phq9"].item9 == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- FAQ
|
||||
|
||||
|
||||
async def test_faq_in_onboarding_and_idle(dbs: None, engine: Engine) -> None:
|
||||
await to_intro(engine)
|
||||
for key in vo.FAQ_KEYS:
|
||||
r = await press(engine, f"vonb:faq:{key}")
|
||||
assert r[0].text == getattr(VT.faq, key).text
|
||||
assert "vonb:go:yes" in buttons(r[-1]) # the intro is shown again
|
||||
assert (await get_state())[0] == vo.START
|
||||
await press(engine, "vonb:go:yes")
|
||||
r = await press(engine, "vonb:faq:unit")
|
||||
assert r[0].text == VT.faq.unit.text and "vonb:exposure:yes" in buttons(r[-1])
|
||||
|
||||
# FAQ buttons keep working after onboarding (idle triggers).
|
||||
r = await answer(engine, await press(engine, "vonb:exposure:no"), "pcl5", [0] * 20)
|
||||
r = await answer(engine, r, "phq9", [0] * 9)
|
||||
r = await answer(engine, r, "auditc", [0, 0, 0])
|
||||
await press(engine, "vonb:weapon:none")
|
||||
assert (await get_state())[0] == "idle"
|
||||
r = await press(engine, "vonb:faq:licences")
|
||||
assert r[0].text == VT.faq.licences.text and "menu:checkin" in buttons(r[0])
|
||||
|
||||
|
||||
def test_faq_texts_honest() -> None:
|
||||
assert "прямо предусмотренных законом" in VT.faq.unit.text
|
||||
assert "лечащий врач" in VT.faq.who.text
|
||||
raw = Path(vo.texts_path()).read_text(encoding="utf-8")
|
||||
assert raw.count("формулировку проверяет юрист") >= 3
|
||||
assert VT.status == "черновик, утверждает психиатр (#8)"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- texts scan
|
||||
|
||||
FORBIDDEN = ["диагноз", "расстройств", "психолог", "психиатр", "болезн", "псих"]
|
||||
FORBIDDEN_SERVICE = ["учёт", "учет", "контрол", "надзор"]
|
||||
|
||||
|
||||
def _strings(node: Any, path: str = "") -> list[tuple[str, str]]:
|
||||
if isinstance(node, dict):
|
||||
return [s for k, v in node.items() for s in _strings(v, f"{path}.{k}")]
|
||||
if isinstance(node, list):
|
||||
return [s for v in node for s in _strings(v, path)]
|
||||
return [(path, node)] if isinstance(node, str) else []
|
||||
|
||||
|
||||
def test_yaml_forbidden_words() -> None:
|
||||
data = yaml.safe_load(Path(vo.texts_path()).read_text(encoding="utf-8"))
|
||||
strings = [(p, s) for p, s in _strings(data) if p != ".status"]
|
||||
assert strings
|
||||
for path, value in strings:
|
||||
low = value.lower()
|
||||
for word in FORBIDDEN:
|
||||
assert word not in low, f"{path}: «{word}»"
|
||||
# Quoting the patient's own fear in a FAQ button label is allowed; statements about the service are not.
|
||||
if not (path.startswith(".faq.") and path.endswith(".button")):
|
||||
for word in FORBIDDEN_SERVICE:
|
||||
assert word not in low, f"{path}: «{word}»"
|
||||
Reference in New Issue
Block a user