feat(questionnaire): integrate revised intake and follow-up monitoring
CI / ruff · pytest (pull_request) Successful in 55s
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
This commit is contained in:
@@ -4,7 +4,7 @@ Decisions:
|
||||
* The crisis script (`app.core.safety.crisis_reply`, `config/texts/crisis.yaml`) is sent verbatim, once per
|
||||
open crisis. "Open" = `data["crisis"]["open"]` — set when the crisis starts, cleared only by the doctor's
|
||||
acknowledgement (`doctor_ack`), so a patient repeating the phrase does not get the same long script again:
|
||||
they get a short fixed "я рядом, врач уже уведомлён" + emergency resources instead.
|
||||
they get a short fixed support message and emergency resources instead.
|
||||
* Entering the crisis drops unfinished scale sessions (their answered items are already stored item by item):
|
||||
nothing else is asked while the patient is in crisis, except the C-SSRS screen.
|
||||
* Without valid consent (before consent, after refusal/revocation, minors) we still answer a crisis message —
|
||||
|
||||
+54
-3
@@ -22,15 +22,18 @@ Pipeline for one incoming event (SPEC §3, guardrails #1, #2, #3):
|
||||
Logs carry only the token, states and event codes (guardrail #8).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import clock, safety
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
from app.core.privacy import scrub
|
||||
from app.core.safety import SafetyResult, crisis_reply
|
||||
from app.core.types import RiskLevel, ScrubbedText
|
||||
@@ -45,12 +48,14 @@ from app.dialog.flows.core import close_crisis, revoke
|
||||
from app.dialog.fsm import NO_CONSENT_STATES, RESTING_STATES, Ctx, Step, apply_data, enter_hook_for, handler_for
|
||||
from app.dialog.texts import DialogTexts, load_dialog_texts
|
||||
from app.llm.gateway import LLMGateway, get_gateway
|
||||
from app.services import questionnaire as intake_service
|
||||
from app.services.contracts import MonitoringService, get_monitoring
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
|
||||
MAX_ENTER_CHAIN = 8
|
||||
_patient_locks = WeakValueDictionary()
|
||||
# DB unreachable: answer safely instead of failing the channel (connectivity errors only — bugs must surface).
|
||||
_DB_DOWN = (OSError, OperationalError, InterfaceError)
|
||||
|
||||
@@ -76,8 +81,13 @@ class Engine:
|
||||
async def handle(self, token: str, event: Event) -> list[Reply]:
|
||||
texts = load_dialog_texts()
|
||||
try:
|
||||
async with get_clinical_session() as session:
|
||||
return await self._handle(token, event, session, texts)
|
||||
lock = _patient_locks.setdefault(token, asyncio.Lock())
|
||||
async with lock:
|
||||
async with get_clinical_session() as session:
|
||||
await intake_service.lock_patient(session, token)
|
||||
replies = await self._handle(token, event, session, texts)
|
||||
await intake_service.dispatch_notices(token)
|
||||
return replies
|
||||
except _DB_DOWN as exc:
|
||||
log.error("dialog db unavailable token=%s event=%s error=%s", token, event.type, type(exc).__name__)
|
||||
return _degraded(event, texts)
|
||||
@@ -117,6 +127,13 @@ class Engine:
|
||||
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
|
||||
)
|
||||
await self._save_state(ctx)
|
||||
task_id = event.params.get("questionnaire_task")
|
||||
if event.type == "system" and task_id and run.replies:
|
||||
from app.db.intake import QuestionnaireTask
|
||||
|
||||
task = await session.get(QuestionnaireTask, task_id)
|
||||
if task and task.token == token:
|
||||
task.delivery = [r.model_dump() for r in run.replies]
|
||||
log.info(
|
||||
"dialog token=%s event=%s code=%s state=%s->%s replies=%d",
|
||||
token,
|
||||
@@ -143,6 +160,12 @@ class Engine:
|
||||
if event.type == "button":
|
||||
if consented and event.button == "consent:revoke":
|
||||
return await revoke(ctx)
|
||||
if consented and event.button == "intake:help":
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.dialog.flows.intake import urgent
|
||||
|
||||
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
|
||||
return await urgent(ctx, row)
|
||||
if consented:
|
||||
await self._store_in(ctx, scrub(f"[button] {event.button}")) # button data comes from the client
|
||||
return await self._dispatch(ctx, event)
|
||||
@@ -153,6 +176,14 @@ class Engine:
|
||||
if not consented:
|
||||
return Step() # nothing is pushed to people without consent
|
||||
if event.system == "scale_due" and event.params.get("scale"):
|
||||
if intake_enabled() and ctx.data.get("adult") is True:
|
||||
scale = str(event.params["scale"])
|
||||
kind = "cssrs_since_contact" if scale in ("cssrs_screen", "cssrs") else scale
|
||||
if kind in ("phq9", "pcl5", "rus_audit_s", "cssrs_since_contact"):
|
||||
await intake_service.queue_task(
|
||||
ctx.session, ctx.token, kind, f"external:{kind}:{ctx.now.date()}", ctx.now
|
||||
)
|
||||
return Step()
|
||||
scheduled = bool(event.params.get("scheduled", True))
|
||||
pending = scale_session.add_pending(ctx.data, str(event.params["scale"]), scheduled)
|
||||
return Step(data={"pending_scales": pending}) # starts now if resting, else after the current step
|
||||
@@ -181,11 +212,29 @@ class Engine:
|
||||
if result.level == RiskLevel.ACUTE:
|
||||
handled = "crisis_repeat" if crisis_open(ctx.data) else "crisis_script"
|
||||
log.warning("safety acute token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled)
|
||||
step = open_crisis(ctx, result.categories, source=f"safety:{result.source}")
|
||||
if ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True):
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.dialog.flows.intake import urgent
|
||||
|
||||
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
|
||||
step = await urgent(ctx, row, code="safety_acute", categories=result.categories)
|
||||
else:
|
||||
step = open_crisis(ctx, result.categories, source=f"safety:{result.source}")
|
||||
step.data["safety_events"] = self._safety_events(ctx, result, handled)
|
||||
step.notify_monitoring = True # lane B escalates to the doctor on call
|
||||
return step
|
||||
|
||||
if result.level == RiskLevel.POSSIBLE and (
|
||||
ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True)
|
||||
):
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.dialog.flows.intake import manual_review
|
||||
|
||||
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
|
||||
if row is None:
|
||||
row = await intake_service.create_record(ctx, "cssrs_since_contact")
|
||||
return await manual_review(ctx, row, "cssrs_since_contact_required")
|
||||
|
||||
if result.level == RiskLevel.POSSIBLE:
|
||||
screen = ctx.texts.settings.risk_screen_scale
|
||||
if self._can_screen(ctx, screen):
|
||||
@@ -252,6 +301,8 @@ class Engine:
|
||||
|
||||
async def _start_pending(self, ctx: Ctx) -> Step:
|
||||
"""Start a queued scale when nothing else is being asked. In crisis only the risk screen may run."""
|
||||
if intake_enabled():
|
||||
return Step()
|
||||
pending = list(ctx.data.get("pending_scales") or [])
|
||||
if not pending or ctx.state not in RESTING_STATES:
|
||||
return Step()
|
||||
|
||||
@@ -42,6 +42,20 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
|
||||
[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)
|
||||
|
||||
|
||||
@@ -53,6 +67,10 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
|
||||
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:
|
||||
@@ -275,6 +293,8 @@ async def _reach_out(ctx: Ctx, event: Event) -> Step:
|
||||
|
||||
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
|
||||
@@ -311,7 +331,11 @@ async def revoke(ctx: Ctx) -> Step:
|
||||
"""
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Versioned, resumable questionnaires. Disabled until the clinical release gate opens."""
|
||||
|
||||
import math
|
||||
from dataclasses import asdict, replace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.core.intake_questions import (
|
||||
content,
|
||||
cssrs_answers,
|
||||
diary_questions,
|
||||
group_answers,
|
||||
next_intake,
|
||||
scale_question,
|
||||
texts,
|
||||
)
|
||||
from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
|
||||
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
|
||||
from app.core.scales import load_scale
|
||||
from app.core.types import AlertLevel
|
||||
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.crisis import crisis_open, open_crisis
|
||||
from app.dialog.fsm import Step, on_enter, state, trigger
|
||||
from app.services import questionnaire as service
|
||||
|
||||
|
||||
def allowed(ctx):
|
||||
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
|
||||
|
||||
|
||||
def next_question(row):
|
||||
a = row.answers
|
||||
if row.kind == "intake":
|
||||
return next_intake(a, load_policy(), row.details.get("sex"))
|
||||
if row.kind == "diary":
|
||||
for q in diary_questions(
|
||||
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday()
|
||||
):
|
||||
if q.id in a:
|
||||
continue
|
||||
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"):
|
||||
continue
|
||||
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
|
||||
continue
|
||||
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
|
||||
continue
|
||||
return q
|
||||
return None
|
||||
if row.kind in ("phq9", "pcl5", "rus_audit_s"):
|
||||
sid = row.kind
|
||||
if sid == "rus_audit_s":
|
||||
short = score_alcohol(
|
||||
{k: a[k] for k in load_scale(sid).item_ids if k in a}, short=True, sex=row.details.get("sex")
|
||||
)
|
||||
if alcohol_expansion(short, load_policy()):
|
||||
sid = "rus_audit"
|
||||
for item in load_scale(sid).item_ids:
|
||||
if item == "rus_audit_2" and a.get("rus_audit_1") == 0:
|
||||
continue
|
||||
if item not in a:
|
||||
q = scale_question(sid, item)
|
||||
if sid == "pcl5" and row.details["weekly_pcl"]:
|
||||
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
|
||||
return q
|
||||
if row.details.get("medication_active") and "side_effects" not in a:
|
||||
return next(
|
||||
q
|
||||
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
|
||||
if q.id == "side_effects"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def question_reply(row, q):
|
||||
t = texts()
|
||||
# Nonce + item + option index rejects old/replayed callbacks without exposing raw answers.
|
||||
prefix = f"iq:{row.id[:12]}:{q.id}:"
|
||||
buttons = [[Button(label=label, data=prefix + str(i))] for i, (_, label) in enumerate(q.options)]
|
||||
if q.optional:
|
||||
buttons.append([Button(label=t["comment_skip"], data=prefix + "skip")])
|
||||
buttons.extend([[Button(label=t["help"], data="intake:help")], [Button(label=t["pause"], data="intake:pause")]])
|
||||
return Reply(text=q.text + "\n\n" + t["as_of"].format(day=row.day.isoformat()), buttons=buttons, kind="question")
|
||||
|
||||
|
||||
async def urgent(ctx, row=None, *, code="help_now", categories=None):
|
||||
if crisis_open(ctx.data):
|
||||
return open_crisis(ctx, categories or [], source="questionnaire:" + code)
|
||||
profile = await ctx.profile()
|
||||
if profile:
|
||||
profile.enhanced_monitoring = True
|
||||
flags = list(service.decision(row).flags) if row else []
|
||||
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0):
|
||||
flags.append("cssrs_since_contact_required")
|
||||
if row:
|
||||
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
ctx.token,
|
||||
f"{row.id if row else ctx.now.isoformat()}:{code}",
|
||||
code,
|
||||
ctx.now,
|
||||
level=AlertLevel.ACUTE,
|
||||
params={"record_id": row.id if row else None, "flags": flags},
|
||||
)
|
||||
step = open_crisis(ctx, categories or [], source="questionnaire:" + code)
|
||||
step.data["questionnaire_record"] = None
|
||||
step.data["intake_review_required"] = True
|
||||
for reply in step.replies:
|
||||
reply.buttons.append([Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")])
|
||||
return step
|
||||
|
||||
|
||||
async def manual_review(ctx, row, code):
|
||||
await service.finish(ctx, row, "review_required", extra={"stop_reason": code})
|
||||
await service.alert(
|
||||
ctx.session, ctx.token, f"{row.id}:{code}", code, ctx.now, level=AlertLevel.HIGH, params={"record_id": row.id}
|
||||
)
|
||||
return Step(
|
||||
replies=[
|
||||
Reply(
|
||||
text=texts()["missing_followup"] if code == "cssrs_since_contact_required" else texts()["review"],
|
||||
buttons=[[Button(label=texts()["help"], data="intake:help")]],
|
||||
kind="info",
|
||||
)
|
||||
],
|
||||
next_state="idle",
|
||||
data={"questionnaire_record": None, "intake_review_required": True},
|
||||
)
|
||||
|
||||
|
||||
async def advance(ctx, row):
|
||||
a = row.answers
|
||||
c = score_cssrs_recent(cssrs_answers(a))
|
||||
policy = load_policy()
|
||||
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high":
|
||||
return await urgent(ctx, row, code="suicide_signal")
|
||||
if c.positive and policy.cssrs_positive_policy == "stop_any":
|
||||
return await urgent(ctx, row, code="cssrs_positive")
|
||||
if c.conflicts:
|
||||
return await manual_review(ctx, row, "cssrs_conflict")
|
||||
# Finish the risk questions before any routine question; a moderate result stops here.
|
||||
if c.complete and c.risk == "moderate":
|
||||
return await manual_review(ctx, row, "urgent_psychiatrist")
|
||||
if c.complete and c.risk == "low":
|
||||
profile = await ctx.profile()
|
||||
if profile:
|
||||
profile.enhanced_monitoring = True
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
ctx.token,
|
||||
row.id + ":cssrs_low",
|
||||
"cssrs_low_safety_plan",
|
||||
ctx.now,
|
||||
params={"record_id": row.id},
|
||||
)
|
||||
if row.kind == "cssrs_since_contact":
|
||||
# Recent and Since Last Contact are different instruments. No silent substitution.
|
||||
return await manual_review(ctx, row, "cssrs_since_contact_required")
|
||||
try:
|
||||
q = next_question(row)
|
||||
# Validate preserved short answers against a newly answered full-form item 1.
|
||||
score_alcohol(group_answers(a, "rus_audit"), short=False, sex=row.details.get("sex"))
|
||||
except ValueError:
|
||||
return await manual_review(ctx, row, "answer_conflict")
|
||||
if q:
|
||||
replies = [question_reply(row, q)]
|
||||
if c.complete and c.risk == "low" and not row.details.get("plan_shown"):
|
||||
from app.dialog.actions import safety_plan_reply
|
||||
|
||||
replies.insert(0, await safety_plan_reply(ctx))
|
||||
row.details = {**row.details, "plan_shown": True}
|
||||
return Step(replies=replies, next_state="intake.collect", data={"questionnaire_record": row.id})
|
||||
extra = {}
|
||||
if a.get("side_effects") == 1:
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
ctx.token,
|
||||
row.id + ":side_effects_review",
|
||||
"side_effects_review",
|
||||
ctx.now,
|
||||
params={"record_id": row.id},
|
||||
)
|
||||
if row.kind == "intake":
|
||||
result = service.decision(row)
|
||||
if result.status != "ready":
|
||||
return await manual_review(ctx, row, "intake_incomplete_review")
|
||||
extra = {"decision": asdict(result), "totals": service.summary(row)}
|
||||
if a.get("recent_trauma") == 1:
|
||||
extra["recent_trauma"] = True
|
||||
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
ctx.token,
|
||||
row.id + ":route",
|
||||
"intake_route_review",
|
||||
ctx.now,
|
||||
params={"record_id": row.id, **extra},
|
||||
)
|
||||
elif row.kind == "diary":
|
||||
history = {
|
||||
r.day: r.answers
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
if r.kind == "diary" and r.status == "complete"
|
||||
}
|
||||
history[row.day] = a
|
||||
flags = diary_flags(history, row.day)
|
||||
extra["flags"] = flags
|
||||
for flag in flags:
|
||||
await service.alert(
|
||||
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
|
||||
)
|
||||
if flag == "phq9_due_mood":
|
||||
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now)
|
||||
else:
|
||||
extra["totals"] = service.summary(row)
|
||||
if row.kind in ("phq9", "pcl5"):
|
||||
sid = row.kind
|
||||
total = extra["totals"][sid]["total"]
|
||||
priority = getattr(policy, sid + "_priority_at")
|
||||
specialist = getattr(policy, sid + "_specialist_at")
|
||||
extra["route"] = (
|
||||
"priority_psychiatrist"
|
||||
if total >= priority
|
||||
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
|
||||
)
|
||||
prior = next(
|
||||
(
|
||||
r
|
||||
for r in reversed(await service.records(ctx.session, ctx.token))
|
||||
if r.id != row.id
|
||||
and r.status == "complete"
|
||||
and r.version == row.version
|
||||
and r.details.get("totals", {}).get(sid, {}).get("complete")
|
||||
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if prior:
|
||||
change = total - prior.details["totals"][sid]["total"]
|
||||
extra["delta"] = {
|
||||
"baseline_record": prior.id,
|
||||
"value": change,
|
||||
"retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"),
|
||||
}
|
||||
if row.kind == "rus_audit_s" and not row.details.get("sex"):
|
||||
return await manual_review(ctx, row, "alcohol_sex_unknown")
|
||||
if extra["totals"]["rus_audit"].get("zone") == 4:
|
||||
extra["parallel_routes"] = ["narcologist"]
|
||||
retriage = extra.get("delta", {}).get("retriage", False)
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
ctx.token,
|
||||
row.id + ":followup",
|
||||
"followup_retriage_required" if retriage else "followup_review",
|
||||
ctx.now,
|
||||
level=AlertLevel.HIGH if retriage or extra.get("route") == "priority_psychiatrist" else AlertLevel.MODERATE,
|
||||
params={"record_id": row.id, **extra},
|
||||
)
|
||||
await service.finish(ctx, row, "complete", extra=extra)
|
||||
from app.dialog.flows.core import menu_reply
|
||||
|
||||
replies = [menu_reply(ctx, texts()["done"])]
|
||||
alcohol_total = extra.get("totals", {}).get("rus_audit", {})
|
||||
if alcohol_total.get("zone") in (2, 3):
|
||||
replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info"))
|
||||
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
|
||||
|
||||
|
||||
async def start(ctx, kind="intake", task=None):
|
||||
if not allowed(ctx) or crisis_open(ctx.data):
|
||||
if task:
|
||||
return Step()
|
||||
return Step(replies=[Reply(text=texts()["unavailable"], kind="info")])
|
||||
existing = await service.records(ctx.session, ctx.token)
|
||||
active = next((r for r in reversed(existing) if r.status in ("active", "paused")), None)
|
||||
if active:
|
||||
if task and task.record_id != active.id:
|
||||
return Step()
|
||||
if (
|
||||
active.policy_version != load_policy().version
|
||||
or active.version != content()["version"]
|
||||
or active.details.get("runtime_version") != load_runtime().version
|
||||
):
|
||||
return await manual_review(ctx, active, "questionnaire_version_changed")
|
||||
active.status = "active"
|
||||
return await advance(ctx, active)
|
||||
if ctx.data.get("intake_review_required") and kind != "cssrs_since_contact":
|
||||
if task:
|
||||
return Step()
|
||||
return Step(replies=[Reply(text=texts()["review"], kind="info")])
|
||||
row = await service.create_record(ctx, kind, task=task)
|
||||
return await advance(ctx, row)
|
||||
|
||||
|
||||
@on_enter("intake.start")
|
||||
async def enter(ctx):
|
||||
return await start(ctx)
|
||||
|
||||
|
||||
@trigger("button:intake:start")
|
||||
@trigger("button:intake:resume")
|
||||
async def start_button(ctx, event):
|
||||
return await start(ctx)
|
||||
|
||||
|
||||
@trigger("button:menu:checkin", replace=True)
|
||||
@trigger("system:daily_checkin", replace=True)
|
||||
async def checkin(ctx, event):
|
||||
if not intake_enabled():
|
||||
return Step(replies=[Reply(text=ctx.texts.idle.stub_checkin)])
|
||||
if event.params.get("questionnaire_task"):
|
||||
task = await ctx.session.get(QuestionnaireTask, event.params["questionnaire_task"])
|
||||
if task is None or task.token != ctx.token or task.status not in ("pending", "active"):
|
||||
return Step()
|
||||
return await start(ctx, task.kind, task)
|
||||
existing = await service.records(ctx.session, ctx.token)
|
||||
if not any(r.kind == "intake" and r.status == "complete" for r in existing):
|
||||
return await start(ctx)
|
||||
day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing):
|
||||
return Step(replies=[Reply(text=texts()["done"], kind="info")])
|
||||
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
|
||||
if risk_task.status in ("pending", "active"):
|
||||
await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now)
|
||||
return await start(ctx, "cssrs_since_contact", risk_task)
|
||||
return await start(ctx, "diary")
|
||||
|
||||
|
||||
@state("intake")
|
||||
async def collect(ctx, event: Event):
|
||||
if not allowed(ctx):
|
||||
return Step(replies=[Reply(text=texts()["unavailable"], kind="info")], next_state="idle")
|
||||
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
|
||||
if row is None or row.token != ctx.token or row.status != "active":
|
||||
return Step(next_state="idle")
|
||||
if (
|
||||
row.policy_version != load_policy().version
|
||||
or row.version != content()["version"]
|
||||
or row.details.get("runtime_version") != load_runtime().version
|
||||
):
|
||||
return await manual_review(ctx, row, "questionnaire_version_changed")
|
||||
if event.type == "system":
|
||||
task_id = event.params.get("questionnaire_task")
|
||||
if task_id and row.details.get("task_id") == task_id:
|
||||
return await advance(ctx, row)
|
||||
return Step()
|
||||
if event.button == "intake:pause":
|
||||
row.status = "paused"
|
||||
return Step(
|
||||
replies=[Reply(text=texts()["paused"], buttons=[[Button(label=texts()["resume"], data="intake:resume")]])],
|
||||
next_state="idle",
|
||||
)
|
||||
q = next_question(row)
|
||||
if q is None:
|
||||
return await advance(ctx, row)
|
||||
prefix = f"iq:{row.id[:12]}:{q.id}:"
|
||||
value = None
|
||||
valid = False
|
||||
if event.type == "button" and (event.button or "").startswith(prefix):
|
||||
index = event.button[len(prefix) :]
|
||||
if index == "skip" and q.optional:
|
||||
valid = True
|
||||
elif index.isdigit() and int(index) < len(q.options):
|
||||
value, valid = q.options[int(index)][0], True
|
||||
elif event.type == "text" and q.numeric:
|
||||
try:
|
||||
value = float(str(ctx.text).replace(",", "."))
|
||||
valid = math.isfinite(value) and 0 <= value <= 24
|
||||
except ValueError:
|
||||
pass
|
||||
elif event.type == "text" and q.optional:
|
||||
value, valid = str(ctx.text)[:2000], True
|
||||
if not valid:
|
||||
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)])
|
||||
service.save_answer(row, q, value, ctx.now)
|
||||
return await advance(ctx, row)
|
||||
Reference in New Issue
Block a user