"""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::" 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)