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:
+108
-6
@@ -29,7 +29,9 @@ from sqlalchemy import select
|
||||
from app.api import fixtures
|
||||
from app.core import clock
|
||||
from app.core.instrument_catalog import Catalog, catalog
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
from app.core.intake_runtime import intake_enabled, load_runtime
|
||||
from app.core.interpretation import texts as results_texts
|
||||
from app.core.meds_reference import find_drug, indication_for, load_reference
|
||||
from app.core.safety import CrisisResource, crisis_reply
|
||||
from app.db import repo
|
||||
@@ -56,6 +58,15 @@ class EmiasCodeIn(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
|
||||
|
||||
|
||||
class AnonymousIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module
|
||||
|
||||
|
||||
# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010).
|
||||
_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"}
|
||||
|
||||
|
||||
class EmiasDemoIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
persona_token: str = Field(min_length=1, max_length=64)
|
||||
@@ -134,9 +145,16 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
||||
|
||||
|
||||
@router.post("/auth/anonymous", response_model=AuthOut)
|
||||
async def auth_anonymous() -> AuthOut:
|
||||
"""Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere."""
|
||||
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
|
||||
"""Anonymous sign-in: a fresh random token; no identity record, no doctor.
|
||||
|
||||
With `focus` a doctor-less pseudonymous profile keeps only the chosen module, so the engine starts the
|
||||
matching onboarding instead of going straight to the menu. Without it nothing is stored at all.
|
||||
"""
|
||||
token = secrets.token_hex(TOKEN_LEN // 2)
|
||||
if body is not None and body.focus is not None:
|
||||
async with get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[body.focus]])
|
||||
log.info("web auth: anonymous tok=%s", token[:6])
|
||||
return AuthOut(token=token, mode="anonymous")
|
||||
|
||||
@@ -170,7 +188,8 @@ class Questionnaires(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
intake: Literal["none", "active", "paused", "complete", "review"]
|
||||
diary_done: bool
|
||||
diary_done: bool # both parts of today's diary
|
||||
diary_next: Literal["morning", "evening"] | None = None # the part still open today
|
||||
|
||||
|
||||
class TodayOut(BaseModel):
|
||||
@@ -240,7 +259,9 @@ async def my_today(token: str) -> TodayOut:
|
||||
week = [week_start + timedelta(days=i) for i in range(7)]
|
||||
modules = list(profile.modules or []) if profile else []
|
||||
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
diary_done = any(r.kind == "diary" and r.day == diary_day and r.status == "complete" for r in records)
|
||||
done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"}
|
||||
open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts]
|
||||
diary_done = not open_parts
|
||||
|
||||
def item(p: Prescription) -> PrescriptionItem:
|
||||
ref = find_drug(p.drug)
|
||||
@@ -258,7 +279,7 @@ async def my_today(token: str) -> TodayOut:
|
||||
return TodayOut(
|
||||
today=today,
|
||||
now=now.strftime("%H:%M"),
|
||||
has_doctor=profile is not None,
|
||||
has_doctor=profile is not None and bool(profile.doctor_id),
|
||||
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
|
||||
next_visit=profile.next_visit if profile else None,
|
||||
doses=today_doses,
|
||||
@@ -268,13 +289,94 @@ async def my_today(token: str) -> TodayOut:
|
||||
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
|
||||
],
|
||||
questionnaires=Questionnaires(
|
||||
enabled=intake_enabled() and profile is not None,
|
||||
# Stage 1 needs no profile: the screening creates it (anonymous people have none before it).
|
||||
enabled=intake_enabled(),
|
||||
intake=_intake_state(records),
|
||||
diary_done=diary_done,
|
||||
diary_next=open_parts[0] if open_parts else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ResultFinding(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
|
||||
|
||||
class Conclusion(BaseModel):
|
||||
"""Preliminary conclusion of stage 1 — a screening result, not a diagnosis."""
|
||||
|
||||
day: date
|
||||
title: str
|
||||
lines: list[str]
|
||||
findings: list[ResultFinding]
|
||||
modules: list[str]
|
||||
disclaimer: str
|
||||
|
||||
|
||||
class HistoryItem(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
kind_title: str
|
||||
status: str
|
||||
status_title: str
|
||||
day: date
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
title: str | None # interpretation shown to the patient when the form was finished
|
||||
lines: list[str]
|
||||
|
||||
|
||||
class ResultsOut(BaseModel):
|
||||
conclusion: Conclusion | None
|
||||
history: list[HistoryItem]
|
||||
|
||||
|
||||
@router.get("/me/{token}/results", response_model=ResultsOut)
|
||||
async def my_results(token: str) -> ResultsOut:
|
||||
"""The patient's own questionnaire log: every administration with its date, status and the interpretation that
|
||||
was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers."""
|
||||
async with get_clinical_session() as s:
|
||||
records = list(
|
||||
(
|
||||
await s.scalars(
|
||||
select(QuestionnaireRecord)
|
||||
.where(QuestionnaireRecord.token == token)
|
||||
.order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
t = results_texts()
|
||||
intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None)
|
||||
conclusion = None
|
||||
if intake and intake.details.get("interpretation"):
|
||||
i = intake.details["interpretation"]
|
||||
conclusion = Conclusion(
|
||||
day=intake.day,
|
||||
title=i["title"],
|
||||
lines=i["lines"],
|
||||
findings=[ResultFinding(**f) for f in i.get("findings", [])],
|
||||
modules=i.get("modules", []),
|
||||
disclaimer=i.get("disclaimer", t["disclaimer"]),
|
||||
)
|
||||
history = [
|
||||
HistoryItem(
|
||||
id=r.id,
|
||||
kind=r.kind,
|
||||
kind_title=t["kinds"].get(r.kind, r.kind),
|
||||
status=r.status,
|
||||
status_title=t["status"].get(r.status, r.status),
|
||||
day=r.day,
|
||||
started_at=r.started_at,
|
||||
completed_at=r.completed_at,
|
||||
title=(r.details.get("interpretation") or {}).get("title"),
|
||||
lines=(r.details.get("interpretation") or {}).get("lines", []),
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
return ResultsOut(conclusion=conclusion, history=history)
|
||||
|
||||
|
||||
@router.get("/crisis/resources", response_model=list[CrisisResource])
|
||||
async def crisis_resources() -> list[CrisisResource]:
|
||||
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
||||
|
||||
@@ -9,12 +9,11 @@ the versioned sources in config/.
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.cssrs_content import load_cssrs_russian
|
||||
from app.core.intake_questions import content, texts
|
||||
from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts
|
||||
from app.core.scales import load_scale
|
||||
|
||||
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
|
||||
@@ -113,13 +112,15 @@ def catalog() -> Catalog:
|
||||
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
|
||||
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
|
||||
|
||||
diary = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
|
||||
diary_items = [item for key in ("core", "ptsd", "depression", "suicide") for item in diary.get(key, [])]
|
||||
diary = diary_config()
|
||||
core = [item for part in DIARY_PARTS for item in diary[part]]
|
||||
depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]]
|
||||
ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]]
|
||||
add(
|
||||
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
|
||||
[i["id"] for i in diary_items],
|
||||
optional={i["id"] for i in diary_items if i.get("optional")},
|
||||
numeric={i["id"] for i in diary_items if i.get("numeric")},
|
||||
[i["id"] for i in core] + depression + ptsd,
|
||||
optional={i["id"] for i in core if i.get("optional")},
|
||||
numeric={i["id"] for i in core if i.get("numeric")},
|
||||
)
|
||||
|
||||
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
|
||||
|
||||
@@ -18,6 +18,7 @@ class Question:
|
||||
period: str = "context"
|
||||
numeric: bool = False
|
||||
optional: bool = False
|
||||
maximum: float = 24
|
||||
|
||||
|
||||
def content() -> dict:
|
||||
@@ -53,18 +54,10 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
|
||||
|
||||
|
||||
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
if c.missing:
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == c.missing[0])
|
||||
period = texts()["periods"][item.recall_period]
|
||||
# Guidance is interviewer-facing; retained in the source and record version.
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
period + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
item.recall_period,
|
||||
)
|
||||
"""Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
|
||||
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
|
||||
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
|
||||
so a positive answer interrupts the questionnaire wherever it comes."""
|
||||
for item in ("phq9_1", "phq9_2"):
|
||||
if item not in answers:
|
||||
return scale_question("phq9", item)
|
||||
@@ -81,6 +74,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
for item in load_scale("rus_audit_s").item_ids:
|
||||
if item not in answers:
|
||||
return scale_question("rus_audit_s", item)
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
if c.missing:
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == c.missing[0])
|
||||
period = texts()["periods"][item.recall_period]
|
||||
# Guidance is interviewer-facing; retained in the source and record version.
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
period + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
item.recall_period,
|
||||
)
|
||||
if sex is None and "sex" not in answers:
|
||||
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
|
||||
for key in ("tbi", "psychotropics"):
|
||||
@@ -108,34 +113,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
return None
|
||||
|
||||
|
||||
def diary_questions(
|
||||
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
|
||||
) -> tuple[Question, ...]:
|
||||
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
|
||||
selected = ["core"]
|
||||
detailed = [name for name in ("veteran", "depression") if name in modules]
|
||||
if len(detailed) == 2:
|
||||
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
|
||||
if "veteran" in detailed:
|
||||
selected.append("ptsd")
|
||||
if "depression" in detailed:
|
||||
selected.append("depression")
|
||||
if suicide_monitoring or "depression" in modules:
|
||||
selected.append("suicide")
|
||||
questions = []
|
||||
for section in selected:
|
||||
for item in config[section]:
|
||||
if item.get("weekly") and weekday != 0:
|
||||
continue
|
||||
questions.append(
|
||||
Question(
|
||||
item["id"],
|
||||
item["text"],
|
||||
tuple((o["value"], o["label"]) for o in item.get("options", [])),
|
||||
"past_week" if item.get("weekly") else "past_day",
|
||||
item.get("numeric", False),
|
||||
item.get("optional", False),
|
||||
def cssrs_since_contact_question(answers: dict) -> Question | None:
|
||||
"""Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact».
|
||||
|
||||
The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the
|
||||
items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact
|
||||
is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies.
|
||||
"""
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
missing = [k for k in c.missing if k != "q6_recent"]
|
||||
if not missing:
|
||||
return None
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == missing[0])
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
texts()["periods"]["since_last_contact"] + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
"since_last_contact",
|
||||
)
|
||||
|
||||
|
||||
DIARY_PARTS = ("morning", "evening")
|
||||
DIARY_KINDS = ("diary_morning", "diary_evening")
|
||||
|
||||
|
||||
def diary_config() -> dict:
|
||||
return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]:
|
||||
"""Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile.
|
||||
|
||||
PTSD («veteran») and depression run side by side when both are present. The depression module asks three
|
||||
PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`)
|
||||
so diary answers never mix with the full scales.
|
||||
"""
|
||||
if part not in DIARY_PARTS:
|
||||
raise ValueError("unknown diary part")
|
||||
config = diary_config()
|
||||
questions = [
|
||||
Question(
|
||||
item["id"],
|
||||
item["text"],
|
||||
tuple((o["value"], o["label"]) for o in item.get("options", [])),
|
||||
"past_day",
|
||||
item.get("numeric", False),
|
||||
item.get("optional", False),
|
||||
item.get("max", 24),
|
||||
)
|
||||
for item in config[part]
|
||||
]
|
||||
if part == "evening":
|
||||
forms = content()["forms"]
|
||||
if "depression" in modules:
|
||||
module = config["depression"]
|
||||
items = forms[module["form"]]["items"]
|
||||
per_day = module["items_per_day"]
|
||||
start = (day_index % (len(items) // per_day)) * per_day
|
||||
options = tuple((o["value"], o["label"]) for o in module["options"])
|
||||
for item in items[start : start + per_day]:
|
||||
number = item["id"].rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today")
|
||||
)
|
||||
if "veteran" in modules:
|
||||
module = config["ptsd"]
|
||||
form = forms[module["form"]]
|
||||
by_id = {i["id"]: i["text"] for i in form["items"]}
|
||||
options = tuple((o["value"], o["label"]) for o in form["options"])
|
||||
for item_id in module["items"]:
|
||||
number = item_id.rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today")
|
||||
)
|
||||
)
|
||||
questions.sort(key=lambda q: q.id != "death_thoughts")
|
||||
return tuple(questions)
|
||||
|
||||
+73
-31
@@ -1,6 +1,5 @@
|
||||
"""Versioned release gate and deterministic follow-up planning."""
|
||||
|
||||
import os
|
||||
from calendar import monthrange
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
@@ -11,7 +10,7 @@ import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.questionnaire import Approval, load_policy
|
||||
from app.core.questionnaire import Approval, demo_mode, load_policy
|
||||
|
||||
|
||||
class RuntimePolicy(BaseModel):
|
||||
@@ -22,13 +21,20 @@ class RuntimePolicy(BaseModel):
|
||||
content_review: Approval | None = None
|
||||
contact_review: Approval | None = None
|
||||
timezone: str
|
||||
daily_hour: int = Field(ge=0, le=23)
|
||||
mood_baseline_days: int = Field(ge=2)
|
||||
mood_bad_days: int = Field(ge=1)
|
||||
mood_drop: int = Field(ge=1, le=10)
|
||||
morning_hour: int = Field(ge=0, le=23)
|
||||
evening_hour: int = Field(ge=0, le=23)
|
||||
norm_days: int = Field(ge=2)
|
||||
week_days: int = Field(ge=2)
|
||||
min_norm_values: int = Field(ge=1)
|
||||
min_week_values: int = Field(ge=1)
|
||||
mood_drop: float = Field(gt=0, le=10)
|
||||
depression_item_rise: float = Field(gt=0, le=3)
|
||||
ptsd_sum_rise: float = Field(gt=0, le=16)
|
||||
sleep_bad_days: int = Field(ge=1)
|
||||
sleep_hours_below: float = Field(gt=0, le=24)
|
||||
nightmares_at: int = Field(ge=0, le=4)
|
||||
alcohol_heavy_portions: float = Field(gt=0)
|
||||
alcohol_heavy_days: int = Field(ge=1)
|
||||
adherence_below: float = Field(gt=0, le=1)
|
||||
missed_medication_days: int = Field(ge=1)
|
||||
high_risk_silence_days: int = Field(ge=1)
|
||||
medication_days: tuple[int, ...]
|
||||
@@ -45,6 +51,8 @@ class RuntimePolicy(BaseModel):
|
||||
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
|
||||
):
|
||||
raise ValueError("all route and scale intervals must be defined")
|
||||
if self.morning_hour >= self.evening_hour:
|
||||
raise ValueError("the morning diary must come before the evening one")
|
||||
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
|
||||
raise ValueError("medication days must be nonempty and strictly increasing")
|
||||
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
|
||||
@@ -71,7 +79,7 @@ def demo_intake() -> bool:
|
||||
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
|
||||
until the approvals are recorded there.
|
||||
"""
|
||||
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
|
||||
return demo_mode()
|
||||
|
||||
|
||||
def intake_enabled() -> bool:
|
||||
@@ -125,39 +133,73 @@ def due_scales(
|
||||
return tuple(sorted(due))
|
||||
|
||||
|
||||
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
|
||||
"""Consecutive calendar dates; missing data never become zero or missed doses.
|
||||
def _mean(values: list[float]) -> float:
|
||||
return sum(values) / len(values)
|
||||
|
||||
Baseline is the seven dates BEFORE the three-day deterioration window;
|
||||
every baseline value is required. Nightmares OR short sleep must hold on
|
||||
each of three nights. These candidate definitions require signed review.
|
||||
|
||||
def diary_flags(
|
||||
history: dict[date, dict],
|
||||
today: date,
|
||||
policy: RuntimePolicy | None = None,
|
||||
*,
|
||||
medication_active: bool = False,
|
||||
) -> tuple[str, ...]:
|
||||
"""Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days.
|
||||
|
||||
`history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of
|
||||
observation; trends are checked only after it. Missing data never become zero or missed doses.
|
||||
"""
|
||||
p = policy or load_runtime()
|
||||
flags = []
|
||||
flags: list[str] = []
|
||||
current = history.get(today, {})
|
||||
if current.get("death_thoughts") == 1:
|
||||
flags.append("suicide_assessment_now")
|
||||
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
|
||||
baseline = [
|
||||
history.get(today - timedelta(days=i), {}).get("mood")
|
||||
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
|
||||
]
|
||||
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
|
||||
mean = sum(baseline) / len(baseline)
|
||||
if all(mean - x["mood"] >= p.mood_drop for x in recent):
|
||||
flags.append("phq9_due_mood")
|
||||
start = min(history) if history else today
|
||||
norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
|
||||
week_start = today - timedelta(days=p.week_days - 1)
|
||||
week = [history[d] for d in sorted(history) if week_start <= d <= today]
|
||||
trend = (week_start - start).days >= p.norm_days
|
||||
|
||||
def values(rows: list[dict], key) -> list[float]:
|
||||
return [v for r in rows if (v := key(r)) is not None]
|
||||
|
||||
def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
|
||||
before, now = values(norm, key), values(week, key)
|
||||
if not trend or len(before) < min_norm or len(now) < min_week:
|
||||
return False
|
||||
change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before)
|
||||
return change >= by
|
||||
|
||||
if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True):
|
||||
flags.append("phq9_due_mood")
|
||||
# Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item.
|
||||
for item in range(1, 10):
|
||||
if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2):
|
||||
flags.append("phq9_due_depression")
|
||||
break
|
||||
# PTSD module: only the sum of the four items is interpreted, never single clusters.
|
||||
ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18")
|
||||
|
||||
def ptsd_sum(r):
|
||||
return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None
|
||||
|
||||
if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values):
|
||||
flags.append("pcl5_due_ptsd")
|
||||
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
|
||||
if all(
|
||||
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
|
||||
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
|
||||
(r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
|
||||
for r in nights
|
||||
):
|
||||
flags.append("sleep_review")
|
||||
heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions]
|
||||
if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions:
|
||||
flags.append("alcohol_on_medication")
|
||||
if len(heavy) >= p.alcohol_heavy_days:
|
||||
flags.append("rus_audit_due_alcohol")
|
||||
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
|
||||
if all(r.get("medication") == "none" for r in doses):
|
||||
if all(r.get("medication") == "no" for r in doses):
|
||||
flags.append("medication_contact")
|
||||
if current.get("side_effects") == 1:
|
||||
taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
|
||||
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
|
||||
flags.append("adherence_review")
|
||||
if current.get("medication_reason") == "side_effects":
|
||||
flags.append("side_effects_review")
|
||||
if current.get("medication") == "partial":
|
||||
flags.append("partial_medication_review")
|
||||
return tuple(flags)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml.
|
||||
|
||||
Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2.
|
||||
Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags.
|
||||
Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.intake_questions import content
|
||||
from app.core.questionnaire import IntakeDecision, IntakePolicy
|
||||
|
||||
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
|
||||
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
|
||||
# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022).
|
||||
PHQ9_REMISSION_BELOW = 5
|
||||
PHQ9_IMPROVED_BY = 5
|
||||
PCL5_RESPONSE_BY = 10
|
||||
PCL5_IMPROVED_BELOW = 28
|
||||
|
||||
|
||||
def texts() -> dict[str, Any]:
|
||||
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def render(interpretation: dict[str, Any]) -> str:
|
||||
"""Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any)."""
|
||||
parts = [interpretation["title"], *interpretation.get("lines", [])]
|
||||
if interpretation.get("disclaimer"):
|
||||
parts.append(interpretation["disclaimer"])
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _alcohol_zone(totals: dict) -> int | None:
|
||||
full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {}
|
||||
return full.get("zone") if full.get("complete") else short.get("zone")
|
||||
|
||||
|
||||
def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]:
|
||||
"""Screening findings of stage 1, in the order they are shown."""
|
||||
found = []
|
||||
phq = totals.get("phq9") or {}
|
||||
if phq.get("complete"):
|
||||
if phq["total"] >= policy.phq9_specialist_at:
|
||||
found.append("depression_suspected")
|
||||
elif phq["total"] >= policy.phq9_psychologist_at:
|
||||
found.append("depression_mild")
|
||||
pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {}
|
||||
if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at:
|
||||
found.append("ptsd_suspected")
|
||||
elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at:
|
||||
found.append("ptsd_signs")
|
||||
zone = _alcohol_zone(totals)
|
||||
if zone == 4:
|
||||
found.append("alcohol_high")
|
||||
elif zone in (2, 3):
|
||||
found.append("alcohol_risky")
|
||||
if (totals.get("cssrs_recent") or {}).get("risk") == "low":
|
||||
found.append("suicide_low")
|
||||
if answers.get("tbi") == 1:
|
||||
found.append("tbi")
|
||||
return found
|
||||
|
||||
|
||||
def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict:
|
||||
t = texts()
|
||||
found = intake_findings(totals, answers, policy)
|
||||
modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE]
|
||||
head = t["intake"]["attention" if found else "ok"]
|
||||
lines = [head["text"]]
|
||||
lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found]
|
||||
if decision.primary_route in t["routes"]:
|
||||
lines.append(t["routes"][decision.primary_route])
|
||||
names = ", ".join(t["modules"]["names"][m] for m in modules)
|
||||
lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else ""))
|
||||
return {
|
||||
"kind": "intake",
|
||||
"title": head["title"],
|
||||
"lines": lines,
|
||||
"codes": found or ["ok"],
|
||||
"findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found],
|
||||
"route": decision.primary_route,
|
||||
"modules": modules,
|
||||
"disclaimer": t["disclaimer"],
|
||||
}
|
||||
|
||||
|
||||
def _phq9_level(total: int) -> str:
|
||||
band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"])
|
||||
return band["code"]
|
||||
|
||||
|
||||
def scale_interpretation(
|
||||
kind: str,
|
||||
totals: dict,
|
||||
*,
|
||||
baseline: int | None,
|
||||
previous: int | None,
|
||||
policy: IntakePolicy,
|
||||
retriage_delta: int,
|
||||
) -> dict:
|
||||
"""A scheduled full scale: level, then the change against the start of observation and the last measurement."""
|
||||
t = texts()["scales"]
|
||||
if kind == "rus_audit_s":
|
||||
zone = _alcohol_zone(totals)
|
||||
result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"]
|
||||
level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"]
|
||||
line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level)
|
||||
return {
|
||||
"kind": kind,
|
||||
"title": t[kind]["title"],
|
||||
"lines": [line],
|
||||
"codes": [f"zone_{zone}" if zone else "unknown"],
|
||||
}
|
||||
total = totals[kind]["total"]
|
||||
pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below"
|
||||
code = _phq9_level(total) if kind == "phq9" else pcl5_level
|
||||
lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])]
|
||||
change = None
|
||||
if previous is not None and total - previous >= retriage_delta:
|
||||
change = "worse"
|
||||
elif baseline is not None:
|
||||
if kind == "phq9":
|
||||
if total < PHQ9_REMISSION_BELOW:
|
||||
change = "remission"
|
||||
elif baseline > 0 and total <= baseline / 2:
|
||||
change = "response"
|
||||
elif baseline - total >= PHQ9_IMPROVED_BY:
|
||||
change = "improved"
|
||||
else:
|
||||
change = "stable"
|
||||
else:
|
||||
if baseline - total >= PCL5_RESPONSE_BY:
|
||||
change = "response"
|
||||
elif total < PCL5_IMPROVED_BELOW <= baseline:
|
||||
change = "improved"
|
||||
else:
|
||||
change = "stable"
|
||||
if change:
|
||||
lines.append(t["change"][change])
|
||||
return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]}
|
||||
|
||||
|
||||
def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict:
|
||||
t = texts()["diary"]
|
||||
shown = [f for f in flags if f in t["flags"]]
|
||||
return {
|
||||
"kind": f"diary_{part}",
|
||||
"title": t["saved"].format(part=t["parts"][part]),
|
||||
"lines": [t["flags"][f] for f in shown],
|
||||
"codes": list(flags),
|
||||
}
|
||||
|
||||
|
||||
def cssrs_interpretation(risk: str | None) -> dict:
|
||||
t = texts()["cssrs"]
|
||||
return {
|
||||
"kind": "cssrs_since_contact",
|
||||
"title": t["low" if risk == "low" else "none"],
|
||||
"lines": [],
|
||||
"codes": [risk or "none"],
|
||||
}
|
||||
@@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -69,8 +70,17 @@ class IntakePolicy(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
def demo_mode() -> bool:
|
||||
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data.
|
||||
|
||||
It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the
|
||||
real policy and the release gate in config/intake_runtime.yaml are never touched.
|
||||
"""
|
||||
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
|
||||
|
||||
|
||||
def load_policy(path: Path | None = None) -> IntakePolicy:
|
||||
path = path or get_settings().config_dir / "questionnaire.yaml"
|
||||
path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml")
|
||||
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+22
-9
@@ -15,7 +15,9 @@ from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core import clock
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
|
||||
from app.core.questionnaire import load_policy
|
||||
from app.db import repo
|
||||
from app.db.clinical import PatientProfile
|
||||
from app.db.intake import QuestionnaireTask
|
||||
@@ -45,16 +47,18 @@ async def plan():
|
||||
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
|
||||
if initial is None:
|
||||
continue
|
||||
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day])
|
||||
diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"]
|
||||
last_activity = max([*diaries, initial.day])
|
||||
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
|
||||
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
|
||||
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
|
||||
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
|
||||
continue
|
||||
if local.hour >= p.daily_hour and not any(
|
||||
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows
|
||||
):
|
||||
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now)
|
||||
done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"}
|
||||
# Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2).
|
||||
for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)):
|
||||
if local.hour >= hour and kind not in done:
|
||||
await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now)
|
||||
last = {}
|
||||
for row in rows:
|
||||
if row.status != "complete":
|
||||
@@ -82,12 +86,21 @@ async def plan():
|
||||
after_crisis=crisis_date,
|
||||
policy=p,
|
||||
)
|
||||
if scales or local.hour >= p.daily_hour:
|
||||
# Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring.
|
||||
if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done:
|
||||
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now)
|
||||
totals = initial.details.get("totals", {})
|
||||
modules = set(profile.modules or [])
|
||||
policy = load_policy()
|
||||
# PHQ-9 only with depression or a positive PHQ-2; PCL-5 only with PTSD or a positive TSQ («Этап 2», p.3).
|
||||
wanted = {
|
||||
"phq9": "depression" in modules or bool(totals.get("phq9", {}).get("complete")),
|
||||
"pcl5": "veteran" in modules or (totals.get("tsq", {}).get("total") or 0) >= policy.tsq_expand_at,
|
||||
"rus_audit_s": True,
|
||||
}
|
||||
for sid in scales:
|
||||
if sid == "pcl5" and initial.answers.get("trauma") != 1:
|
||||
continue
|
||||
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
|
||||
if wanted[sid]:
|
||||
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
|
||||
|
||||
|
||||
async def deliver():
|
||||
|
||||
@@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
sex = profile.sex if profile else None
|
||||
if sex is None and prior:
|
||||
sex = prior.details.get("sex")
|
||||
day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date()
|
||||
row = QuestionnaireRecord(
|
||||
id=uuid4().hex,
|
||||
token=ctx.token,
|
||||
@@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
version=content()["version"],
|
||||
policy_version=load_policy().version,
|
||||
started_at=ctx.now,
|
||||
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(),
|
||||
day=day,
|
||||
status="active",
|
||||
answers={},
|
||||
details={
|
||||
@@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None):
|
||||
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
|
||||
"answer_metadata": {},
|
||||
"task_id": task.id if task else None,
|
||||
# Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day).
|
||||
"day_index": (day - prior.day).days if prior else 0,
|
||||
},
|
||||
)
|
||||
ctx.session.add(row)
|
||||
@@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
return row
|
||||
|
||||
|
||||
def save_answer(row, question, value, now):
|
||||
def save_answer(row, question, value, now, *, derived=False):
|
||||
"""Anchor recall windows to administration, never to the day of a resumed answer."""
|
||||
day = row.day
|
||||
starts = {
|
||||
@@ -105,8 +108,8 @@ def save_answer(row, question, value, now):
|
||||
"period": question.period,
|
||||
"period_start": starts[question.period].isoformat() if question.period in starts else None,
|
||||
"period_end": day.isoformat(),
|
||||
"source": "patient",
|
||||
"derived": False,
|
||||
"source": "derived" if derived else "patient",
|
||||
"derived": derived,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now):
|
||||
return row
|
||||
|
||||
|
||||
async def outstanding_task(session, token, kind):
|
||||
return await session.scalar(
|
||||
select(QuestionnaireTask).where(
|
||||
QuestionnaireTask.token == token,
|
||||
QuestionnaireTask.kind == kind,
|
||||
QuestionnaireTask.status.in_(("pending", "active")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def lock_patient(session, token):
|
||||
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
|
||||
# use the engine's in-process lock. Consent and answers share this transaction.
|
||||
|
||||
Reference in New Issue
Block a user