feat(questionnaire): integrate revised intake and follow-up monitoring
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:
Pavel Eliseev
2026-10-03 18:19:34 +03:00
parent 8b61eecf1b
commit c42ef621e5
34 changed files with 2556 additions and 85 deletions
+1 -1
View File
@@ -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
View File
@@ -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()
+24
View File
@@ -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,
+376
View File
@@ -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)