Files
PTSRaiAgent/tests/test_dialog_veteran_onboarding.py
T
ElebobaandClaude Opus 5.5 531bef1e73
CI / ruff · pytest (pull_request) Successful in 2m33s
feat: crisis message after the test and non-blocking; weapon answer triggers nothing; ЕМИАС demo = Никита
- CRISIS_BLOCKING (default off for the demo): a crisis no longer locks the dialog; the message and the
  doctor's acute alert still happen. The clinical protocol (blocking, immediate stop) stays behind the flag and
  keeps its tests.
- Crisis signals inside a questionnaire (intake red flags, high C-SSRS, PHQ-9 item 9) no longer stop it: the
  test is finished and the crisis message comes at the end; the doctor's alert is raised at once/at the end.
- Weapon access: asked and stored, triggers nothing (rule disabled, no red flag, no safety block).
- ЕМИАС demo persona is «Никита В.»; the one-click demo stays «Андрей С.».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:30:05 +03:00

369 lines
15 KiB
Python

"""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 not in [x.text for x in r] # the weapon answer affects nothing
# ----------------------------------------------------------------------------------------------- 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}»"