CI / ruff · pytest (pull_request) Successful in 55s
Add gated TSQ, Russian C-SSRS Recent, durable responses and notifications, diaries, schedules and audited clinical review. Preserve manual assessment when the Russian follow-up source is unavailable. Refs #52
353 lines
15 KiB
Python
353 lines
15 KiB
Python
"""Core flow shared by all modules: connect → consent → age gate → module onboarding → idle; crisis; revocation.
|
|
|
|
new ──start(code)──▶ consent ──«Согласен»──▶ age_gate ──adult──▶ <module>.onboarding.start … ──▶ idle
|
|
│ └──< 18──▶ minor (stop, child helpline)
|
|
└──«Не согласен»──▶ refused (stop, /start to reconsider)
|
|
any consented state ──«Отозвать согласие»──▶ consent_revoked (silent; /start → consent again)
|
|
any ──ACUTE / item 9 / C-SSRS high──▶ crisis ──doctor_ack──▶ idle
|
|
|
|
Consent and age are the registration step, so this module (and only it) touches the identity DB, through
|
|
`app.db.identity_repo`, and only for consent / revocation / birth year (guardrail #5).
|
|
"""
|
|
|
|
import re
|
|
|
|
from app.db import identity_repo, repo
|
|
from app.db.session import get_identity_session
|
|
from app.dialog.actions import safety_plan_reply
|
|
from app.dialog.contracts import Button, Event, Reply
|
|
from app.dialog.fsm import Ctx, Step, get_trigger, is_registered, on_enter, state, trigger
|
|
from app.integrations.base import integrations
|
|
from app.services.contracts import NoopMonitoring
|
|
|
|
ADULT_AGE = 18
|
|
_YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b")
|
|
_AGE_RE = re.compile(r"^\s*(\d{1,3})\s*(лет|год|года)?\s*$")
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------- replies
|
|
|
|
|
|
def consent_reply(ctx: Ctx) -> Reply:
|
|
t = ctx.texts.consent
|
|
buttons = [[Button(label=t.accept, data="consent:yes")], [Button(label=t.decline, data="consent:no")]]
|
|
return Reply(text=t.text, buttons=buttons, kind="question")
|
|
|
|
|
|
def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
|
|
b = ctx.texts.idle.buttons
|
|
buttons = [
|
|
[Button(label=b.checkin, data="menu:checkin")],
|
|
[Button(label=b.med_question, data="menu:med_question")],
|
|
[Button(label=b.contact_doctor, data="menu:contact_doctor")],
|
|
[Button(label=b.revoke, data="consent:revoke")],
|
|
]
|
|
from app.core.intake_questions import texts
|
|
from app.core.intake_runtime import intake_enabled
|
|
|
|
if intake_enabled():
|
|
buttons.insert(0, [Button(label=texts()["help"], data="intake:help")])
|
|
buttons.insert(
|
|
0,
|
|
[
|
|
Button(
|
|
label=texts()["resume"] if ctx.data.get("questionnaire_record") else texts()["start"],
|
|
data="intake:resume" if ctx.data.get("questionnaire_record") else "intake:start",
|
|
)
|
|
],
|
|
)
|
|
return Reply(text=text or ctx.texts.idle.menu_prompt, buttons=buttons)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------- onboarding API
|
|
|
|
|
|
async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
|
|
"""Entry state of the next module onboarding: `"{module}.onboarding.start"` for the first module of the
|
|
profile (after `after`, if given) that has such a state registered; `"idle"` when none is left."""
|
|
profile = await ctx.profile()
|
|
modules = list(profile.modules) if profile is not None and profile.modules else []
|
|
from app.core.intake_runtime import intake_enabled
|
|
|
|
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
|
|
return "intake.start"
|
|
if after in modules:
|
|
modules = modules[modules.index(after) + 1 :]
|
|
for module in modules:
|
|
if is_registered(f"{module}.onboarding.start"):
|
|
return f"{module}.onboarding.start"
|
|
return "idle"
|
|
|
|
|
|
async def finish_onboarding(ctx: Ctx, module: str | None = None) -> Step:
|
|
"""A module flow calls this when its onboarding is done: next module's onboarding, or idle + welcome."""
|
|
nxt = await onboarding_entry(ctx, after=module)
|
|
if nxt == "idle":
|
|
return Step(replies=[menu_reply(ctx, ctx.texts.idle.welcome)], next_state="idle")
|
|
return Step(next_state=nxt)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------- new / consent
|
|
|
|
|
|
@state("new")
|
|
async def _new(ctx: Ctx, event: Event) -> Step:
|
|
code = event.params.get("code") if event.type == "start" else None
|
|
if code:
|
|
# Telegram deep link carries the doctor's one-time connect code; the web console passes the token
|
|
# directly (no code) — then the token is already known to the system.
|
|
person = await integrations.identity.verify(str(code))
|
|
if person is None or person.token != ctx.token:
|
|
return Step(replies=[Reply(text=ctx.texts.consent.code_invalid)])
|
|
if await ctx.profile() is None:
|
|
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id=person.doctor_id)
|
|
return Step(replies=[consent_reply(ctx)], next_state="consent")
|
|
|
|
|
|
@state("consent")
|
|
async def _consent(ctx: Ctx, event: Event) -> Step:
|
|
if event.type == "button" and event.button == "consent:yes":
|
|
version = ctx.texts.consent.version
|
|
async with get_identity_session() as s:
|
|
# None when the token has no identity record (web demo console) — the dialog state keeps the fact.
|
|
await identity_repo.record_consent(s, token=ctx.token, version=version, now=ctx.now)
|
|
data = {"consent": True, "consent_ver": version, "consent_at": ctx.now.isoformat(), "revoked_at": None}
|
|
return Step(next_state="age_gate", data=data)
|
|
if event.type == "button" and event.button == "consent:no":
|
|
return _refused_step(ctx)
|
|
# Anything else (incl. free text) is not processed before consent: show the consent text again.
|
|
return Step(replies=[consent_reply(ctx)])
|
|
|
|
|
|
def _refused_step(ctx: Ctx) -> Step:
|
|
button = Button(label=ctx.texts.consent.reconsider, data="consent:restart")
|
|
return Step(replies=[Reply(text=ctx.texts.consent.refused, buttons=[[button]])], next_state="refused")
|
|
|
|
|
|
@state("refused")
|
|
async def _refused(ctx: Ctx, event: Event) -> Step:
|
|
if event.type == "start" or (event.type == "button" and event.button == "consent:restart"):
|
|
return Step(replies=[consent_reply(ctx)], next_state="consent")
|
|
if event.type == "system":
|
|
return Step()
|
|
return _refused_step(ctx)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------- age gate
|
|
|
|
|
|
def _age_verdict(birth_year: int, current_year: int) -> str:
|
|
"""Without the birthday the age is `current - birth_year` or one less; ask when the two disagree on 18."""
|
|
oldest, youngest = current_year - birth_year, current_year - birth_year - 1
|
|
if youngest >= ADULT_AGE:
|
|
return "adult"
|
|
if oldest < ADULT_AGE:
|
|
return "minor"
|
|
return "unsure"
|
|
|
|
|
|
def _age_question(ctx: Ctx, note: str = "") -> Reply:
|
|
t = ctx.texts.age_gate
|
|
buttons = [[Button(label=t.adult, data="age:adult")], [Button(label=t.minor, data="age:minor")]]
|
|
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
|
|
|
|
|
|
@on_enter("age_gate")
|
|
async def _age_enter(ctx: Ctx) -> Step:
|
|
profile = await ctx.profile()
|
|
birth_year = profile.birth_year if profile is not None else None
|
|
if birth_year is None:
|
|
async with get_identity_session() as s:
|
|
patient = await identity_repo.get_patient(s, ctx.token)
|
|
birth_year = patient.birth_year if patient is not None else None
|
|
if birth_year is not None:
|
|
verdict = _age_verdict(birth_year, ctx.now.year)
|
|
if verdict == "adult":
|
|
return await _adult(ctx)
|
|
if verdict == "minor":
|
|
return await _minor(ctx)
|
|
return Step(replies=[_age_question(ctx)])
|
|
|
|
|
|
@state("age_gate")
|
|
async def _age_gate(ctx: Ctx, event: Event) -> Step:
|
|
if event.type == "button" and event.button == "age:adult":
|
|
return await _adult(ctx)
|
|
if event.type == "button" and event.button == "age:minor":
|
|
return await _minor(ctx)
|
|
if event.type == "text" and ctx.text:
|
|
year = _YEAR_RE.search(ctx.text)
|
|
age_match = _AGE_RE.match(ctx.text)
|
|
if year:
|
|
birth_year = int(year.group(1))
|
|
verdict = _age_verdict(birth_year, ctx.now.year)
|
|
if verdict == "adult":
|
|
return await _adult(ctx, birth_year)
|
|
if verdict == "minor":
|
|
return await _minor(ctx)
|
|
elif age_match:
|
|
age = int(age_match.group(1))
|
|
return await (_adult(ctx) if age >= ADULT_AGE else _minor(ctx))
|
|
return Step(replies=[_age_question(ctx, ctx.texts.age_gate.unclear)])
|
|
return Step(replies=[_age_question(ctx)])
|
|
|
|
|
|
async def _adult(ctx: Ctx, birth_year: int | None = None) -> Step:
|
|
if birth_year is not None and await ctx.profile() is not None:
|
|
await repo.upsert_profile(ctx.session, token=ctx.token, birth_year=birth_year)
|
|
return Step(data={"adult": True}).then(await finish_onboarding(ctx))
|
|
|
|
|
|
async def _minor(ctx: Ctx) -> Step:
|
|
# A minor cannot give this consent on their own: withdraw it and stop processing.
|
|
async with get_identity_session() as s:
|
|
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now)
|
|
return Step(
|
|
replies=[Reply(text=ctx.texts.age_gate.minor_stop, kind="info")],
|
|
next_state="minor",
|
|
data={"consent": False, "minor": True},
|
|
)
|
|
|
|
|
|
@state("minor")
|
|
async def _minor_state(ctx: Ctx, event: Event) -> Step:
|
|
if event.type == "system":
|
|
return Step()
|
|
return Step(replies=[Reply(text=ctx.texts.age_gate.minor_stop, kind="info")])
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------- idle
|
|
|
|
|
|
@state("idle")
|
|
async def _idle(ctx: Ctx, event: Event) -> Step:
|
|
if event.type == "start":
|
|
return Step(replies=[menu_reply(ctx)])
|
|
if event.type == "button" and event.button:
|
|
handler = get_trigger(f"button:{event.button}")
|
|
return await handler(ctx, event) if handler else Step(replies=[menu_reply(ctx)])
|
|
if event.type == "system" and event.system:
|
|
handler = get_trigger(f"system:{event.system}")
|
|
return await handler(ctx, event) if handler else Step()
|
|
if event.type == "text":
|
|
handler = get_trigger("text:idle")
|
|
if handler:
|
|
return await handler(ctx, event)
|
|
# Neutral empathic reply; the LLM only rephrases the approved template (and is filtered).
|
|
return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))])
|
|
return Step()
|
|
|
|
|
|
# Default idle triggers — flows (#15, #36, #37) override them with `@trigger(..., replace=True)`.
|
|
|
|
|
|
@trigger("button:menu:checkin")
|
|
async def _menu_checkin(ctx: Ctx, event: Event) -> Step:
|
|
return Step(replies=[Reply(text=ctx.texts.idle.stub_checkin)])
|
|
|
|
|
|
@trigger("button:menu:med_question")
|
|
async def _menu_med_question(ctx: Ctx, event: Event) -> Step:
|
|
return Step(replies=[Reply(text=ctx.texts.idle.stub_med_question)])
|
|
|
|
|
|
@trigger("button:menu:contact_doctor")
|
|
async def _menu_contact_doctor(ctx: Ctx, event: Event) -> Step:
|
|
# A fact for lane B (#38): the patient asked the doctor to get in touch. No alert is created here (ADR-007).
|
|
requests = [*(ctx.data.get("contact_requests") or []), {"ts": ctx.now.isoformat()}][-20:]
|
|
return Step(
|
|
replies=[Reply(text=ctx.texts.idle.contact_doctor_ack)],
|
|
data={"contact_requests": requests},
|
|
notify_monitoring=True,
|
|
)
|
|
|
|
|
|
@trigger("button:menu:visit")
|
|
async def _menu_visit(ctx: Ctx, event: Event) -> Step:
|
|
return Step(replies=[Reply(text=ctx.texts.idle.visit_stub)])
|
|
|
|
|
|
@trigger("button:safety_plan")
|
|
async def _show_plan(ctx: Ctx, event: Event) -> Step:
|
|
return Step(replies=[await safety_plan_reply(ctx)])
|
|
|
|
|
|
@trigger("system:med_reminder")
|
|
async def _med_reminder(ctx: Ctx, event: Event) -> Step:
|
|
drug = str(event.params.get("drug", ""))
|
|
return Step(replies=[Reply(text=await ctx.gateway.phrase("med_reminder", {"drug": drug}))])
|
|
|
|
|
|
@trigger("system:daily_checkin")
|
|
async def _daily_checkin(ctx: Ctx, event: Event) -> Step:
|
|
return Step(replies=[menu_reply(ctx, ctx.texts.idle.stub_checkin)])
|
|
|
|
|
|
@trigger("system:reach_out")
|
|
async def _reach_out(ctx: Ctx, event: Event) -> Step:
|
|
return Step(replies=[Reply(text=ctx.texts.reach_out.default)])
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------- crisis
|
|
|
|
|
|
def close_crisis(ctx: Ctx) -> Step:
|
|
"""Doctor acknowledged the crisis: clear the flag; a C-SSRS still running returns to idle afterwards."""
|
|
if not (ctx.data.get("crisis") or {}).get("open") and ctx.state != "crisis":
|
|
return Step()
|
|
crisis = {**(ctx.data.get("crisis") or {}), "open": False, "closed_at": ctx.now.isoformat()}
|
|
scales = {
|
|
sid: {**s, "return_state": "idle"} if s.get("return_state") == "crisis" else s
|
|
for sid, s in (ctx.data.get("scales") or {}).items()
|
|
}
|
|
step = Step(data={"crisis": crisis, "scales": scales})
|
|
if ctx.state == "crisis":
|
|
step.next_state = "idle"
|
|
step.replies.append(Reply(text=ctx.texts.crisis.closed))
|
|
return step
|
|
|
|
|
|
@state("crisis")
|
|
async def _crisis(ctx: Ctx, event: Event) -> Step:
|
|
# The NoopMonitoring stub knows nothing about crises and always says "not in crisis"; trusting it would
|
|
# end every crisis on the next message. Until lane B (#38) lands only `doctor_ack` ends a crisis.
|
|
if not isinstance(ctx.monitoring, NoopMonitoring) and not await ctx.monitoring.is_in_crisis(ctx.token):
|
|
return close_crisis(ctx)
|
|
if event.type == "system":
|
|
return Step() # reminders and check-ins wait until the crisis is over
|
|
if event.type == "button" and event.button == "safety_plan":
|
|
return Step(replies=[await safety_plan_reply(ctx)])
|
|
plan = Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")
|
|
return Step(replies=[Reply(text=ctx.texts.crisis.hold, buttons=[[plan]], kind="crisis")])
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------- revocation
|
|
|
|
|
|
async def revoke(ctx: Ctx) -> Step:
|
|
"""Withdraw consent in one step (152-FZ art. 9 p. 2): identity DB + dialog state; the bot goes silent.
|
|
|
|
Clinical data are marked for deletion (`data["delete_requested"]`) — erasure itself is lane B's job.
|
|
"""
|
|
async with get_identity_session() as s:
|
|
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now)
|
|
from app.services.questionnaire import cancel_pending
|
|
|
|
await cancel_pending(ctx.session, ctx.token)
|
|
data = {
|
|
"questionnaire_record": None,
|
|
"consent": False,
|
|
"revoked_at": ctx.now.isoformat(),
|
|
"delete_requested": True,
|
|
"scales": None,
|
|
"pending_scales": None,
|
|
}
|
|
return Step(replies=[Reply(text=ctx.texts.revoked.confirm)], next_state="consent_revoked", data=data)
|
|
|
|
|
|
@state("consent_revoked")
|
|
async def _revoked(ctx: Ctx, event: Event) -> Step:
|
|
if event.type == "start":
|
|
return Step(replies=[consent_reply(ctx)], next_state="consent")
|
|
return Step() # silent: nothing is processed after revocation
|