feat(web): two-stage questionnaires, screening result and diary by result
- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
flags on weekly means against the personal norm; scheduled full scales with change
(remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).
ADR-012.
Refs #77
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,9 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
|
||||
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")):
|
||||
# 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 :]
|
||||
|
||||
+131
-57
@@ -4,9 +4,13 @@ import math
|
||||
from dataclasses import asdict, replace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.core import interpretation
|
||||
from app.core.intake_questions import (
|
||||
DIARY_KINDS,
|
||||
Question,
|
||||
content,
|
||||
cssrs_answers,
|
||||
cssrs_since_contact_question,
|
||||
diary_questions,
|
||||
group_answers,
|
||||
next_intake,
|
||||
@@ -17,6 +21,7 @@ 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 import repo
|
||||
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.crisis import crisis_open, open_crisis
|
||||
@@ -28,21 +33,23 @@ def allowed(ctx):
|
||||
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
|
||||
|
||||
|
||||
def side_effects_question():
|
||||
"""FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3)."""
|
||||
return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week")
|
||||
|
||||
|
||||
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 row.kind == "cssrs_since_contact":
|
||||
return cssrs_since_contact_question(a)
|
||||
if row.kind in DIARY_KINDS:
|
||||
part = row.kind.removeprefix("diary_")
|
||||
for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)):
|
||||
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:
|
||||
if q.id == "medication_reason" and a.get("medication") != "no":
|
||||
continue
|
||||
return q
|
||||
return None
|
||||
@@ -63,11 +70,7 @@ def next_question(row):
|
||||
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 side_effects_question()
|
||||
return None
|
||||
|
||||
|
||||
@@ -89,7 +92,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
|
||||
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):
|
||||
if row and (row.answers.get("dphq_9", 0) > 0 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})
|
||||
@@ -132,7 +135,8 @@ 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":
|
||||
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
|
||||
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 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")
|
||||
@@ -153,9 +157,6 @@ async def advance(ctx, row):
|
||||
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.
|
||||
@@ -187,6 +188,8 @@ async def advance(ctx, row):
|
||||
extra = {"decision": asdict(result), "totals": service.summary(row)}
|
||||
if a.get("recent_trauma") == 1:
|
||||
extra["recent_trauma"] = True
|
||||
extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy)
|
||||
await apply_modules(ctx, extra["interpretation"]["modules"])
|
||||
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
@@ -196,23 +199,29 @@ async def advance(ctx, row):
|
||||
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)
|
||||
elif row.kind == "cssrs_since_contact":
|
||||
extra["totals"] = {"cssrs_recent": asdict(c)}
|
||||
extra["interpretation"] = interpretation.cssrs_interpretation(c.risk)
|
||||
elif row.kind in DIARY_KINDS:
|
||||
# Morning and evening answers of one date form one diary day.
|
||||
history: dict = {}
|
||||
for r in await service.records(ctx.session, ctx.token):
|
||||
if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id:
|
||||
history.setdefault(r.day, {}).update(r.answers)
|
||||
history.setdefault(row.day, {}).update(a)
|
||||
flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False))
|
||||
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)
|
||||
if flag in DIARY_SCALE_TASKS:
|
||||
sid = DIARY_SCALE_TASKS[flag]
|
||||
await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now)
|
||||
extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags)
|
||||
else:
|
||||
extra["totals"] = service.summary(row)
|
||||
runtime = load_runtime()
|
||||
if row.kind in ("phq9", "pcl5"):
|
||||
sid = row.kind
|
||||
total = extra["totals"][sid]["total"]
|
||||
@@ -223,27 +232,37 @@ async def advance(ctx, row):
|
||||
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,
|
||||
)
|
||||
earlier = [
|
||||
r
|
||||
for r in 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))
|
||||
]
|
||||
prior = earlier[-1] if earlier else 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"),
|
||||
"retriage": change >= getattr(runtime, sid + "_retriage_delta"),
|
||||
}
|
||||
extra["interpretation"] = interpretation.scale_interpretation(
|
||||
sid,
|
||||
extra["totals"],
|
||||
baseline=earlier[0].details["totals"][sid]["total"] if earlier else None,
|
||||
previous=prior.details["totals"][sid]["total"] if prior else None,
|
||||
policy=policy,
|
||||
retriage_delta=getattr(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 row.kind == "rus_audit_s":
|
||||
extra["interpretation"] = interpretation.scale_interpretation(
|
||||
"rus_audit_s", extra["totals"], baseline=None, previous=None, policy=policy, retriage_delta=0
|
||||
)
|
||||
if extra["totals"]["rus_audit"].get("zone") == 4:
|
||||
extra["parallel_routes"] = ["narcologist"]
|
||||
retriage = extra.get("delta", {}).get("retriage", False)
|
||||
@@ -257,13 +276,48 @@ async def advance(ctx, row):
|
||||
params={"record_id": row.id, **extra},
|
||||
)
|
||||
await service.finish(ctx, row, "complete", extra=extra)
|
||||
shown = interpretation.render(extra["interpretation"]) if extra.get("interpretation") else texts()["done"]
|
||||
if row.kind == "cssrs_since_contact" and not await done_today(ctx, "diary_evening"):
|
||||
# The risk questions open the evening diary; continue straight into it.
|
||||
step = await start(ctx, "diary_evening")
|
||||
step.replies.insert(0, Reply(text=shown, kind="info"))
|
||||
return step
|
||||
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})
|
||||
return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None})
|
||||
|
||||
|
||||
# Diary flags that bring a full scale forward («Этап 2», p.4).
|
||||
DIARY_SCALE_TASKS = {
|
||||
"phq9_due_mood": "phq9",
|
||||
"phq9_due_depression": "phq9",
|
||||
"pcl5_due_ptsd": "pcl5",
|
||||
"rus_audit_due_alcohol": "rus_audit_s",
|
||||
}
|
||||
|
||||
|
||||
async def apply_modules(ctx, modules):
|
||||
"""Stage 1 result → diary modules of stage 2. Without a doctor the screening defines them; a doctor's modules
|
||||
(diagnosis from ЕМИАС) are kept and only extended."""
|
||||
profile = await ctx.profile()
|
||||
if profile is None:
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id="", modules=list(modules))
|
||||
return
|
||||
current = list(profile.modules or [])
|
||||
merged = [*current, *(m for m in modules if m not in current)] if profile.doctor_id else list(modules)
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, modules=merged)
|
||||
|
||||
|
||||
def local_day(ctx):
|
||||
return ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
|
||||
|
||||
async def done_today(ctx, kind):
|
||||
day = local_day(ctx)
|
||||
return any(
|
||||
r.kind == kind and r.day == day and r.status == "complete"
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
)
|
||||
|
||||
|
||||
async def start(ctx, kind="intake", task=None):
|
||||
@@ -288,6 +342,8 @@ async def start(ctx, kind="intake", task=None):
|
||||
if task:
|
||||
return Step()
|
||||
return Step(replies=[Reply(text=texts()["review"], kind="info")])
|
||||
# A manual start takes over the scheduled task of the same form, so the task does not stay outstanding.
|
||||
task = task or await service.outstanding_task(ctx.session, ctx.token, kind)
|
||||
row = await service.create_record(ctx, kind, task=task)
|
||||
return await advance(ctx, row)
|
||||
|
||||
@@ -316,14 +372,28 @@ async def checkin(ctx, event):
|
||||
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")
|
||||
return await next_diary(ctx)
|
||||
|
||||
|
||||
async def next_diary(ctx):
|
||||
"""The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS
|
||||
«since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk)."""
|
||||
day = local_day(ctx)
|
||||
done = {r.kind for r in await service.records(ctx.session, ctx.token) if r.day == day and r.status == "complete"}
|
||||
for kind in DIARY_KINDS:
|
||||
if kind in done:
|
||||
continue
|
||||
profile = await ctx.profile()
|
||||
if kind == "diary_evening" and profile and profile.enhanced_monitoring and "cssrs_since_contact" not in done:
|
||||
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
|
||||
return await start(ctx, "cssrs_since_contact", risk_task)
|
||||
return await start(ctx, kind)
|
||||
return Step(replies=[Reply(text=texts()["diary_done"], kind="info")])
|
||||
|
||||
|
||||
@trigger("button:diary:next")
|
||||
async def diary_button(ctx, event):
|
||||
return await next_diary(ctx)
|
||||
|
||||
|
||||
@state("intake")
|
||||
@@ -365,12 +435,16 @@ async def collect(ctx, event: Event):
|
||||
elif event.type == "text" and q.numeric:
|
||||
try:
|
||||
value = float(str(ctx.text).replace(",", "."))
|
||||
valid = math.isfinite(value) and 0 <= value <= 24
|
||||
valid = math.isfinite(value) and 0 <= value <= q.maximum
|
||||
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)])
|
||||
hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric")
|
||||
return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)])
|
||||
service.save_answer(row, q, value, ctx.now)
|
||||
if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime":
|
||||
# Behaviour since the last contact is recent by definition (see cssrs_since_contact_question).
|
||||
service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True)
|
||||
return await advance(ctx, row)
|
||||
|
||||
@@ -46,6 +46,8 @@ from app.llm.med_qa import fallback_text, med_qa
|
||||
|
||||
QUESTION_STATE = "meds.question"
|
||||
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
|
||||
# A client sends this button when the person submits a free-text step without writing anything.
|
||||
NO_COMMENT = "text:skip"
|
||||
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
|
||||
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
|
||||
|
||||
@@ -378,7 +380,8 @@ def _side_effect_code(code: str) -> Any:
|
||||
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
|
||||
if code == "other":
|
||||
return Step(
|
||||
replies=[Reply(text=load_meds_texts().side_effects.describe)],
|
||||
# kind="question": the engine waits for free text here (clients show an input, optional).
|
||||
replies=[Reply(text=load_meds_texts().side_effects.describe, kind="question")],
|
||||
next_state=SIDE_EFFECT_TEXT_STATE,
|
||||
data={"med_se_ref": ref},
|
||||
)
|
||||
@@ -393,12 +396,15 @@ for _code in SIDE_EFFECT_CODES:
|
||||
|
||||
@state(SIDE_EFFECT_TEXT_STATE)
|
||||
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
|
||||
ref = ctx.data.get("med_se_ref")
|
||||
if event.type == "button" and event.button == NO_COMMENT:
|
||||
# The description is optional: «other» side effect without words.
|
||||
return await _after_side_effects(ctx, ["other"], str(ref) if ref else None)
|
||||
if event.type != "text" or ctx.text is None:
|
||||
return await leave_to_idle(ctx, event, {"med_se_ref": None})
|
||||
facts = await ctx.gateway.extract_checkin(ctx.text)
|
||||
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
|
||||
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
|
||||
ref = ctx.data.get("med_se_ref")
|
||||
return await _after_side_effects(ctx, codes, str(ref) if ref else None)
|
||||
|
||||
|
||||
@@ -428,7 +434,7 @@ async def _menu_question(ctx: Ctx, event: Event) -> Step:
|
||||
t = load_meds_texts().question
|
||||
if await current_prescription(ctx) is None:
|
||||
return Step(replies=[menu_reply(ctx, t.no_prescription)])
|
||||
return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE)
|
||||
return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE)
|
||||
|
||||
|
||||
async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
|
||||
|
||||
Reference in New Issue
Block a user