From 290b0d615126aec3194e113e03da3684fa4bd600 Mon Sep 17 00:00:00 2001
From: Arkhip
Date: Sun, 4 Oct 2026 15:14:09 +0300
Subject: [PATCH] feat(web): two-stage questionnaires, screening result and
diary by result
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
app/api/patient_web.py | 114 +++++++++-
app/core/instrument_catalog.py | 15 +-
app/core/intake_questions.py | 131 +++++++----
app/core/intake_runtime.py | 104 ++++++---
app/core/interpretation.py | 165 ++++++++++++++
app/core/questionnaire.py | 12 +-
app/dialog/flows/core.py | 4 +-
app/dialog/flows/intake.py | 188 +++++++++++-----
app/dialog/flows/meds.py | 12 +-
app/scheduler/jobs.py | 31 ++-
app/services/questionnaire.py | 21 +-
config/instruments/diary.yaml | 251 +++++++--------------
config/intake_runtime.yaml | 24 +-
config/questionnaire_demo.yaml | 21 ++
config/texts/intake.yaml | 4 +
config/texts/results.yaml | 120 ++++++++++
docs/DECISIONS.md | 6 +
tests/test_api_patient_web.py | 33 ++-
tests/test_dialog_intake.py | 183 +++++++++++-----
tests/test_dialog_meds.py | 8 +
tests/test_intake_runtime.py | 126 ++++++++---
web/src/api/client.ts | 5 +-
web/src/api/types.ts | 32 +++
web/src/components/AppShell.tsx | 15 ++
web/src/components/Icon.tsx | 1 +
web/src/components/Toast.tsx | 55 +++++
web/src/content/ru.ts | 63 ++++--
web/src/dialog/step.ts | 11 +-
web/src/main.tsx | 5 +
web/src/screens/AnonymousSignIn.tsx | 81 +++----
web/src/screens/Flow.tsx | 159 ++++++++------
web/src/screens/Profile.tsx | 31 +++
web/src/screens/Results.tsx | 118 ++++++++++
web/src/screens/Today.tsx | 19 +-
web/src/screens/Welcome.tsx | 98 +++++----
web/src/theme/global.css | 325 +++++++++++++++++++++++-----
36 files changed, 1900 insertions(+), 691 deletions(-)
create mode 100644 app/core/interpretation.py
create mode 100644 config/questionnaire_demo.yaml
create mode 100644 config/texts/results.yaml
create mode 100644 web/src/components/Toast.tsx
create mode 100644 web/src/screens/Results.tsx
diff --git a/app/api/patient_web.py b/app/api/patient_web.py
index e69b6ab..847b920 100644
--- a/app/api/patient_web.py
+++ b/app/api/patient_web.py
@@ -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."""
diff --git a/app/core/instrument_catalog.py b/app/core/instrument_catalog.py
index 05c965c..7b0a3b5 100644
--- a/app/core/instrument_catalog.py
+++ b/app/core/instrument_catalog.py
@@ -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::...` buttons): the form header only.
diff --git a/app/core/intake_questions.py b/app/core/intake_questions.py
index 8c598a2..b332574 100644
--- a/app/core/intake_questions.py
+++ b/app/core/intake_questions.py
@@ -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)
diff --git a/app/core/intake_runtime.py b/app/core/intake_runtime.py
index e7b82e3..6ee7ada 100644
--- a/app/core/intake_runtime.py
+++ b/app/core/intake_runtime.py
@@ -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)
diff --git a/app/core/interpretation.py b/app/core/interpretation.py
new file mode 100644
index 0000000..aa46d65
--- /dev/null
+++ b/app/core/interpretation.py
@@ -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"],
+ }
diff --git a/app/core/questionnaire.py b/app/core/questionnaire.py
index b148bd6..09a3065 100644
--- a/app/core/questionnaire.py
+++ b/app/core/questionnaire.py
@@ -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")))
diff --git a/app/dialog/flows/core.py b/app/dialog/flows/core.py
index 2d3ec47..21f585f 100644
--- a/app/dialog/flows/core.py
+++ b/app/dialog/flows/core.py
@@ -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 :]
diff --git a/app/dialog/flows/intake.py b/app/dialog/flows/intake.py
index 80c801b..22b2905 100644
--- a/app/dialog/flows/intake.py
+++ b/app/dialog/flows/intake.py
@@ -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)
diff --git a/app/dialog/flows/meds.py b/app/dialog/flows/meds.py
index d079160..fecc1cb 100644
--- a/app/dialog/flows/meds.py
+++ b/app/dialog/flows/meds.py
@@ -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:
diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py
index 9a72211..e6f50e8 100644
--- a/app/scheduler/jobs.py
+++ b/app/scheduler/jobs.py
@@ -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():
diff --git a/app/services/questionnaire.py b/app/services/questionnaire.py
index 364a637..76a62f6 100644
--- a/app/services/questionnaire.py
+++ b/app/services/questionnaire.py
@@ -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.
diff --git a/config/instruments/diary.yaml b/config/instruments/diary.yaml
index 5c0d73c..e69e6b6 100644
--- a/config/instruments/diary.yaml
+++ b/config/instruments/diary.yaml
@@ -1,189 +1,84 @@
-version: diary-v2-2026-10-03
-core:
-- id: mood
- text: 'Какое у Вас сегодня настроение?
+# Stage 2 daily diary — «Этап 2: наблюдение после диагноза» (@Andrey, 2026-10-03), p.1–3.
+# Every block comes from an instrument checked for frequent use; there are no own questions. No validated thresholds
+# exist for daily data: the diary shows a trend and brings a full scale forward, routes come from full scales only.
+# Modules follow the screening result of stage 1 (profile modules): depression → «depression», PTSD → «veteran».
+version: diary-v3-2026-10-04
- 0 — самое плохое, 10 — самое хорошее.'
- options:
- - value: 0
- label: '0'
- - value: 1
- label: '1'
- - value: 2
- label: '2'
- - value: 3
- label: '3'
- - value: 4
- label: '4'
- - value: 5
- label: '5'
- - value: 6
- label: '6'
- - value: 7
- label: '7'
- - value: 8
- label: '8'
- - value: 9
- label: '9'
- - value: 10
- label: '10'
+# Core — everyone. Morning block after waking (the sleep diary is built for it), evening block at 20:00.
+morning:
- id: sleep_hours
- text: Сколько часов Вы спали прошлой ночью?
+ source: Consensus Sleep Diary
+ text: Сколько часов Вы спали этой ночью?
numeric: true
-- id: rested
- text: 'Насколько Вы выспались?
-
- 0 — совсем не выспались, 4 — полностью выспались.'
+ max: 24
+- id: sleep_quality
+ source: Consensus Sleep Diary
+ text: Как Вы оцениваете качество своего сна?
options:
- - value: 0
- label: '0'
- - value: 1
- label: '1'
- - value: 2
- label: '2'
- - value: 3
- label: '3'
- - value: 4
- label: '4'
+ - {value: 0, label: Очень плохо}
+ - {value: 1, label: Плохо}
+ - {value: 2, label: Удовлетворительно}
+ - {value: 3, label: Хорошо}
+ - {value: 4, label: Очень хорошо}
+- id: alcohol_portions
+ source: принцип Timeline Followback; порция по RUS-AUDIT
+ text: |-
+ Сколько стандартных порций алкоголя Вы выпили вчера?
+
+ Одна порция — 100 мл вина, 250 мл пива или 30 мл крепкого алкоголя. Если не пили — введите 0.
+ numeric: true
+ max: 50
+evening:
+- id: mood
+ source: ежедневная оценка настроения (недельные средние коррелируют с PHQ-9)
+ text: |-
+ Как Вы оцениваете своё настроение сегодня?
+
+ 0 — самое плохое, 10 — самое хорошее.
+ options:
+ - {value: 0, label: '0'}
+ - {value: 1, label: '1'}
+ - {value: 2, label: '2'}
+ - {value: 3, label: '3'}
+ - {value: 4, label: '4'}
+ - {value: 5, label: '5'}
+ - {value: 6, label: '6'}
+ - {value: 7, label: '7'}
+ - {value: 8, label: '8'}
+ - {value: 9, label: '9'}
+ - {value: 10, label: '10'}
- id: medication
- text: Приняли ли Вы сегодня лекарства по схеме?
+ source: КР ПТСР — дневник приёма лекарств
+ text: Приняли ли Вы сегодня лекарства, назначенные врачом?
options:
- - value: full
- label: По схеме
- - value: partial
- label: Частично
- - value: none
- label: Нет
- - value: not_prescribed
- label: Не назначены
+ - {value: 'yes', label: Да}
+ - {value: 'no', label: Нет}
+ - {value: not_prescribed, label: Лекарства не назначены}
- id: medication_reason
- text: Почему не приняли лекарства по схеме?
+ source: КР ПТСР — дневник приёма лекарств
+ text: Почему не приняли?
options:
- - value: forgot
- label: Забыл(а)
- - value: side_effects
- label: Побочные эффекты
- - value: declined
- label: Не хочу
- - value: ran_out
- label: Закончились
- - value: other
- label: Другая причина
-- id: side_effects
- text: Беспокоили ли Вас сегодня побочные эффекты лекарств?
- options: &id001
- - value: 1
- label: Да
- - value: 0
- label: Нет
-- id: substances
- text: Употребляли ли Вы сегодня алкоголь или другие вещества?
- options: *id001
-- id: comment
- text: Что хотите передать врачу?
- optional: true
-ptsd:
-- id: intrusions
- text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?'
- options: &id002
- - value: 0
- label: Совсем нет
- - value: 1
- label: Немного
- - value: 2
- label: Умеренно
- - value: 3
- label: Сильно
- - value: 4
- label: Крайне сильно
-- id: nightmares
- text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?'
- options: *id002
-- id: avoidance
- text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?'
- options: *id002
-- id: hypervigilance
- text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?'
- options: *id002
-- id: irritability
- text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?'
- options: *id002
+ - {value: forgot, label: Забыл(а)}
+ - {value: side_effects, label: Побочные эффекты}
+ - {value: declined, label: Решил(а) не принимать}
+ - {value: ran_out, label: Закончились}
+
+# Depression module: PHQ-9 items with the official Russian text (config/instruments/intake_forms.yaml), answer
+# options adapted to one day. Three items a day in rotation: day 1 — items 1–3, day 2 — 4–6, day 3 — 7–9.
+# Item 9 ≥1 → stop and the crisis protocol (stage 1 rule).
depression:
-- id: interest
- text: 'Насколько Вам сегодня было интересно и приятно что-то делать?
-
- 0 — совсем нет, 4 — крайне сильно.'
- options: *id002
-- id: depressed
- text: Насколько Вы сегодня чувствовали подавленность или безнадёжность?
- options: *id002
-- id: energy
- text: 'Насколько у Вас сегодня были силы и энергия?
-
- 0 — совсем нет, 4 — крайне сильно.'
- options: *id002
-- id: planned_activity
- text: Сделали ли Вы сегодня хотя бы одно запланированное дело?
- options: *id001
-suicide:
-- id: death_thoughts
- text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить?
- options: *id001
-- id: thoughts_intensity
- text: 'Если да — насколько сильными они были?
-
- 0 — совсем слабые, 10 — максимально сильные.'
+ form: phq9
+ items_per_day: 3
+ question: 'Насколько сегодня Вас беспокоило:'
options:
- - value: 0
- label: '0'
- - value: 1
- label: '1'
- - value: 2
- label: '2'
- - value: 3
- label: '3'
- - value: 4
- label: '4'
- - value: 5
- label: '5'
- - value: 6
- label: '6'
- - value: 7
- label: '7'
- - value: 8
- label: '8'
- - value: 9
- label: '9'
- - value: 10
- label: '10'
-- id: hope
- text: 'Насколько Вы верите, что станет лучше?
+ - {value: 0, label: Совсем нет}
+ - {value: 1, label: Немного}
+ - {value: 2, label: Умеренно}
+ - {value: 3, label: Сильно}
- 0 — совсем не верю, 10 — полностью верю.'
- options:
- - value: 0
- label: '0'
- - value: 1
- label: '1'
- - value: 2
- label: '2'
- - value: 3
- label: '3'
- - value: 4
- label: '4'
- - value: 5
- label: '5'
- - value: 6
- label: '6'
- - value: 7
- label: '7'
- - value: 8
- label: '8'
- - value: 9
- label: '9'
- - value: 10
- label: '10'
-- id: burden
- text: Чувствовали ли Вы себя обузой для близких?
- options: *id001
- weekly: true
+# PTSD module: short 4-item PCL-5 (Price et al., 2016), one item per cluster B, C, D, E; «today» instead of «the
+# past month», options 0–4 of PCL-5. Item numbers must still be checked against the article (PDF p.7, «Медику»).
+ptsd:
+ form: pcl5
+ items: [pcl5_1, pcl5_7, pcl5_10, pcl5_18]
+ question: 'Насколько сильно сегодня Вас беспокоили:'
diff --git a/config/intake_runtime.yaml b/config/intake_runtime.yaml
index 0d187f4..2a13428 100644
--- a/config/intake_runtime.yaml
+++ b/config/intake_runtime.yaml
@@ -1,17 +1,27 @@
-version: intake-v2-2026-10-03
+version: intake-v3-2026-10-04
enabled: false
approval: null
# Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness.
content_review: null
contact_review: null
timezone: Europe/Moscow
-daily_hour: 19
-mood_baseline_days: 7
-mood_bad_days: 3
-mood_drop: 3
-sleep_bad_days: 3
+# Stage 2 diary («Этап 2: наблюдение после диагноза»): morning block after waking, evening block at 20:00.
+morning_hour: 8
+evening_hour: 20
+# Personal norm = the first `norm_days` of observation; compared with the mean of the last `week_days`.
+# Flag numbers are the team's candidates (PDF «Этап 2», p.4) — agree with the doctor.
+norm_days: 14
+week_days: 7
+min_norm_values: 7
+min_week_values: 4
+mood_drop: 3 # weekly mood mean fell ≥3 below the norm → unscheduled PHQ-9
+depression_item_rise: 1.0 # weekly mean of a PHQ-9 diary item rose ≥1 above its norm → PHQ-9 earlier
+ptsd_sum_rise: 3.0 # weekly mean of the 4-item PCL-5 sum rose ≥3 above the norm → PCL-5 earlier
+sleep_bad_days: 3 # <4 h or «очень плохо» three nights in a row → flag to the doctor
sleep_hours_below: 4
-nightmares_at: 3
+alcohol_heavy_portions: 6 # ≥6 standard drinks a day = heavy day (volumes of RUS-AUDIT item 3)
+alcohol_heavy_days: 2 # heavy days in a week → full RUS-AUDIT
+adherence_below: 0.8 # share of days with medication taken over a week
missed_medication_days: 3
high_risk_silence_days: 2
medication_days: [7, 14, 28]
diff --git a/config/questionnaire_demo.yaml b/config/questionnaire_demo.yaml
new file mode 100644
index 0000000..77c4f50
--- /dev/null
+++ b/config/questionnaire_demo.yaml
@@ -0,0 +1,21 @@
+# DEMO ONLY (INTAKE_DEMO=1 + demo clock, synthetic data). NOT a clinical approval: the real policy is
+# config/questionnaire.yaml and stays closed until the doctor's decisions C01–C09 (docs/clinical/README.md).
+# Values follow the MVP PDF so the demo can reach a result and stage 2:
+# C01 → graded: low C-SSRS risk shows the safety plan and continues (PDF p.11, routing table);
+# C02 → full RUS-AUDIT from zone II of RUS-AUDIT-S (WHO zones, Bunova 2022).
+version: mvp-v2-demo-2026-10-04
+approval:
+ reviewer: "DEMO — синтетические данные, не клиническое согласование"
+ decision_reference: "INTAKE_DEMO"
+ approved_at: "2026-10-04T00:00:00+03:00"
+ policy_version: mvp-v2-demo-2026-10-04
+cssrs_positive_policy: graded
+rus_audit_s_expand_from_zone: 2
+phq2_expand_at: 2
+tsq_expand_at: 6
+phq9_psychologist_at: 5
+phq9_specialist_at: 10
+phq9_priority_at: 20
+pcl5_specialist_at: 31
+pcl5_priority_at: 50
+pcl5_behavior_flag_at: 3
diff --git a/config/texts/intake.yaml b/config/texts/intake.yaml
index 0e88489..d33f1c7 100644
--- a/config/texts/intake.yaml
+++ b/config/texts/intake.yaml
@@ -14,7 +14,11 @@ sex: "Укажите пол для интерпретации алкогольн
tbi: "Были ли у Вас контузия или черепно-мозговая травма?"
psychotropics: "Принимаете ли Вы сейчас психотропные препараты?"
missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже."
+side_effects: "Беспокоили ли Вас за последнее время побочные эффекты лекарств?"
+numeric_portions: "Введите число стандартных порций, например 0 или 2."
+diary_done: "Дневник на сегодня уже заполнен. Спасибо!"
periods:
+ since_last_contact: "С момента Вашего последнего ответа на эти вопросы."
past_month: "За последний месяц."
lifetime: "За всю жизнь."
past_3_months: "За последние три месяца."
diff --git a/config/texts/results.yaml b/config/texts/results.yaml
new file mode 100644
index 0000000..0165968
--- /dev/null
+++ b/config/texts/results.yaml
@@ -0,0 +1,120 @@
+# Patient-facing interpretation of questionnaire results (ADR-008: texts in config). The rules that pick a text are
+# deterministic (app/core/interpretation.py) and use the thresholds of config/questionnaire*.yaml, PDF «Опросники для
+# MVP» (stage 1) and «Этап 2» (follow-up). The app never diagnoses: every result is a screening result.
+disclaimer: "Это результат скрининга, а не диагноз. Диагноз ставит врач после очной консультации."
+
+intake:
+ ok:
+ title: "Признаков депрессии и ПТСР не выявлено"
+ text: "По Вашим ответам сейчас нет признаков, которые требуют обращения к специалисту. Можно спать спокойно: продолжайте короткий дневник — так Вы заметите изменения раньше."
+ attention:
+ title: "Есть признаки, которые стоит обсудить со специалистом"
+ text: "Это не диагноз, но ответы похожи на те, что бывают при состояниях ниже. Ниже — что именно мы заметили и что делать дальше."
+
+findings:
+ depression_suspected:
+ title: "Предположение депрессии"
+ text: "Ответы указывают на выраженные симптомы депрессии за последние 2 недели. Включаем модуль «Депрессия» в ежедневном дневнике."
+ depression_mild:
+ title: "Лёгкие симптомы депрессии"
+ text: "Есть отдельные симптомы сниженного настроения. Они часто проходят сами, но за ними стоит последить."
+ ptsd_suspected:
+ title: "Склонность к ПТСР"
+ text: "Ответы указывают на выраженные последствия пережитого травматического события за последний месяц. Включаем модуль «ПТСР» в ежедневном дневнике."
+ ptsd_signs:
+ title: "Отдельные признаки посттравматического стресса"
+ text: "После травматического события есть отдельные реакции, но их выраженность ниже порога вероятного ПТСР."
+ alcohol_risky:
+ title: "Рискованное употребление алкоголя"
+ text: "Употребление алкоголя выше безопасного уровня. Стоит сократить его и обсудить это с врачом."
+ alcohol_high:
+ title: "Высокий риск зависимости от алкоголя"
+ text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом."
+ suicide_low:
+ title: "Тяжёлые мысли"
+ text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия."
+ tbi:
+ title: "Контузия или ЧМТ"
+ text: "Рекомендуем консультацию невролога."
+
+routes:
+ urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра."
+ priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни."
+ psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту."
+ psychologist: "Рекомендуем консультацию психолога."
+ self_help: "Достаточно самопомощи и короткого ежедневного дневника."
+
+modules:
+ title: "Что дальше"
+ diary: "Каждый день — короткий дневник: 3 вопроса утром и 2 вечером{modules}. Полные опросники — по расписанию."
+ names:
+ depression: "«Депрессия»"
+ veteran: "«ПТСР»"
+ with: ", плюс модуль {names}"
+
+scales:
+ phq9:
+ title: "Опросник PHQ-9 (депрессия)"
+ levels:
+ minimal: "минимальные симптомы"
+ mild: "лёгкие симптомы"
+ moderate: "умеренные симптомы"
+ moderately_severe: "умеренно тяжёлые симптомы"
+ severe: "тяжёлые симптомы"
+ pcl5:
+ title: "Опросник PCL-5 (ПТСР)"
+ levels:
+ below: "ниже порога вероятного ПТСР"
+ probable: "выше порога вероятного ПТСР"
+ rus_audit_s:
+ title: "Опросник RUS-AUDIT-S (алкоголь)"
+ levels:
+ zone_1: "относительно низкий риск"
+ zone_2: "опасное употребление"
+ zone_3: "пагубное употребление"
+ zone_4: "риск зависимости"
+ unknown: "нельзя оценить без указания пола"
+ score: "{title}: {total} — {level}."
+ change:
+ remission: "Это ремиссия по шкале — отличный результат."
+ response: "По сравнению с началом наблюдения — ответ на лечение."
+ improved: "По сравнению с началом наблюдения — клинически значимое улучшение."
+ stable: "По сравнению с началом наблюдения существенных изменений нет."
+ worse: "По сравнению с прошлым замером состояние ухудшилось. Врач получит сигнал и пересмотрит план."
+
+diary:
+ saved: "Спасибо, дневник за {part} сохранён."
+ parts:
+ morning: "утро"
+ evening: "вечер"
+ flags:
+ phq9_due_mood: "Настроение за неделю заметно ниже Вашей обычной нормы — предложим полный опросник PHQ-9 раньше срока."
+ phq9_due_depression: "Симптомы депрессии за неделю выше Вашей нормы — предложим полный опросник PHQ-9 раньше срока."
+ pcl5_due_ptsd: "Симптомы ПТСР за неделю выше Вашей нормы — предложим полный опросник PCL-5 раньше срока."
+ sleep_review: "Несколько ночей подряд очень плохой или короткий сон. Врач увидит это в сводке."
+ alcohol_on_medication: "Много алкоголя при приёме лекарств опасно. Врач получит отметку."
+ rus_audit_due_alcohol: "За неделю было несколько дней с большим количеством алкоголя — предложим опросник об алкоголе."
+ medication_contact: "Несколько дней подряд без лекарств. Не прекращайте приём без врача — мы сообщим ему."
+ adherence_review: "За неделю лекарства пропущены чаще, чем в 1 из 5 дней. Обсудите это с врачом."
+ side_effects_review: "Вы отметили побочные эффекты — врач получит отметку."
+
+cssrs:
+ none: "Спасибо за ответы."
+ low: "Спасибо, что ответили честно. Откройте план безопасности, если станет тяжело."
+
+kinds:
+ intake: "Первичный опрос"
+ diary_morning: "Дневник — утро"
+ diary_evening: "Дневник — вечер"
+ cssrs_since_contact: "Вопросы о безопасности"
+ phq9: "PHQ-9 по расписанию"
+ pcl5: "PCL-5 по расписанию"
+ rus_audit_s: "Алкоголь по расписанию"
+ diary: "Дневник"
+status:
+ complete: "пройден"
+ review_required: "остановлен для оценки специалиста"
+ interrupted: "прерван: открыта экстренная помощь"
+ active: "в процессе"
+ paused: "на паузе"
+ cancelled: "отменён"
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index b3a1da2..6ec03da 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -3,6 +3,12 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
+## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату
+**Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста».
+**Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется.
+**Причина:** пациенту нужен понятный итог и путь дальше, а не тупик; решения о риске и маршруте при этом остаются детерминированными (ADR-007).
+**Последствия:** отступления от присланного PDF сохранены по `docs/clinical/README.md`: TSQ вместо PC-PTSD-5 (v2 PDF), RUS-AUDIT-S — пункты 3/9/10 (ВОЗ). Открыто: номера 4 пунктов короткой PCL-5 (Price et al., 2016), официальная форма C-SSRS Since Last Contact (сейчас — тексты Recent с периодом «с последнего ответа»), пороги «роста» модулей дневника — согласовать с врачом.
+
## 2026-10-04 — ADR-011: База знаний (RAG) — лексический поиск BM25 и экстрактивные ответы с цитатами
**Контекст:** агенту нужны ответы по клиническим рекомендациям (КР ПТСР 2023, КР Депрессия 2024) со ссылкой на источник (#60). Эмбеддинги требуют либо сети (GigaChat Embeddings), либо тяжёлых зависимостей (torch, модель на сотни МБ). При этом демо должно работать офлайн (ADR-004), а `pyproject.toml` заморожен (#34).
**Решение:** PDF переводятся в txt один раз, офлайн (`docs/source/rag/pdf_to_txt.py`: таблицы → markdown, маркеры страниц, текст блок-схем перенесён вручную). Корпус лежит в `config/rag/`. Поиск — BM25 по основам слов (свой стеммер Snowball) с синонимами и переранжированием по самому плотному отрывку. Ответ **экстрактивный**: показываются только предложения из найденного фрагмента с цитатой «[КР ПТСР 2023, стр. N]». LLM может лишь упростить их для пациента, а перефразировка с новым содержанием отбраковывается. Если отрывки покрывают слишком малую долю вопроса (`min_coverage`), ответ — «ответа нет, уточните у врача». Пациенту не показываются предложения о назначении и дозах. Внутренние документы команды (`audiences: [team]`) не доходят ни до пациента, ни до врача.
diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py
index 269dfa3..950fa0f 100644
--- a/tests/test_api_patient_web.py
+++ b/tests/test_api_patient_web.py
@@ -51,6 +51,15 @@ async def test_anonymous_tokens_are_fresh_and_store_nothing(client: AsyncClient)
assert await repo.get_profile(s, first["token"]) is None
+async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None:
+ token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"]
+ async with session.get_clinical_session() as s:
+ profile = await repo.get_profile(s, token)
+ assert profile.modules == ["veteran"] and not profile.doctor_id
+ assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False
+ assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422
+
+
async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"]
@@ -142,7 +151,7 @@ async def test_questionnaires_are_off_without_release_or_demo_flag(
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1")
q = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["questionnaires"]
- assert q == {"enabled": False, "intake": "none", "diary_done": False}
+ assert q == {"enabled": False, "intake": "none", "diary_done": False, "diary_next": "morning"}
async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -158,7 +167,8 @@ async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeyp
await press()
await press("consent:yes")
buttons = await press("age:adult")
- assert any(b.startswith("iq:") and ":cssrs_q1:" in b for b in buttons)
+ # Stage 1 starts with PHQ-2, in the order of the MVP questionnaire.
+ assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons)
assert "intake:help" in buttons and "intake:pause" in buttons
q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"]
assert q["enabled"] and q["intake"] == "active"
@@ -169,7 +179,8 @@ async def test_instrument_catalog_describes_intake_items(client: AsyncClient) ->
phq = body["items"]["phq9_3"]
assert phq["form"] == "phq9" and phq["index"] == 3 and phq["total"] == 9 and not phq["adaptive"]
assert body["items"]["cssrs_q1"]["adaptive"] and body["forms"]["cssrs"]["section"] == "safety"
- assert body["items"]["sleep_hours"]["numeric"] and body["items"]["comment"]["optional"]
+ assert body["items"]["sleep_hours"]["numeric"] and body["items"]["alcohol_portions"]["numeric"]
+ assert body["items"]["dphq_9"]["form"] == "diary" and body["items"]["dpcl_18"]["form"] == "diary"
assert body["forms"]["pcl5"]["recall_period"] and body["forms"]["gad7"]["section"] == "scale"
drinks = body["standard_drinks"]
assert len(drinks["header"]) == 4 and len(drinks["rows"]) == 5 and all(len(c) == 4 for _, c in drinks["rows"])
@@ -186,3 +197,19 @@ async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClie
)
assert drugs["diazepam"]["max_course_days"] == 28 and drugs["alprazolam"]["max_course_days"] == 90
assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values())
+
+
+async def test_stage_one_starts_without_a_profile(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
+ # An anonymous token unknown to this database (e.g. after a reset) has no profile and no modules.
+ monkeypatch.setenv("INTAKE_DEMO", "1")
+ token = "c" * 32
+
+ async def press(button: str | None = None) -> list[str]:
+ event = {"type": "button", "button": button} if button else {"type": "start"}
+ replies = (await client.post(f"/api/v1/dialog/{token}/events", json=event)).json()["replies"]
+ return [b["data"] for r in replies for row in r["buttons"] for b in row]
+
+ assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"]
+ await press()
+ await press("consent:yes")
+ assert any(":phq9_1:" in b for b in await press("age:adult"))
diff --git a/tests/test_dialog_intake.py b/tests/test_dialog_intake.py
index 37714eb..09c1bc0 100644
--- a/tests/test_dialog_intake.py
+++ b/tests/test_dialog_intake.py
@@ -94,6 +94,17 @@ async def complete_intake(e, choices=None):
raise AssertionError("intake did not terminate")
+async def to_cssrs(e, choices=None):
+ """Stage 1 up to the C-SSRS screener, which comes after PHQ-2, the trauma filter and RUS-AUDIT-S."""
+ replies = await press(e, "intake:start")
+ for _ in range(20):
+ button = answer_button(replies, {"trauma": 1, **(choices or {})})
+ if ":cssrs_" in button:
+ return replies
+ replies = await press(e, button)
+ raise AssertionError("C-SSRS not reached")
+
+
async def test_minimal_intake_raw_data_not_imputed_and_restart(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
(row,) = await rows()
@@ -111,13 +122,13 @@ async def test_pause_restart_and_duplicate_callbacks(released):
old = answer_button(replies)
await asyncio.gather(press(released, old), press(released, old))
(row,) = await rows()
- assert row.answers == {"cssrs_q1": 0}
+ assert row.answers == {"phq9_1": 0}
await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume")
- assert "cssrs_q2" in answer_button(replies)
+ assert "phq9_2" in answer_button(replies)
await press(restarted, old)
- assert (await rows())[0].answers == {"cssrs_q1": 0}
+ assert (await rows())[0].answers == {"phq9_1": 0}
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
@@ -127,6 +138,7 @@ async def test_immediate_stop_preserves_partial_answers(released, signal):
if signal == "help":
replies = await press(released, "intake:help")
elif signal == "cssrs":
+ replies = await to_cssrs(released)
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0}))
else:
@@ -169,12 +181,13 @@ async def test_alcohol_conflict_keeps_positive_short_answer(released):
async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
- replies = await press(released, "intake:start")
+ replies = await to_cssrs(released)
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies))
assert any(r.kind == "safety_plan" for r in replies)
- assert "phq9_1" in answer_button(replies)
+ # The screener ends stage 1; the context questions follow (sex is known from the profile).
+ assert ":tbi:" in answer_button(replies)
async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch):
@@ -210,9 +223,10 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
monkeypatch.setattr(integrations, "outbox", Failing())
await jobs.run_until_now()
tasks = await rows(QuestionnaireTask)
- assert len(tasks) == 2
- risk = next(t for t in tasks if t.kind == "cssrs_since_contact")
- assert risk.delivery and risk.sent_at is None
+ # 20:00 Moscow: both parts of the diary are due; no risk monitoring → no daily C-SSRS.
+ assert {t.kind for t in tasks} == {"diary_morning", "diary_evening"}
+ morning = next(t for t in tasks if t.kind == "diary_morning")
+ assert morning.delivery and morning.sent_at is None
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
await jobs.run_until_now()
@@ -222,17 +236,29 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
assert len(await rows()) == 2
-async def test_missing_since_contact_is_manual_review_not_recent(released):
+async def test_since_contact_risk_check_opens_the_evening_diary(released):
async with session.get_clinical_session() as s:
task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW)
task_id = task.id
replies = await released.handle(
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
)
- (row,) = await rows()
- assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers
- assert row.details["stop_reason"] == "cssrs_since_contact_required"
- assert not answer_button(replies)
+ assert any("последнего ответа" in r.text for r in replies)
+ for _ in range(5):
+ button = answer_button(replies)
+ if ":cssrs_" not in (button or ""):
+ break
+ replies = await press(released, button)
+ risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
+ assert risk.status == "complete" and risk.answers == {
+ "cssrs_q1": 0,
+ "cssrs_q2": 0,
+ "cssrs_q6_lifetime": 0,
+ "cssrs_q6_recent": 0,
+ }
+ assert risk.details["answer_metadata"]["cssrs_q6_recent"]["derived"] is True
+ assert ":mood:" in answer_button(replies)
+ assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
async def test_disabled_or_no_adult_confirmation_cannot_start(released):
@@ -256,6 +282,20 @@ async def review_risk_record(e):
)
+async def run_diary(e, replies, choices=None, numbers=("6,5", "2")):
+ """Answers the open diary questions; numeric items take `numbers` in turn."""
+ numbers = list(numbers)
+ for _ in range(30):
+ button = answer_button(replies, choices)
+ if button:
+ replies = await press(e, button)
+ elif any("часов" in r.text or "порци" in r.text for r in replies) and numbers:
+ replies = await e.handle(TOKEN, Event(type="text", text=numbers.pop(0)))
+ else:
+ return replies
+ raise AssertionError("diary did not terminate")
+
+
async def test_doctor_review_is_audited_and_unblocks_diary(released):
from fastapi import HTTPException
@@ -263,8 +303,14 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
from app.db.audit import AuditLog
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
- await jobs.run_until_now()
+ async with session.get_clinical_session() as s:
+ (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
+ replies = await run_diary(released, await press(released, "menu:checkin"))
+ # Evening: the risk check comes first; thoughts of a method (q3) are a moderate risk → stop for a specialist.
+ replies = await press(released, "menu:checkin")
+ replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0})
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
+ assert risk.status == "review_required"
with pytest.raises(HTTPException) as exc:
await review(
TOKEN,
@@ -277,53 +323,43 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required")
await review_risk_record(released)
await jobs.run_until_now()
- assert any(r.kind == "diary" and r.status == "active" for r in await rows())
+ assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
await history(TOKEN, "synthetic-doctor")
audits = await rows(AuditLog)
assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"}
-async def test_diary_numeric_skip_and_not_prescribed(released):
+async def test_diary_morning_then_evening_with_the_ptsd_module(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
- await press(released, "menu:checkin")
- await review_risk_record(released)
replies = await press(released, "menu:checkin")
- for _ in range(30):
- q = next(
- (b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")),
- None,
- )
- if q == "comment":
- button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip"))
- replies = await press(released, button)
- elif q:
- replies = await press(released, answer_button(replies, {"medication": 3}))
- elif any("часов" in r.text for r in replies):
- invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
- assert any("число часов" in r.text for r in invalid)
- replies = await released.handle(TOKEN, Event(type="text", text="6,5"))
- else:
- break
- diary = next(r for r in await rows() if r.kind == "diary")
- assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5
- assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers
- assert diary.answers["comment"] is None
+ assert any("часов" in r.text for r in replies)
+ invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
+ assert any("число часов" in r.text for r in invalid)
+ replies = await run_diary(released, invalid, {"sleep_quality": 3})
+ assert any("утро" in r.text for r in replies)
+ morning = next(r for r in await rows() if r.kind == "diary_morning")
+ assert morning.status == "complete"
+ assert morning.answers == {"sleep_hours": 6.5, "sleep_quality": 3, "alcohol_portions": 2.0}
+ replies = await run_diary(released, await press(released, "menu:checkin"), {"mood": 7, "medication": 2})
+ evening = next(r for r in await rows() if r.kind == "diary_evening")
+ assert evening.status == "complete" and evening.answers["medication"] == "not_prescribed"
+ assert "medication_reason" not in evening.answers
+ assert {"dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18"} <= set(evening.answers)
+ assert evening.details["interpretation"]["kind"] == "diary_evening"
+ replies = await press(released, "menu:checkin")
+ assert any("уже заполнен" in r.text for r in replies)
-async def test_high_risk_diary_asks_safety_first_and_stops(released):
+async def test_depression_module_item9_stops_with_crisis(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
- (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
- await press(released, "menu:checkin")
- await review_risk_record(released)
- replies = await press(released, "menu:checkin")
- button = answer_button(replies)
- assert "death_thoughts" in button
- replies = await press(released, button)
+ await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
+ monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
+ await run_diary(released, await press(released, "menu:checkin"))
+ replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1})
assert any(r.kind == "crisis" for r in replies)
- assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line)
- diary = next(r for r in await rows() if r.kind == "diary")
- assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted"
+ evening = next(r for r in await rows() if r.kind == "diary_evening")
+ assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted"
async def test_unknown_sex_goes_to_review_without_a_zone(released):
@@ -344,7 +380,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released):
path.write_text(yaml.safe_dump(data, allow_unicode=True))
await press(released, "intake:resume")
(row,) = await rows()
- assert row.status == "review_required" and row.answers == {"cssrs_q1": 0}
+ assert row.status == "review_required" and row.answers == {"phq9_1": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released):
@@ -377,20 +413,23 @@ async def test_stop_any_policy_interrupts_first_positive(released):
data = yaml.safe_load(path.read_text())
data["cssrs_positive_policy"] = "stop_any"
path.write_text(yaml.safe_dump(data, allow_unicode=True))
- replies = await press(released, "intake:start")
+ replies = await to_cssrs(released)
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
assert any(r.kind == "crisis" for r in replies)
- assert (await rows())[0].answers == {"cssrs_q1": 1}
+ assert (await rows())[0].answers["cssrs_q1"] == 1
-async def test_evening_schedule_uses_moscow_date(released, monkeypatch):
+async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
- monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59))
+ monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=4, minute=59))
await jobs.plan()
assert await rows(QuestionnaireTask) == []
- monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0))
+ monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=5, minute=0))
await jobs.plan()
- assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"}
+ assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning"}
+ monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=17, minute=0))
+ await jobs.plan()
+ assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning", "diary_evening"}
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
@@ -401,7 +440,35 @@ async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:resume")
await press(released, answer_button(replies))
(record,) = await rows()
- metadata = record.details["answer_metadata"]["cssrs_q2"]
- assert metadata["period_start"] == "2026-09-03"
+ metadata = record.details["answer_metadata"]["phq9_2"]
+ assert metadata["period_start"] == "2026-09-19"
assert metadata["period_end"] == "2026-10-03"
assert metadata["answered_at"].startswith("2026-10-04")
+
+
+async def test_intake_result_is_shown_logged_and_sets_the_diary_modules(released):
+ from app.api.patient_web import my_results
+
+ choices = {f"phq9_{i}": 2 for i in range(1, 9)}
+ choices.update(trauma=1, tbi=1, psychotropics=1)
+ replies = await complete_intake(released, choices)
+ assert any("Предположение депрессии" in r.text for r in replies)
+ assert any("не диагноз" in r.text for r in replies)
+ (row,) = await rows()
+ interpretation = row.details["interpretation"]
+ assert interpretation["codes"] == ["depression_suspected"] and interpretation["modules"] == ["depression"]
+ async with session.get_clinical_session() as s:
+ # A doctor's modules (diagnosis) stay; the screening only adds to them.
+ assert (await repo.get_profile(s, TOKEN)).modules == ["veteran", "depression"]
+ results = await my_results(TOKEN)
+ assert results.conclusion.title == interpretation["title"]
+ assert [h.kind for h in results.history] == ["intake"] and results.history[0].status_title == "пройден"
+
+
+async def test_without_a_doctor_the_screening_defines_the_modules(released):
+ async with session.get_clinical_session() as s:
+ await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
+ replies = await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
+ assert any("не выявлено" in r.text for r in replies)
+ async with session.get_clinical_session() as s:
+ assert (await repo.get_profile(s, TOKEN)).modules == []
diff --git a/tests/test_dialog_meds.py b/tests/test_dialog_meds.py
index 803ac9d..b36c094 100644
--- a/tests/test_dialog_meds.py
+++ b/tests/test_dialog_meds.py
@@ -202,6 +202,14 @@ async def test_side_effect_other_free_text(engine: Engine) -> None:
assert (await checkin()).side_effects == ["headache", "other"]
+async def test_side_effect_description_is_optional(engine: Engine) -> None:
+ reminder = await system(engine, "med_reminder")
+ r = await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
+ assert r[0].kind == "question" # the client shows an (optional) input for it
+ await press(engine, "text:skip")
+ assert (await checkin()).side_effects == ["other"] and (await get_state())[0] == "idle"
+
+
async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_checkin(
diff --git a/tests/test_intake_runtime.py b/tests/test_intake_runtime.py
index 5924c84..f8b7641 100644
--- a/tests/test_intake_runtime.py
+++ b/tests/test_intake_runtime.py
@@ -56,28 +56,51 @@ def test_medication_schedule_overlapping_and_delayed_completion():
)
-def test_diary_consecutive_days_and_baseline_do_not_include_bad_window():
- today = date(2026, 10, 3)
- history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)}
- for i in range(3):
- history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"}
- assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"}
- history[today - timedelta(days=1)] = {}
- assert diary_flags(history, today) == ()
- history[today]["death_thoughts"] = 1
- assert diary_flags(history, today) == ("suicide_assessment_now",)
+def norm_and_week(norm: dict, week: dict, today=date(2026, 10, 30)):
+ """14 days of personal norm starting 27 days ago, then the last 7 days with other values."""
+ start = today - timedelta(days=27)
+ history = {start + timedelta(days=i): dict(norm) for i in range(14)}
+ history.update({today - timedelta(days=i): dict(week) for i in range(7)})
+ return history, today
-def test_sleep_alternation_counts_but_threshold_is_strict():
+def test_weekly_mood_against_personal_norm():
+ history, today = norm_and_week({"mood": 8}, {"mood": 5})
+ assert "phq9_due_mood" in diary_flags(history, today)
+ history, today = norm_and_week({"mood": 8}, {"mood": 6})
+ assert "phq9_due_mood" not in diary_flags(history, today)
+ # Inside the norm window there is no trend yet.
+ early = {date(2026, 10, 1) + timedelta(days=i): {"mood": 9 if i < 3 else 1} for i in range(10)}
+ assert "phq9_due_mood" not in diary_flags(early, date(2026, 10, 10))
+
+
+def test_module_trends_bring_full_scales_forward():
+ ptsd = {"dpcl_1": 1, "dpcl_7": 1, "dpcl_10": 1, "dpcl_18": 1}
+ history, today = norm_and_week(ptsd, {k: 2 for k in ptsd})
+ assert "pcl5_due_ptsd" in diary_flags(history, today)
+ history, today = norm_and_week({"dphq_4": 0}, {"dphq_4": 1})
+ assert "phq9_due_depression" in diary_flags(history, today)
+
+
+def test_sleep_alcohol_and_medication_flags():
today = date(2026, 10, 3)
- h = {
- today: {"nightmares": 3},
- today - timedelta(days=1): {"sleep_hours": 3.5},
- today - timedelta(days=2): {"sleep_hours": 3.5},
- }
- assert "sleep_review" in diary_flags(h, today)
- h[today - timedelta(days=1)]["sleep_hours"] = 4
- assert "sleep_review" not in diary_flags(h, today)
+ nights = {today - timedelta(days=i): {"sleep_hours": 7, "sleep_quality": 0} for i in range(3)}
+ nights[today - timedelta(days=1)] = {"sleep_hours": 3.5, "sleep_quality": 2}
+ assert "sleep_review" in diary_flags(nights, today)
+ nights[today - timedelta(days=1)]["sleep_hours"] = 4
+ assert "sleep_review" not in diary_flags(nights, today)
+
+ drinks = {today: {"alcohol_portions": 6}, today - timedelta(days=2): {"alcohol_portions": 7}}
+ assert set(diary_flags(drinks, today, medication_active=True)) >= {"alcohol_on_medication", "rus_audit_due_alcohol"}
+ assert "alcohol_on_medication" not in diary_flags(drinks, today)
+
+ missed = {today - timedelta(days=i): {"medication": "no"} for i in range(3)}
+ assert "medication_contact" in diary_flags(missed, today)
+ week = {today - timedelta(days=i): {"medication": "yes" if i % 3 else "no"} for i in range(7)}
+ assert "adherence_review" in diary_flags(week, today)
+ assert diary_flags({today: {"medication": "no", "medication_reason": "side_effects"}}, today) == (
+ "side_effects_review",
+ )
def test_total_threshold_and_zone_are_separate_decisions():
@@ -96,15 +119,60 @@ def test_total_threshold_and_zone_are_separate_decisions():
IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2})
-def test_module_rotation_never_drops_suicide_question():
+def test_diary_parts_and_module_rotation():
from app.core.intake_questions import diary_questions
- first = {
- q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100)
- }
- second = {
- q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101)
- }
- assert "death_thoughts" in first & second
- assert "nightmares" in first and "nightmares" not in second
- assert "burden" in first and "burden" not in second
+ assert [q.id for q in diary_questions("morning", ["veteran", "depression"])] == [
+ "sleep_hours",
+ "sleep_quality",
+ "alcohol_portions",
+ ]
+ evening = [q.id for q in diary_questions("evening", ["veteran", "depression"], day_index=0)]
+ assert evening == ["mood", "medication", "medication_reason", "dphq_1", "dphq_2", "dphq_3"] + [
+ "dpcl_1",
+ "dpcl_7",
+ "dpcl_10",
+ "dpcl_18",
+ ]
+ assert [q.id for q in diary_questions("evening", ["depression"], day_index=5)][-3:] == [
+ "dphq_7",
+ "dphq_8",
+ "dphq_9",
+ ]
+ assert [q.id for q in diary_questions("evening", [], day_index=1)] == ["mood", "medication", "medication_reason"]
+
+
+def test_demo_policy_is_separate_and_marked(monkeypatch):
+ from app.config import get_settings
+
+ monkeypatch.setenv("INTAKE_DEMO", "1")
+ monkeypatch.setattr(get_settings(), "demo_clock", True)
+ demo = load_policy()
+ assert demo.approval and "DEMO" in demo.approval.reviewer and demo.cssrs_positive_policy == "graded"
+ monkeypatch.delenv("INTAKE_DEMO")
+ assert load_policy().approval is None
+
+
+def test_full_scale_change_against_the_start_of_observation():
+ from app.core.interpretation import scale_interpretation
+
+ def phq(total, baseline, previous=None):
+ totals = {"phq9": {"complete": True, "total": total}}
+ return scale_interpretation(
+ "phq9", totals, baseline=baseline, previous=previous, policy=load_policy(), retriage_delta=5
+ )["codes"]
+
+ assert phq(4, 16) == ["minimal", "remission"]
+ assert phq(8, 16) == ["mild", "response"]
+ assert phq(11, 16) == ["moderate", "improved"]
+ assert phq(15, 16) == ["moderately_severe", "stable"]
+ assert phq(21, 10, previous=16) == ["severe", "worse"]
+ pcl = scale_interpretation(
+ "pcl5",
+ {"pcl5": {"complete": True, "total": 27}},
+ baseline=45,
+ previous=40,
+ policy=load_policy(),
+ retriage_delta=10,
+ )
+ assert pcl["codes"] == ["below", "response"]
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 6f796d6..96a5a4e 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -4,8 +4,10 @@ import type {
CrisisResource,
DemoPersona,
DialogEvent,
+ Focus,
MedsReference,
Reply,
+ Results,
TodayPlan,
} from "./types";
@@ -41,12 +43,13 @@ const post = (path: string, body?: unknown) =>
export const api = {
signInEmias: (code: string) => post("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post("/auth/emias/demo", { persona_token: personaToken }),
- signInAnonymous: () => post("/auth/anonymous"),
+ signInAnonymous: (focus: Focus) => post("/auth/anonymous", { focus }),
demoPersonas: () => request("/demo/personas"),
crisisResources: () => request("/crisis/resources"),
catalog: () => request("/instruments/catalog"),
medsReference: () => request("/meds/reference"),
today: (token: string) => request(`/me/${encodeURIComponent(token)}/today`),
+ results: (token: string) => request(`/me/${encodeURIComponent(token)}/results`),
sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
outbox: async (token: string) =>
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index b0dd747..de5c3e5 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -21,6 +21,9 @@ export type DialogEvent =
export type AuthMode = "emias" | "anonymous";
+/** What an anonymous person suspects; the backend maps it to a clinical module. */
+export type Focus = "depression" | "ptsd";
+
export interface AuthResult {
token: string;
mode: AuthMode;
@@ -67,6 +70,35 @@ export interface Questionnaires {
enabled: boolean;
intake: "none" | "active" | "paused" | "complete" | "review";
diary_done: boolean;
+ diary_next: "morning" | "evening" | null;
+}
+
+// app/api/patient_web.py: /me/{token}/results — preliminary conclusion of stage 1 and the questionnaire log.
+export interface Conclusion {
+ day: string;
+ title: string;
+ lines: string[];
+ findings: { code: string; title: string }[];
+ modules: string[];
+ disclaimer: string;
+}
+
+export interface HistoryItem {
+ id: string;
+ kind: string;
+ kind_title: string;
+ status: string;
+ status_title: string;
+ day: string;
+ started_at: string;
+ completed_at: string | null;
+ title: string | null;
+ lines: string[];
+}
+
+export interface Results {
+ conclusion: Conclusion | null;
+ history: HistoryItem[];
}
export interface TodayPlan {
diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx
index 10dd890..1fde105 100644
--- a/web/src/components/AppShell.tsx
+++ b/web/src/components/AppShell.tsx
@@ -10,6 +10,7 @@ import { Icon, LogoMark, type IconName } from "./Icon";
* Layout from docs/mock (series 2).
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
* desktop: navy sidebar | content | right rail (optional `aside` + emergency panel)
+ * bare: one centred column on every width (questionnaires)
*/
const TABS: { to: string; label: string; icon: IconName }[] = [
@@ -127,6 +128,7 @@ export function AppShell({
aside,
focus = false,
sos = true,
+ bare = false,
}: {
children: ReactNode;
signedIn?: boolean;
@@ -135,7 +137,20 @@ export function AppShell({
focus?: boolean;
/** The crisis screen shows the hotlines itself, so it drops the bottom block. */
sos?: boolean;
+ /** Questionnaires: no sidebar, rail or tab bar — one centred column, so nothing distracts from the answers. */
+ bare?: boolean;
}) {
+ if (bare)
+ return (
+
+
+
+ {children}
+ {sos && }
+
+
+
+ );
return (
diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx
index c323363..cf90800 100644
--- a/web/src/components/Icon.tsx
+++ b/web/src/components/Icon.tsx
@@ -28,6 +28,7 @@ const paths = {
>
),
chevronRight: ,
+ chevronDown: ,
arrowLeft: ,
close: ,
check: ,
diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx
new file mode 100644
index 0000000..3927ac4
--- /dev/null
+++ b/web/src/components/Toast.tsx
@@ -0,0 +1,55 @@
+import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react";
+
+import { Icon } from "./Icon";
+
+/*
+ * Short confirmations («отметил приём», «дневник сохранён») as a toast in the top-right corner instead of a separate
+ * confirmation page: the person lands on the home screen right away and still sees what happened.
+ */
+
+interface Toast {
+ id: number;
+ text: string;
+}
+
+const Ctx = createContext<(text: string) => void>(() => undefined);
+const LIFETIME_MS = 6000;
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState([]);
+ const next = useRef(0);
+
+ const close = useCallback((id: number) => setToasts((all) => all.filter((t) => t.id !== id)), []);
+ const show = useCallback(
+ (text: string) => {
+ const id = ++next.current;
+ setToasts((all) => [...all.slice(-2), { id, text }]);
+ window.setTimeout(() => close(id), LIFETIME_MS);
+ },
+ [close],
+ );
+
+ const value = useMemo(() => show, [show]);
+ return (
+
+ {children}
+
+ {toasts.map((t) => (
+
+
+
+
+
{t.text}
+
+
+ ))}
+
+
+ );
+}
+
+export function useToast(): (text: string) => void {
+ return useContext(Ctx);
+}
diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts
index ab28858..44e0c74 100644
--- a/web/src/content/ru.ts
+++ b/web/src/content/ru.ts
@@ -37,23 +37,21 @@ export const ru = {
networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.",
},
welcome: {
- title: "Как вы хотите войти в приложение?",
- lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.",
+ title: "Вход",
emias: {
- title: "Через ЕМИАС",
- text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.",
+ title: "Войти через ЕМИАС",
+ perks: [
+ "Назначения врача подгружаются автоматически",
+ "Врач видит ответы и получает сигнал при ухудшении",
+ "История доступна с любого устройства",
+ ],
},
+ or: "или",
anonymous: {
- title: "Анонимно",
- text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.",
+ title: "Войти анонимно",
+ text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.",
},
- disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
- howTitle: "Как это работает",
- how: [
- { title: "Врач подключает", text: "На приёме вы получаете код или входите анонимно" },
- { title: "Минута в день", text: "Напоминание о лекарстве и короткая отметка самочувствия" },
- { title: "Врач видит динамику", text: "И получает сигнал, если состоянию нужно внимание" },
- ],
+ disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.",
},
emias: {
title: "Введите код от врача",
@@ -70,14 +68,12 @@ export const ru = {
demoDay: (day: number) => `${day}-й день терапии`,
},
anonymous: {
- title: "Анонимный вход",
- lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.",
- yesTitle: "Что будет доступно:",
- yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"],
- noTitle: "Чего не будет:",
- no: ["Врач не увидит ответы", "История только на этом устройстве"],
- warning: "После выхода историю восстановить нельзя.",
- submit: "Продолжить анонимно",
+ title: "Что вы хотите проверить?",
+ note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.",
+ focus: {
+ depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" },
+ ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" },
+ },
submitting: "Создаём профиль…",
},
today: {
@@ -105,7 +101,6 @@ export const ru = {
newMessage: "Новое сообщение",
open: "Открыть",
continue: "Продолжить",
- surveyStep: (section: string, n: number) => `${section} · вопрос ${n}`,
intakeStart: "Первичный опрос",
intakeStartHint: "15–20 минут, можно прерваться и продолжить позже",
intakeResume: "Продолжить первичный опрос",
@@ -115,6 +110,9 @@ export const ru = {
intakeDone: "Первичный опрос пройден",
diary: "Дневник самочувствия",
diaryHint: "2–3 минуты: настроение, сон, лекарства",
+ diaryPart: { morning: "Дневник — утро", evening: "Дневник — вечер" },
+ diaryPartHint: { morning: "3 вопроса: сон и алкоголь", evening: "Настроение, лекарства и модули по результату опроса" },
+ results: "Мой результат",
diaryDone: "Дневник заполнен",
therapyDay: (day: number) => `День ${day} лечения`,
effectHint: "Когда ждать эффекта и что важно знать",
@@ -187,6 +185,20 @@ export const ru = {
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.",
logout: "Выйти",
messages: "Сообщения",
+ result: "Предварительный результат",
+ resultNone: "Появится после первичного опроса.",
+ history: "История опросов",
+ historySub: "Все опросы и дневники с датами и результатами",
+ },
+ results: {
+ title: "Мои результаты",
+ conclusion: "Предварительный результат",
+ conclusionDay: (d: string) => `Первичный опрос от ${d}`,
+ modules: { depression: "Модуль «Депрессия»", veteran: "Модуль «ПТСР»" },
+ history: "История опросов",
+ empty: "Здесь появятся пройденные опросы.",
+ noResult: "Пройдите первичный опрос — после него здесь будет результат.",
+ toIntake: "Пройти первичный опрос",
},
flow: {
question: (n: number, total: number) => `Вопрос ${n} из ${total}`,
@@ -194,6 +206,8 @@ export const ru = {
textPlaceholder: "Напишите несколько слов",
sending: "Отправляем…",
doneTitle: "Готово",
+ resultTitle: "Результат",
+ sendEmpty: "Отправить без комментария",
doneLead: "Врач увидит отметку.",
breathe: "Вдох на 4 — выдох на 6",
breatheIn: "Вдох",
@@ -214,11 +228,12 @@ export const ru = {
},
questionN: (n: number) => `Вопрос ${n}`,
recall: (period: string) => `Период: ${period}`,
- asOf: (date: string) => `Считайте от ${date}`,
- saved: "Ответы сохраняются после каждого вопроса",
hoursLabel: "Сколько часов",
hoursUnit: "ч",
hoursHint: "От 0 до 24, можно с половиной: 6,5",
+ portionsLabel: "Сколько порций",
+ portionsUnit: "порц.",
+ portionsHint: "Если не пили — 0. Можно дробное: 1,5",
drinks: "Таблица стандартных порций",
drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.",
noticeTitle: "Опрос приостановлен",
diff --git a/web/src/dialog/step.ts b/web/src/dialog/step.ts
index f808031..a291a6a 100644
--- a/web/src/dialog/step.ts
+++ b/web/src/dialog/step.ts
@@ -7,6 +7,7 @@ import type { DialogButton, Reply } from "../api/types";
export type Step =
| { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over
+ | { kind: "ack"; text: string[] } // a short confirmation without a question: a toast, then home
| { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] }
| { kind: "crisis"; text: string; buttons: DialogButton[] }
| { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] }
@@ -26,7 +27,7 @@ export interface Survey {
title: string; // the item text, verbatim
footnotes: string[]; // «*…» explanations printed under the item on the paper form
options: DialogButton[];
- input: "choice" | "number" | "hours" | "text";
+ input: "choice" | "number" | "hours" | "portions" | "text";
anchors?: string[]; // «0 — самое плохое», «10 — самое хорошее»: the scale ends as printed in the item
asOf?: string; // ISO date the recall period counts from
skip?: DialogButton;
@@ -107,7 +108,10 @@ function survey(batch: Reply[], qIndex: number): Survey | null {
? "choice"
: skip
? "text"
- : "hours";
+ : // Numeric items carry no buttons: diary alcohol is counted in standard drinks, everything else in hours.
+ /порци/i.test(title)
+ ? "portions"
+ : "hours";
return { kind: "survey", itemId, instruction: [...before, ...instruction], title, footnotes, options, input, anchors, asOf, skip, help, pause };
}
@@ -142,6 +146,9 @@ export function classify(batch: Reply[]): Step | null {
const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true);
const texts = batch.map((r) => r.text);
if (qIndex === -1) {
+ // Without buttons the engine waits for free text only when it marks the reply as a question; anything else
+ // («отметил приём») is a confirmation.
+ if (!batch.some((r) => r.kind === "question")) return { kind: "ack", text: texts.filter(Boolean) };
const { lead, title } = titled(texts);
return { kind: "text", lead, title };
}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 7fac6f9..0b98700 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -10,6 +10,7 @@ import { createRoot } from "react-dom/client";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { HelpProvider } from "./components/HelpSheet";
+import { ToastProvider } from "./components/Toast";
import { DialogProvider } from "./dialog/DialogContext";
import { AnonymousSignIn } from "./screens/AnonymousSignIn";
import { Doctor } from "./screens/Doctor";
@@ -19,6 +20,7 @@ import { Flow } from "./screens/Flow";
import { DoseReminder, Meds } from "./screens/Meds";
import { Messages } from "./screens/Messages";
import { Profile } from "./screens/Profile";
+import { Results } from "./screens/Results";
import { Today } from "./screens/Today";
import { Welcome } from "./screens/Welcome";
import { SessionProvider, useSession } from "./session/SessionContext";
@@ -43,6 +45,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
} />
} />
@@ -54,9 +57,11 @@ createRoot(document.getElementById("root")!).render(
+
+
diff --git a/web/src/screens/AnonymousSignIn.tsx b/web/src/screens/AnonymousSignIn.tsx
index 4736702..097248f 100644
--- a/web/src/screens/AnonymousSignIn.tsx
+++ b/web/src/screens/AnonymousSignIn.tsx
@@ -2,15 +2,18 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
+import type { Focus } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
-const YES_ICONS: IconName[] = ["calendar", "pill", "doc"];
-const NO_ICONS: IconName[] = ["user", "doc"];
+const FOCUS: { id: Focus; icon: IconName }[] = [
+ { id: "depression", icon: "smile" },
+ { id: "ptsd", icon: "heart" },
+];
-// A3 · Анонимный вход
+// A3 · Анонимный вход: человек выбирает, что хочет проверить — от этого зависит стартовый опрос
export function AnonymousSignIn() {
const t = ru.anonymous;
const navigate = useNavigate();
@@ -18,11 +21,11 @@ export function AnonymousSignIn() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
- const start = async () => {
+ const start = async (focus: Focus) => {
setBusy(true);
setError(false);
try {
- signIn(await api.signInAnonymous());
+ signIn(await api.signInAnonymous(focus));
navigate("/flow", { replace: true });
} catch {
setError(true);
@@ -34,58 +37,28 @@ export function AnonymousSignIn() {
navigate("/")} />
-
-
-
-
-
-
+
{t.title}
-
{t.lead}
-
- {t.warning}
-
-
-
-
-
-
-
- {t.yesTitle}
-
-
- {t.yes.map((item, i) => (
-
-
- {item}
-
+
+
+ {FOCUS.map(({ id, icon }) => (
+
))}
-
-
+
-
-
-
-
-
- {t.noTitle}
-
-
- {t.no.map((item, i) => (
-
-
- {item}
-
- ))}
-
-
-
- {error && {ru.common.networkError}}
-
-
-
+ {error && {ru.common.networkError}}
+
{busy ? t.submitting : t.note}
diff --git a/web/src/screens/Flow.tsx b/web/src/screens/Flow.tsx
index b26b45c..50d15fb 100644
--- a/web/src/screens/Flow.tsx
+++ b/web/src/screens/Flow.tsx
@@ -1,15 +1,15 @@
-import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
+import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
-import type { Catalog, DialogButton, Section } from "../api/types";
+import type { Catalog, DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
+import { useToast } from "../components/Toast";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify, lines, type Step, type Survey } from "../dialog/step";
-import { dayMonth } from "./useToday";
/*
* Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5),
@@ -17,16 +17,6 @@ import { dayMonth } from "./useToday";
* versioned questionnaires and the diary (stage 1 / stage 2: C-SSRS, PHQ, TSQ, PCL-5, RUS-AUDIT, дневник).
*/
-export const SECTION_ICON: Record = {
- safety: "shield",
- mood: "smile",
- trauma: "heart",
- alcohol: "info",
- about: "user",
- diary: "book",
- scale: "clipboard",
-};
-
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g;
function RichText({ text }: { text: string }) {
@@ -91,19 +81,6 @@ function Progress({ n, total, adaptive = false }: { n: number; total: number; ad
);
}
-function FormChip({ section, title }: { section: Section; title?: string }) {
- return (
-
-
-
- {ru.flow.sections[section]}
-
- {/* The C-SSRS form title names suicide risk in capitals: the patient sees the section name only. */}
- {title && section !== "safety" && {title}}
-
- );
-}
-
// RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml).
function DrinksTable({ table }: { table: NonNullable }) {
return (
@@ -135,7 +112,8 @@ function DrinksTable({ table }: { table: NonNullable
- {table.title} {table.note}
+ {/* The form name stays hidden while answering: only the definition of a standard drink is shown. */}
+ {table.title.replace(/\s*Таблица бланка RUS-AUDIT:?/, "")} {table.note}
);
@@ -248,6 +226,9 @@ function NumberScale({
);
}
+// Sent when a free-text step is submitted empty: a comment is never required (app/dialog/flows/meds.py).
+const NO_COMMENT: DialogButton = { label: ru.flow.sendEmpty, data: "text:skip" };
+
function FreeText({
skip,
busy,
@@ -291,20 +272,34 @@ function FreeText({
@@ -699,7 +724,7 @@ export function Flow() {
}
return (
-
+
{content}
);
diff --git a/web/src/screens/Profile.tsx b/web/src/screens/Profile.tsx
index 7f05273..b319157 100644
--- a/web/src/screens/Profile.tsx
+++ b/web/src/screens/Profile.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { api } from "../api/client";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
@@ -14,8 +15,18 @@ export function Profile() {
const { session, signOut } = useSession();
const dialog = useDialog();
const [confirm, setConfirm] = useState(false);
+ const [conclusion, setConclusion] = useState(null);
const ref = useRef(null);
+ // The preliminary conclusion of stage 1 is part of the profile until the doctor's diagnosis replaces it.
+ useEffect(() => {
+ if (!session) return;
+ api
+ .results(session.token)
+ .then((r) => setConclusion(r.conclusion?.title ?? null))
+ .catch(() => undefined);
+ }, [session]);
+
useEffect(() => {
const d = ref.current;
if (!d) return;
@@ -68,6 +79,26 @@ export function Profile() {
{t.data}
+ navigate("/results")}>
+
+
+
+
+ {t.result}
+ {conclusion ?? t.resultNone}
+
+
+
+ navigate("/results")}>
+
+
+
+
+ {t.history}
+ {t.historySub}
+
+
+ navigate("/messages")}>
diff --git a/web/src/screens/Results.tsx b/web/src/screens/Results.tsx
new file mode 100644
index 0000000..9036ae9
--- /dev/null
+++ b/web/src/screens/Results.tsx
@@ -0,0 +1,118 @@
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { api } from "../api/client";
+import type { Results as ResultsData } from "../api/types";
+import { AppShell, Notice, TopBar } from "../components/AppShell";
+import { Icon } from "../components/Icon";
+import { ru } from "../content/ru";
+import { useSession } from "../session/SessionContext";
+import { dayMonth } from "./useToday";
+
+/*
+ * «Мои результаты»: the preliminary conclusion of stage 1 (a screening result, not a diagnosis) and the log of every
+ * questionnaire with its date, status and the interpretation shown when it was finished. Data come from the server
+ * (`/me/{token}/results`), so the log is the same on every device.
+ */
+export function Results() {
+ const t = ru.results;
+ const navigate = useNavigate();
+ const { session } = useSession();
+ const [data, setData] = useState(null);
+ const [failed, setFailed] = useState(false);
+ const [open, setOpen] = useState(null);
+
+ useEffect(() => {
+ if (!session) return;
+ api
+ .results(session.token)
+ .then(setData)
+ .catch(() => setFailed(true));
+ }, [session]);
+
+ const c = data?.conclusion;
+ return (
+
+