"""Core flow shared by all modules: connect → consent → age gate → module onboarding → idle; crisis; revocation. new ──start(code)──▶ consent ──«Согласен»──▶ age_gate ──adult──▶ .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 # Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous # person has no profile at all before it. if after is None and intake_enabled(): 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