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:
2026-10-04 15:14:09 +03:00
co-authored by Claude Opus 5.5
parent 6e7c89900b
commit 290b0d6151
36 changed files with 1900 additions and 691 deletions
+108 -6
View File
@@ -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."""
+8 -7
View File
@@ -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.
+90 -41
View File
@@ -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
View File
@@ -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)
+165
View File
@@ -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"],
}
+11 -1
View File
@@ -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")))
+3 -1
View File
@@ -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
View File
@@ -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)
+9 -3
View File
@@ -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
View File
@@ -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():
+17 -4
View File
@@ -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.