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,458 @@
|
||||
"""Veteran start screening (issue #36, CLINICAL_MODULES §1, ADR-001/002/007/010).
|
||||
|
||||
Entered by the core flow after consent + age gate when the profile has module `veteran`:
|
||||
|
||||
veteran.onboarding.start intro «что нормально после возвращения» + FAQ ──«Начнём»──▶
|
||||
veteran.onboarding.exposure trauma exposure (yes / no / decline)
|
||||
yes ──▶ scale.pcptsd5 ──▶ ┐
|
||||
no / decline ───────────▶ ┤ (PC-PTSD-5 is not administered; nothing is stored for it)
|
||||
veteran.onboarding.pcl5 ─┘──▶ scale.pcl5
|
||||
veteran.onboarding.phq9 ──▶ scale.phq9 (item 9 buttons only; positive → engine crisis path)
|
||||
veteran.onboarding.auditc sex from profile, asked if unknown ──▶ scale.auditc
|
||||
veteran.onboarding.weapon weapon access (4 buttons) → facts; access + risk signal → safe-storage block
|
||||
veteran.onboarding.result plain-language summary, modules enabled in profile → finish_onboarding()
|
||||
|
||||
Clinical decisions encoded here:
|
||||
* PCL-5 is given to every veteran regardless of the exposure answer: the module's start set is PCL-5/PHQ-9/
|
||||
AUDIT-C (CLINICAL_MODULES §1); PC-PTSD-5 is only a short entry screen and, by its own instructions, is asked
|
||||
only after a "yes" to trauma exposure (`config/scales/pcptsd5.yaml: notes`).
|
||||
* Module `depression` is enabled when the PHQ-9 total meets `config/modules.yaml:
|
||||
veteran.enables_depression_if` (≥ 10). Scores and severity labels are never shown to the patient.
|
||||
* Safe-storage block (means restriction, one of the best-evidenced suicide-prevention measures) is shown when
|
||||
the patient reports access to a weapon AND there is a risk signal in this onboarding: a scale flag listed in
|
||||
`settings.weapon_risk_flags` (PHQ-9 item 9 ≥ 1, C-SSRS, AUDIT-C positive), a safety event (POSSIBLE/ACUTE)
|
||||
or a crisis opened since the onboarding began. Deterministic, no LLM (ADR-007).
|
||||
|
||||
Facts for lane B (`dialog_state.data["facts"]`, merged with whatever else is there):
|
||||
trauma_exposure True | False | None (declined)
|
||||
weapon_access True (self / others_or_safe) | False (none) | None (declined)
|
||||
weapon_access_choice "none" | "self" | "others_or_safe" | "declined"
|
||||
weapon_access_at ISO timestamp of the answer
|
||||
weapon_safety_block True when the safe-storage block was shown
|
||||
`PatientSnapshot.weapon_access` should be built from `facts["weapon_access"]`.
|
||||
|
||||
Crisis during onboarding: the engine moves to `crisis` and drops the running scale session. Progress is kept in
|
||||
`data["veteran_onboarding"]` (`stage`, `since_id`); when the FSM later enters `idle` (doctor acknowledged the
|
||||
crisis) the onboarding resumes at the stored stage, and scale stages whose result was already stored since the
|
||||
onboarding began are skipped (`since_id` = last scale_results id before the start).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.modules import load_modules
|
||||
from app.db import repo
|
||||
from app.db.clinical import ScaleResult
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.crisis import crisis_open
|
||||
from app.dialog.flows.core import finish_onboarding, menu_reply
|
||||
from app.dialog.fsm import Ctx, Step, on_enter, state, trigger
|
||||
from app.dialog.scale_session import start_scale
|
||||
|
||||
MODULE = "veteran"
|
||||
PREFIX = "veteran.onboarding"
|
||||
START = f"{PREFIX}.start"
|
||||
EXPOSURE = f"{PREFIX}.exposure"
|
||||
PCL5 = f"{PREFIX}.pcl5"
|
||||
PHQ9 = f"{PREFIX}.phq9"
|
||||
AUDITC = f"{PREFIX}.auditc"
|
||||
WEAPON = f"{PREFIX}.weapon"
|
||||
RESULT = f"{PREFIX}.result"
|
||||
|
||||
BTN = "vonb" # button data namespace: "vonb:<question>:<choice>"
|
||||
FAQ_KEYS = ("who", "unit", "licences")
|
||||
|
||||
# choice code → value of facts["weapon_access"]: storage with others still means the patient owns a weapon.
|
||||
WEAPON_ACCESS: dict[str, bool | None] = {"none": False, "self": True, "others_or_safe": True, "declined": None}
|
||||
EXPOSURE_VALUE: dict[str, bool | None] = {"yes": True, "no": False, "declined": None}
|
||||
SEX_VALUE: dict[str, str | None] = {"m": "m", "f": "f", "declined": None}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- texts
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class OnbSettings(_Strict):
|
||||
weapon_risk_flags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class IntroTexts(_Strict):
|
||||
text: str
|
||||
go: str
|
||||
repeat: str
|
||||
resume: str
|
||||
|
||||
|
||||
class ExposureTexts(_Strict):
|
||||
question: str
|
||||
answer_yes: str
|
||||
answer_no: str
|
||||
decline: str
|
||||
ack_yes: str
|
||||
ack_other: str
|
||||
|
||||
|
||||
class SexTexts(_Strict):
|
||||
question: str
|
||||
male: str
|
||||
female: str
|
||||
decline: str
|
||||
|
||||
|
||||
class WeaponTexts(_Strict):
|
||||
question: str
|
||||
no_access: str
|
||||
self: str
|
||||
others_or_safe: str
|
||||
decline: str
|
||||
ack: str
|
||||
safety_block_self: str
|
||||
safety_block_stored: str
|
||||
safety_block_unknown: str
|
||||
|
||||
|
||||
class ResultTexts(_Strict):
|
||||
intro: str
|
||||
calm: str
|
||||
load: str
|
||||
ptsd: str
|
||||
mood: str
|
||||
alcohol: str
|
||||
next: str
|
||||
|
||||
|
||||
class FaqEntry(_Strict):
|
||||
button: str
|
||||
text: str
|
||||
|
||||
|
||||
class FaqTexts(_Strict):
|
||||
who: FaqEntry
|
||||
unit: FaqEntry
|
||||
licences: FaqEntry
|
||||
|
||||
|
||||
class VeteranOnboardingTexts(_Strict):
|
||||
status: str
|
||||
settings: OnbSettings
|
||||
intro: IntroTexts
|
||||
buttons_hint: str
|
||||
exposure: ExposureTexts
|
||||
sex: SexTexts
|
||||
weapon: WeaponTexts
|
||||
result: ResultTexts
|
||||
faq: FaqTexts
|
||||
|
||||
|
||||
def texts_path() -> Path:
|
||||
return get_settings().config_dir / "texts" / "veteran_onboarding.yaml"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_texts(path: Path | None = None) -> VeteranOnboardingTexts:
|
||||
path = path or texts_path()
|
||||
return VeteranOnboardingTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def _progress(ctx: Ctx) -> dict[str, Any]:
|
||||
return dict(ctx.data.get("veteran_onboarding") or {})
|
||||
|
||||
|
||||
def _at_stage(ctx: Ctx, stage: str) -> dict[str, Any]:
|
||||
"""Data update remembering the current stage (resume point after a crisis)."""
|
||||
return {"veteran_onboarding": {**_progress(ctx), "stage": stage}}
|
||||
|
||||
|
||||
def _facts(ctx: Ctx, **updates: Any) -> dict[str, Any]:
|
||||
return {"facts": {**(ctx.data.get("facts") or {}), **updates}}
|
||||
|
||||
|
||||
def _faq_buttons() -> list[list[Button]]:
|
||||
faq = load_texts().faq
|
||||
return [[Button(label=getattr(faq, key).button, data=f"{BTN}:faq:{key}")] for key in FAQ_KEYS]
|
||||
|
||||
|
||||
def _choice(event: Event, question: str, allowed: dict[str, Any]) -> str | None:
|
||||
if event.type != "button" or not event.button:
|
||||
return None
|
||||
parts = event.button.split(":")
|
||||
if len(parts) == 3 and parts[0] == BTN and parts[1] == question and parts[2] in allowed:
|
||||
return parts[2]
|
||||
return None
|
||||
|
||||
|
||||
def _faq_key(event: Event) -> str | None:
|
||||
if event.type == "button" and event.button and event.button.startswith(f"{BTN}:faq:"):
|
||||
key = event.button.rsplit(":", 1)[1]
|
||||
return key if key in FAQ_KEYS else None
|
||||
return None
|
||||
|
||||
|
||||
async def _results_since(ctx: Ctx) -> list[ScaleResult]:
|
||||
since_id = int(_progress(ctx).get("since_id") or 0)
|
||||
return [r for r in await repo.list_scale_results(ctx.session, ctx.token) if r.id > since_id]
|
||||
|
||||
|
||||
async def _latest(ctx: Ctx, scale: str) -> ScaleResult | None:
|
||||
rows = [r for r in await _results_since(ctx) if r.scale == scale]
|
||||
return rows[-1] if rows else None
|
||||
|
||||
|
||||
async def _risk_signals(ctx: Ctx) -> list[str]:
|
||||
"""Deterministic risk signals collected during this onboarding (see module docstring)."""
|
||||
risk_flags = set(load_texts().settings.weapon_risk_flags)
|
||||
signals = sorted({f for r in await _results_since(ctx) for f in (r.flags or {}) if f in risk_flags})
|
||||
started = str(_progress(ctx).get("started_at") or "")
|
||||
events = [
|
||||
e
|
||||
for e in ctx.data.get("safety_events") or []
|
||||
if str(e.get("ts", "")) >= started and e.get("level") in ("possible", "acute")
|
||||
]
|
||||
if events:
|
||||
signals.append("safety_event")
|
||||
opened_at = str((ctx.data.get("crisis") or {}).get("opened_at") or "")
|
||||
if crisis_open(ctx.data) or (opened_at and opened_at >= started):
|
||||
signals.append("crisis")
|
||||
return signals
|
||||
|
||||
|
||||
async def _scale_stage(ctx: Ctx, stage: str, scale: str, next_stage: str) -> Step:
|
||||
"""Start `scale` unless its result is already stored in this onboarding (resume after a crisis)."""
|
||||
if await _latest(ctx, scale) is not None:
|
||||
return Step(next_state=next_stage, data=_at_stage(ctx, next_stage))
|
||||
step = await start_scale(ctx, scale, return_state=next_stage, scheduled=True)
|
||||
step.data.update(_at_stage(ctx, stage))
|
||||
return step
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- start
|
||||
|
||||
|
||||
def _intro_reply(text: str) -> Reply:
|
||||
go = Button(label=load_texts().intro.go, data=f"{BTN}:go:yes")
|
||||
return Reply(text=text, buttons=[[go], *_faq_buttons()], kind="question")
|
||||
|
||||
|
||||
@on_enter(START)
|
||||
async def _start_enter(ctx: Ctx) -> Step:
|
||||
rows = await repo.list_scale_results(ctx.session, ctx.token)
|
||||
progress = {
|
||||
"stage": START,
|
||||
"since_id": max((r.id for r in rows), default=0),
|
||||
"started_at": ctx.now.isoformat(),
|
||||
}
|
||||
return Step(replies=[_intro_reply(load_texts().intro.text)], data={"veteran_onboarding": progress})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- questions
|
||||
|
||||
|
||||
def _exposure_reply(note: str = "") -> Reply:
|
||||
t = load_texts().exposure
|
||||
buttons = [
|
||||
[Button(label=t.answer_yes, data=f"{BTN}:exposure:yes")],
|
||||
[Button(label=t.answer_no, data=f"{BTN}:exposure:no")],
|
||||
[Button(label=t.decline, data=f"{BTN}:exposure:declined")],
|
||||
]
|
||||
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
|
||||
|
||||
|
||||
def _sex_reply(note: str = "") -> Reply:
|
||||
t = load_texts().sex
|
||||
buttons = [
|
||||
[Button(label=t.male, data=f"{BTN}:sex:m")],
|
||||
[Button(label=t.female, data=f"{BTN}:sex:f")],
|
||||
[Button(label=t.decline, data=f"{BTN}:sex:declined")],
|
||||
]
|
||||
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
|
||||
|
||||
|
||||
def _weapon_reply(note: str = "") -> Reply:
|
||||
t = load_texts().weapon
|
||||
buttons = [
|
||||
[Button(label=t.no_access, data=f"{BTN}:weapon:none")],
|
||||
[Button(label=t.self, data=f"{BTN}:weapon:self")],
|
||||
[Button(label=t.others_or_safe, data=f"{BTN}:weapon:others_or_safe")],
|
||||
[Button(label=t.decline, data=f"{BTN}:weapon:declined")],
|
||||
]
|
||||
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
|
||||
|
||||
|
||||
def _current_question(ctx: Ctx, note: str = "") -> Reply:
|
||||
if ctx.state == EXPOSURE:
|
||||
return _exposure_reply(note)
|
||||
if ctx.state == AUDITC:
|
||||
return _sex_reply(note)
|
||||
if ctx.state == WEAPON:
|
||||
return _weapon_reply(note)
|
||||
t = load_texts().intro
|
||||
return _intro_reply("\n\n".join(p for p in (note, t.repeat) if p))
|
||||
|
||||
|
||||
@on_enter(EXPOSURE)
|
||||
async def _exposure_enter(ctx: Ctx) -> Step:
|
||||
return Step(replies=[_exposure_reply()], data=_at_stage(ctx, EXPOSURE))
|
||||
|
||||
|
||||
@on_enter(PCL5)
|
||||
async def _pcl5_enter(ctx: Ctx) -> Step:
|
||||
return await _scale_stage(ctx, PCL5, "pcl5", PHQ9)
|
||||
|
||||
|
||||
@on_enter(PHQ9)
|
||||
async def _phq9_enter(ctx: Ctx) -> Step:
|
||||
# Item 9 is buttons-only and a positive answer opens the crisis path inside the scale session (engine).
|
||||
return await _scale_stage(ctx, PHQ9, "phq9", AUDITC)
|
||||
|
||||
|
||||
@on_enter(AUDITC)
|
||||
async def _auditc_enter(ctx: Ctx) -> Step:
|
||||
if await _latest(ctx, "auditc") is not None:
|
||||
return Step(next_state=WEAPON, data=_at_stage(ctx, WEAPON))
|
||||
profile = await ctx.profile()
|
||||
known = profile is not None and profile.sex in ("m", "f")
|
||||
if known or _progress(ctx).get("sex_asked"):
|
||||
# Unknown sex after "не хочу отвечать": AUDIT-C uses the more sensitive default threshold.
|
||||
return await _scale_stage(ctx, AUDITC, "auditc", WEAPON)
|
||||
return Step(replies=[_sex_reply()], data=_at_stage(ctx, AUDITC))
|
||||
|
||||
|
||||
@on_enter(WEAPON)
|
||||
async def _weapon_enter(ctx: Ctx) -> Step:
|
||||
return Step(replies=[_weapon_reply()], data=_at_stage(ctx, WEAPON))
|
||||
|
||||
|
||||
@state(PREFIX)
|
||||
async def _handle(ctx: Ctx, event: Event) -> Step:
|
||||
"""Events in the onboarding's own question states (scales are handled by the scale session)."""
|
||||
t = load_texts()
|
||||
faq = _faq_key(event)
|
||||
if faq is not None:
|
||||
return Step(replies=[Reply(text=getattr(t.faq, faq).text), _current_question(ctx)])
|
||||
if event.type == "system":
|
||||
return Step() # reminders do not interrupt the screening
|
||||
|
||||
if ctx.state == START:
|
||||
if _choice(event, "go", {"yes": True}):
|
||||
return Step(next_state=EXPOSURE)
|
||||
return Step(replies=[_current_question(ctx)])
|
||||
|
||||
if ctx.state == EXPOSURE and (code := _choice(event, "exposure", EXPOSURE_VALUE)):
|
||||
exposed = EXPOSURE_VALUE[code]
|
||||
step = Step(data=_facts(ctx, trauma_exposure=exposed))
|
||||
if exposed:
|
||||
step.replies.append(Reply(text=t.exposure.ack_yes))
|
||||
nxt = await start_scale(ctx, "pcptsd5", return_state=PCL5, scheduled=True)
|
||||
nxt.data.update(_at_stage(ctx, EXPOSURE))
|
||||
return step.then(nxt)
|
||||
step.replies.append(Reply(text=t.exposure.ack_other))
|
||||
return step.then(Step(next_state=PCL5))
|
||||
|
||||
if ctx.state == AUDITC and (code := _choice(event, "sex", SEX_VALUE)):
|
||||
sex = SEX_VALUE[code]
|
||||
if sex is not None:
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, sex=sex)
|
||||
step = await _scale_stage(ctx, AUDITC, "auditc", WEAPON)
|
||||
step.data["veteran_onboarding"] = {**step.data["veteran_onboarding"], "sex_asked": True}
|
||||
return step
|
||||
|
||||
if ctx.state == WEAPON and (code := _choice(event, "weapon", WEAPON_ACCESS)):
|
||||
return await _weapon_answer(ctx, code)
|
||||
|
||||
return Step(replies=[_current_question(ctx, t.buttons_hint)])
|
||||
|
||||
|
||||
async def _weapon_answer(ctx: Ctx, code: str) -> Step:
|
||||
t = load_texts().weapon
|
||||
access = WEAPON_ACCESS[code]
|
||||
replies = [Reply(text=t.ack)]
|
||||
show_block = access is not False and bool(await _risk_signals(ctx))
|
||||
if show_block:
|
||||
block = {"self": t.safety_block_self, "others_or_safe": t.safety_block_stored}.get(code, t.safety_block_unknown)
|
||||
replies = [Reply(text=block, kind="safety_plan")]
|
||||
data = _facts(
|
||||
ctx,
|
||||
weapon_access=access,
|
||||
weapon_access_choice=code,
|
||||
weapon_access_at=ctx.now.isoformat(),
|
||||
weapon_safety_block=show_block,
|
||||
)
|
||||
# notify_monitoring: weapon access is a monitoring fact (lane B rule "доступ к оружию → блок безопасности").
|
||||
return Step(replies=replies, next_state=RESULT, data=data, notify_monitoring=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- result
|
||||
|
||||
|
||||
def _meets(total: int, op: str, value: int) -> bool:
|
||||
return total >= value if op == ">=" else total > value
|
||||
|
||||
|
||||
@on_enter(RESULT)
|
||||
async def _result_enter(ctx: Ctx) -> Step:
|
||||
t = load_texts().result
|
||||
pcl5 = await _latest(ctx, "pcl5")
|
||||
auditc = await _latest(ctx, "auditc")
|
||||
|
||||
# Depression module: PHQ-9 total vs. the threshold from config/modules.yaml (veteran.enables_depression_if).
|
||||
rule = load_modules().modules[MODULE].enables_depression_if
|
||||
trigger_result = await _latest(ctx, rule.scale) if rule is not None else None
|
||||
depression = bool(rule and trigger_result and _meets(trigger_result.total, rule.op, rule.value))
|
||||
profile = await ctx.profile()
|
||||
modules = list(profile.modules or []) if profile is not None else []
|
||||
for module in (MODULE, "depression") if depression else (MODULE,):
|
||||
if module not in modules:
|
||||
modules.append(module)
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, modules=modules)
|
||||
|
||||
lines = []
|
||||
if pcl5 is not None and "pcl5_probable_ptsd" in (pcl5.flags or {}):
|
||||
lines.append(t.ptsd)
|
||||
if depression:
|
||||
lines.append(t.mood)
|
||||
if auditc is not None and "auditc_positive" in (auditc.flags or {}):
|
||||
lines.append(t.alcohol)
|
||||
body = [t.intro, t.load, *lines] if lines else [t.intro, t.calm]
|
||||
summary = Reply(text="\n".join([*body, "", t.next]), buttons=_faq_buttons())
|
||||
done = Step(
|
||||
replies=[summary],
|
||||
data={"veteran_onboarding": {**_progress(ctx), "stage": None, "done_at": ctx.now.isoformat()}},
|
||||
notify_monitoring=True,
|
||||
)
|
||||
return done.then(await finish_onboarding(ctx, module=MODULE))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- resume / FAQ
|
||||
|
||||
|
||||
@on_enter("idle")
|
||||
async def _resume_after_crisis(ctx: Ctx) -> Step:
|
||||
"""Back in idle with an unfinished veteran onboarding (it was interrupted by a crisis) → continue it."""
|
||||
stage = _progress(ctx).get("stage")
|
||||
if not stage or not str(stage).startswith(PREFIX) or crisis_open(ctx.data) or ctx.data.get("consent") is not True:
|
||||
return Step()
|
||||
stage = EXPOSURE if stage == START else str(stage)
|
||||
return Step(replies=[Reply(text=load_texts().intro.resume)], next_state=stage)
|
||||
|
||||
|
||||
def _faq_trigger(key: str) -> None:
|
||||
@trigger(f"button:{BTN}:faq:{key}")
|
||||
async def _faq(ctx: Ctx, event: Event) -> Step:
|
||||
return Step(replies=[menu_reply(ctx, getattr(load_texts().faq, key).text)])
|
||||
|
||||
|
||||
for _key in FAQ_KEYS:
|
||||
_faq_trigger(_key)
|
||||
Reference in New Issue
Block a user